Writing a PrimitiveTool

The PrimitiveTool class serves as the base class for tools that need to create or modify geometric elements. An application's Primitive tools are often very specialized with each serving a single and specific purpose. Understanding of PrimitiveTool methods and what is expected from sub-classes is necessary for creating tools that conform to user expectations and exhibit consistent behavior.

Running the tool

Because Primitive tools often target a specific type of element, it may be undesirable to install a given Primitive tool as the active tool without first checking if all required conditions are being met.

When ToolRegistry.run is called for a Primitive tool, the following sequence of tool methods are called:

public override async run(): Promise<boolean> {
  const { toolAdmin, viewManager } = IModelApp;
  if (!this.isCompatibleViewport(viewManager.selectedView, false) || !await toolAdmin.onInstallTool(this))
    return false;

  await toolAdmin.startPrimitiveTool(this);
  await toolAdmin.onPostInstallTool(this);
  return true;
}

isCompatibleViewport

The very first decision the tool must make is whether to continue the install or leave the current tool active given a viewport identifying the target for graphical interaction. By default ViewManager.selectedView is supplied as the target viewport by PrimitiveTool.run.

The tool is responsible for checking the viewport's compatibility with the tool operation, some examples below:

  • Target isn't readonly. Checks PrimitiveTool.requireWriteableTarget, defaults to true; assumption is that most Primitive tools will insert/update elements.
  • Only applicable to spatial views.
  • Requires a specific GeometricModel be included in the view's ModelSelectorState.

If InteractiveTool.isCompatibleViewport rejects the view, then the current tool remains active and installation of the new tool stops, if the view is accepted, then we proceed to the onInstall step.

For applications that support multiple views, InteractiveTool.onSelectedViewportChanged will also call isCompatibleViewport to provide tools an opportunity to decide if they should remain active or must exit depending on their compatibility with the new selected view. The isSelectedViewChange parameter will be true in this situation.

public override async onSelectedViewportChanged(_previous: Viewport | undefined, current: Viewport | undefined) {
  if (this.isCompatibleViewport(current, true))
    return;
  return this.onRestartTool();
}

Prior to sending a button or motion event to the active tool, isCompatibleViewport is also called. If the tool rejects the view of the would-be motion event, it still remains active and the user is presented with an incompatible view cursor. A data button in an incompatible view will either be ignored (not sent to the tool), or trigger a change of the selected view. The data button behavior is controlled by the state of PrimitiveTool.targetIsLocked. Ideally a placement tool should allow the selected view to be freely changed by the first data button as long as the new view is compatible, afterwards the target view/model will be considered locked for the tool duration, see PrimitiveTool.autoLockTarget.

onInstall

Now that a target view has been accepted for the tool operation, InteractiveTool.onInstall provides one last chance before being set as the active tool to check any remaining requirements. The type of checks to consider for onInstall as opposed to isCompatibleViewport would be one time only initial conditions that would not be appropriate or necessary to test on a motion event, such as:

  • Tool requires an pre-defined SelectionSet of existing elements.

Most tools don't need to override onInstall, as long as it returns true, the new tool is set as the active tool, after which onPostInstall will be called.

onPostInstall

After becoming the active tool, InteractiveTool.onPostInstall is used to establish the initial tool state. This may include enabling AccuSnap, sending AccuDraw hints using AccuDrawHintBuilder, and showing user prompts. Because onPostInstall is paired with InteractiveTool.onCleanup, it's also a good place to register listeners for events.

Refer to AccuSnap and AccuDraw for examples showing how different types of Primitive tools can leverage these drawing aides.

onRestartTool

A Primitive tool is required to provide an implementation for PrimitiveTool.onRestartTool. This method will be called to notify the tool after iModel changes made outside of the tool's purview have occurred which may have invalidated the current tool state.

  • For example, the user requests an undo of their previous action, an element the tool is currently modifying was created in the last transaction and as such no longer exists. The tool is expected to either install a new tool instance, or exit in response to this event.

Example of typical implementation for onRestartTool:

public async onRestartTool() {
  const tool = new SamplePrimitiveTool();
  if (!await tool.run())
    return this.exitTool();
}

The default implementation of InteractiveTool.onSelectedViewportChanged also calls onRestartTool to handle isCompatibleViewport returning false. It's expected that the tool will restart with target from the new viewport if compatible, and call InteractiveTool.exitTool otherwise.

AccuSnap

AccuSnap is a aide for identifying elements and pickable decorations under the cursor. A tool can choose to enable locate, snapping, or both.

Snapping

An interactive tool that requires the user to identity a specific point on geometry can enable snapping.

snapping example

