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 run(): boolean {
    const { toolAdmin, viewManager } = IModelApp;
    if (!this.isCompatibleViewport(viewManager.selectedView, false) || !toolAdmin.onInstallTool(this))
      return false;

    toolAdmin.startPrimitiveTool(this);
    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 onSelectedViewportChanged(_previous: Viewport | undefined, current: Viewport | undefined): void {
    if (this.isCompatibleViewport(current, true))
      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 perview have occured 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 onRestartTool(): void {
    const tool = new SamplePrimitiveTool();
    if (!tool.run())
      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

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 locatations using TentativePoint instead. The default IdleTool behavior of a middle mouse button click is to perform a tentative snap.

tentative example

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 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 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 onPostInstall() {
    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 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 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 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 onPostInstall() {
    super.onPostInstall();
    this.initLocateElements(); // Enable AccuSnap locate, set view cursor, add CoordinateLockOverrides to disable unwanted pre-locate point adjustments...
  }

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.

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 placment 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.

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 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 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 async onResetButtonUp(_ev: BeButtonEvent): Promise<EventHandled> {
    this.onReinitialize(); // Complete current linestring
    return EventHandled.No;
  }

  public onPostInstall() { super.onPostInstall(); this.setupAndPromptForNextAction(); }

Last Updated: 08 January, 2020