Tools that override InteractiveTool.onDataButtonDown or InteractiveTool.onDataButtonUp and use BeButtonEvent.point directly, in particular those that create new or modify existing elements, should call AccuSnap.enableSnap with true to enable snapping. Snapping allows the user to identity locations of interest to them on existing elements or pickable decorations by choosing a SnapMode and snap divisor. Snapping is used to identify points, not elements.

To be considered active, both tool and user must enable snapping; AccuSnap.isSnapEnabled and AccuSnap.isSnapEnabledByUser must both return true. The user that disables snapping through AccuSnap is choosing to identify snap locations using TentativePoint instead. The default IdleTool behavior of a middle mouse button click is to perform a tentative snap.

tentative example

Information about the current snap is presented using several on screen indicators detailed below.

snapping indicators

  1. Tentative snap preview
  2. The snap mode used to compute the point
  3. Hot snap location
  4. The snap normal

A tentative snap preview (shown with a dotted black and white plus symbol) is used as an alternative to forcing a hot snap and having the current point jump around wildly (to potentially off screen locations). SnapMode.NearestKeypoint will show a tentative snap preview when the closest keypoint is too far from the cursor to be considered "hot". The user can accept the tentative snap location either by using a TentativePoint or by moving the cursor closer to the previewed location. The hot distance is based on the settings for AccuSnap.Settings.hotDistanceFactor and ElementLocateManager.apertureInches.

When a tentative point preview is displayed but not accepted, BeButtonEvent.point will be set to the hit point on the geometry under the cursor (not the location of the tentative point preview) as opposed to being treated as unsnapped and projected to the view's ACS.

Unlike the tentative snap preview, a hot snap (shown by a yellow X symbol) indicates an accepted snap location that will be reflected in BeButtonEvent.point. Using SnapMode.Center will always force a hot snap regardless of distance from the cursor to facilitate being able to easily locate arc centers.

The snap normal (shown with a filled in-plane disc) indicates the surface normal of a solid/sheet or well defined normal for other planar geometry at the snap location. In the case of an edge snap, the snap normal combined with the edge tangent will fully define a rotation.

You can combine a TentativePoint snap with AccuSnap when using SnapMode.Intersection to identify extended intersections.

snap to extended intersection

  1. First use a TentativePoint to snap to a curve or edge
  2. AccuSnap finds the intersection with the geometry identified by the tentative with any curve/edge under the cursor

A tool with an understanding of connection points and how things fit together should not enable AccuSnap. For example, a tool to place a valve on a pipe knows to only choose pipe end points of a given diameter, it should not require the user to choose an appropriate snap point at the end of a correct pipe or try to influence AccuSnap to only generative key points it deems appropriate. This is case where locate should be enabled instead.

Example from a simple sketching tool that uses AccuSnap to create and show a linestring in dynamics:

public readonly points: Point3d[] = [];

public override onDynamicFrame(ev: BeButtonEvent, context: DynamicsContext): void {
  if (this.points.length < 1)
    return;

  const tmpPoints = this.points.slice(); // Create shallow copy of accepted points
  tmpPoints.push(ev.point.clone()); // Include current cursor location

  const builder = context.createSceneGraphicBuilder();
  builder.setSymbology(context.viewport.getContrastToBackgroundColor(), ColorDef.black, 1);
  builder.addLineString(tmpPoints);
  context.addGraphic(builder.finish()); // Show linestring in view
}

public override async onDataButtonDown(ev: BeButtonEvent): Promise<EventHandled> {
  this.points.push(ev.point.clone()); // Accumulate accepted points, ev.point has been adjusted by AccuSnap and locks

  if (!this.isDynamicsStarted)
    this.beginDynamics(); // Start dynamics on first data button so that onDynamicFrame will be called

  return EventHandled.No;
}

public override async onPostInstall() {
  await super.onPostInstall();
  IModelApp.accuSnap.enableSnap(true); // Enable AccuSnap so that linestring can be created by snapping to existing geometry
}

PrimitiveTool has a button event filter that becomes active when snapping is enabled. The range of physical geometry created or modified by tools should always be fully contained within the bounds defined by IModel.projectExtents. The purpose of InteractiveTool.isValidLocation is to reject button events that would result in geometry that exceeds the project extents. When isValidLocation returns false, the user is presented with an invalid location cursor and the button event is not sent to the tool. The default implementation of isValidLocation only checks that BeButtonEvent.point is inside the project extents, tools should override isValidLocation to implement a more robust check based on the range of the geometry they create.

Locate

locate example

A tool that only needs to identify elements and does not use BeButtonEvent.point should not enable snapping. Instead the tool should call AccuSnap.enableLocate with true to begin locating elements as the cursor moves over them. Enabling locate for AccuSnap provides the user with feedback regarding the element under the cursor in the form of a tooltip. Element's will also glow to highlight when they are of the type the tool is looking for.

When not snapping be aware that BeButtonEvent.point is not set to HitDetail.hitPoint since this represents an approximate world location based on the displayed facetted/stroked graphics. The point will instead be projected to the view's ACS. In cases where enabling snapping is not desirable but an exact hit point on the geometry is required, calling AccuSnap.doSnapRequest with SnapMode.Nearest may provide an easy alternative to getting the element geometry.

When either locate with AccuSnap is enabled, or the tool requests a new locate by calling ElementLocateManager.doLocate on a button event, InteractiveTool.filterHit will be called to give the tool an opportunity to accept or reject the element or pickable decoration identified by a supplied HitDetail. When overriding filterHit and rejecting a hit, the tool should set LocateResponse.reason to explain why the hit is being rejected; this message will be displayed on motion stop in a tooltip when an implementation for NotificationManager._showToolTip is provided.

A tool can also customize the tooltip for accepted elements in order to include tool specific details by overriding InteractiveTool.getToolTip.

Unlike snapping, only the tool needs to enable locate to make it active. By giving the user feedback about the element under the cursor before they click on it, tools are able to complete with less clicks as the need for an explicit accept/reject step after identifying the element through a button event is eliminated.

In addition to enabling AccuSnap locate, the tool should set an appropriate view cursor as well as enable the display of the locate circle. A convenient helper method is provided to set up everything a tool needs to begin locating elements, InteractiveTool.initLocateElements.

Example from a simple tool that locates elements and makes them the current selection set:

public override async filterHit(hit: HitDetail, _out?: LocateResponse): Promise<LocateFilterStatus> {
  // Check that element is valid for the tool operation, ex. query backend to test class, etc.
  // For this example we'll just test the element's selected status.
  const isSelected = this.iModel.selectionSet.has(hit.sourceId);
  return isSelected ? LocateFilterStatus.Reject : LocateFilterStatus.Accept; // Reject element that is already selected
}

public override async onDataButtonDown(ev: BeButtonEvent): Promise<EventHandled> {
  const hit = await IModelApp.locateManager.doLocate(new LocateResponse(), true, ev.point, ev.viewport, ev.inputSource);
  if (hit !== undefined)
    this.iModel.selectionSet.replace(hit.sourceId); // Replace current selection set with accepted element

  return EventHandled.No;
}

public override async onPostInstall() {
  await super.onPostInstall();
  this.initLocateElements(); // Enable AccuSnap locate, set view cursor, add CoordinateLockOverrides to disable unwanted pre-locate point adjustments...
}

Auxiliary Coordinate System

An auxiliary coordinate system or ACS defines a working plane for a view that can differ from the global coordinate system. By default every view has an ACS that is aligned with the global system. Display of the ACS triad showing the location of ACS origin and direction of the X and Y axes is enabled by setting ViewFlags.acsTriad. The drawing grid can also help visualize the working plane by using GridOrientationType.AuxCoord and enabling ViewFlags.grid.

rotated acs

Setting ToolAdmin.acsContextLock makes it easier to work in a rotated coordinate system as tools like StandardViewTool will use rotations relative to the view's ACS instead of the global system. Use AccuDrawHintBuilder.getContextRotation when writing an interactive tool to properly support this setting.

Setting ToolAdmin.acsPlaneSnapLock makes it easier to work on the ACS plane by projecting snap points into the ACS plane. Unsnapped points are always projected to the ACS plane when AccuDraw is not active.

AccuDraw

AccuDraw example

AccuDrawHintBuilder is an aide for entering coordinate data. By using shortcuts to position and orient the AccuDraw compass, locking a direction, or entering distance and angle values, the user is able to accurately enter points. AccuDraw isn't strictly controlled by the user however, the tool is also able to provide additional context to AccuDraw in the form of hints to make the tool easier to use.

When ToolAdmin.acsContextLock is enabled the AccuDraw shortcuts for orienting the compass to top, front, or side will be relative to the view's ACS instead of the global system. Additionally, when AccuDraw first becomes active when using an interactive tool it will orient itself to the view's ACS.

Some examples of AccuDraw tool hints:

  • Send AccuDraw hint to use polar mode when defining a sweep angle.
  • Send AccuDraw hint to set origin to opposite end point of line segment being modified and orient to segment direction, a new line length can be now easily specified.

Upon installing a new Primitive tool as the active tool, AccuDraw's default state is initialized to inactive. AccuDraw will upgrade its internal state to active automatically if the tool calls InteractiveTool.beginDynamics. Tools that won't start dynamics (might only use view decorations) but still wish to support AccuDraw can explicitly enable it using AccuDrawHintBuilder.activate or AccuDrawHintBuilder.sendHints. Conversely, tools that show dynamics, but do not want AccuDraw, are required to explicitly disable it by calling AccuDrawHintBuilder.deactivate.

Using the example of a tool that places a valve on a pipe again, the tool doesn't require the user to orient the valve on the closest pipe end point, it can get this information from the pipe element. As AccuDraw doesn't need to be enabled by the tool in this situation, but the tool does wish to preview the valve placement using dynamics, it should disable AccuDraw's automatic activation.

AccuDrawHintBuilder is a helper class tools can use to send hints to AccuDraw. A tool will typically send hints from InteractiveTool.onDataButtonDown, the hints are often accompanied by new tool prompts explaining what input is expected next.

Tools that enable AccuDraw, either through automatic or explicit activation, should still not rely on AccuDraw or its hints for point adjustment. The user may choose to disable AccuDraw completely, set a preference to ignore tool hints in favor of manually controlling the AccuDraw compass, or use shortcuts to override the tool's hints. If for example a tool requires the input point be projected to a particular plane in the view, even after enabling AccuDraw and sending hints to set the compass origin and rotation to define the plane, it must still correct BeButtonEvent.point to ensure it lies on the plane for the reasons previously mentioned.

AccuDrawHintBuilder provides several utility methods to help interactive tools with point adjustment.

Example from a simple sketching tool that uses AccuDrawHintBuilder to facilitate drawing orthogonal segments:

public readonly points: Point3d[] = [];

public setupAndPromptForNextAction(): void {
  // NOTE: Tool should call IModelApp.notifications.outputPromptByKey or IModelApp.notifications.outputPrompt to tell user what to do.
  IModelApp.accuSnap.enableSnap(true); // Enable AccuSnap so that linestring can be created by snapping to existing geometry

  if (0 === this.points.length)
    return;

  const hints = new AccuDrawHintBuilder();
  hints.enableSmartRotation = true; // Set initial AccuDraw orientation based on snapped geometry (ex. sketch on face of a solid)

  if (this.points.length > 1 && !(this.points[this.points.length - 1].isAlmostEqual(this.points[this.points.length - 2])))
    hints.setXAxis(Vector3d.createStartEnd(this.points[this.points.length - 2], this.points[this.points.length - 1])); // Align AccuDraw with last accepted segment

  hints.setOrigin(this.points[this.points.length - 1]); // Set compass origin to last accepted point.
  hints.sendHints();
}

public override onDynamicFrame(ev: BeButtonEvent, context: DynamicsContext): void {
  if (this.points.length < 1)
    return;

  const tmpPoints = this.points.slice(); // Create shallow copy of accepted points
  tmpPoints.push(ev.point.clone()); // Include current cursor location

  const builder = context.createSceneGraphicBuilder();
  builder.setSymbology(context.viewport.getContrastToBackgroundColor(), ColorDef.black, 1);
  builder.addLineString(tmpPoints);
  context.addGraphic(builder.finish()); // Show linestring in view
}

public override async onDataButtonDown(ev: BeButtonEvent): Promise<EventHandled> {
  this.points.push(ev.point.clone()); // Accumulate accepted points, ev.point has been adjusted by AccuSnap and locks
  this.setupAndPromptForNextAction();

  if (!this.isDynamicsStarted)
    this.beginDynamics(); // Start dynamics on first data button so that onDynamicFrame will be called

  return EventHandled.No;
}

public override async onResetButtonUp(_ev: BeButtonEvent): Promise<EventHandled> {
  await this.onReinitialize(); // Complete current linestring
  return EventHandled.No;
}

public override async onPostInstall() {
  await super.onPostInstall();
  this.setupAndPromptForNextAction();
}

Getting the Current AccuDraw Rotation

An interactive tool might want to allow the user to use AccuDraw to define the orientation of the geometry it is creating or modifying. For example using AccuDraw to define the rotation when sketching a planar shape or placing a symbol.

Tools should use AccuDrawHintBuilder.getCurrentRotation to get the current AccuDraw rotation when it is active and to fallback to either the view or ACS rotation when not active. When calling AccuDrawHintBuilder.getCurrentRotation with true for checking both AccuDraw and the view's ACS, the priority for what rotation is returned is a follows.

  1. Current AccuDraw rotation when active
  2. Current view ACS rotation when ToolAdmin.acsContextLock is enabled
  3. Current view rotation

By calling AccuDrawHintBuilder.getCurrentRotation the interactive tool doesn't need to consider if AccuDraw is active or not.

AccuDraw and Nearest Snap

You can combine AccuDraw's distance and axis locks with SnapMode.Nearest to adjust the current point to the intersection with the snapped geometry.

accudraw nearest axis lock

  1. Keypoint snap projects the closest keypoint on the snapped geometry to the locked axis
  2. Nearest snap finds the intersection of the locked axis and the snapped geometry

accudraw nearest distance lock

  1. Keypoint snap sets the current point at the locked distance along the vector from the compass origin to closest keypoint.
  2. Nearest snap finds the intersection between the circle defined by the locked distance and the snapped geometry.

Last Updated: 12 February, 2024