Plugin tools
A plugin can add tools the user picks like Line or Move: from the plugin’s
submenu of the Extensions menu, from the toolbar, or with a shortcut.
context.registerTool() takes a
definition with handlers and returns a handle
that sets what the tool shows:
const model = modlumi.activeModel;/** @param {modlumi.PluginContext} context */export function activate(context) { const tool = context.registerTool({ id: "mark", title: "Mark Points", activate() { tool.status = "Click to add a guide point."; }, onMouseUp(event) { const point = event.point; if (point) { model.operation("Mark Point", () => model.activeEntities.addGuidePoint(point.position)); } }, });}Events
Section titled “Events”Modlumi runs a tool’s handlers once per frame for what happened since the
last one, in order: activate and deactivate, onMouseMove,
onMouseDown, onMouseUp, onDoubleClick, onKeyDown, onKeyUp,
onValue and onCancel. Handlers may edit the model inside
model.operation(), without ending the tool’s gesture, and must be
synchronous.
Each event carries the pointer’s position in
pixels and the modifier keys. Its point is the point under the pointer,
snapped as the built-in tools snap it: to endpoints, midpoints, edges and
faces, with the inferences it found and the vertex, edge or face it snapped
to. Its position is in the coordinates of the context being edited, ready
for model.activeEntities, and worldPosition in world coordinates.
picked is what the Select tool would pick there. Mouse events are for the
left button; the right button opens the context menu, as it does for the
built-in tools.
Set tool.reference to a point, such as the first click of a line, and later
points snap along the red, green and blue axes from it, the arrow keys lock
those axes, and Shift locks the current inference. Set it back to null when
the gesture ends; that also releases a lock.
A tool that only picks, such as an inspector, passes snap: false: the
pointer then shows no inferences, and events have no point.
Typed values and the status bar
Section titled “Typed values and the status bar”While a tool is active, what the user types goes to the measurement box, and
Enter hands it to onValue. Read lengths with
model.parseLength(). tool.measurement sets the
box’s label and value while nothing is typed, and tool.status the status
bar’s instruction. Escape, choosing another tool and undo end the gesture
with onCancel.
Previews
Section titled “Previews”After the handlers, the tool’s draw(view) describes its preview, which
shows until the next run. view draws
lines, polylines, filled convex polygons, points and text, in the
coordinates of the context being edited, or in screen pixels with
screen: true, in any color, width and stipple.
To animate a preview, change what draw reads from a
timer or animation;
the selected tool draws again after timers run.
Options
Section titled “Options”A tool’s options(ui) draws its settings in a card at the top left of the
viewport while the tool is selected, with the same
widgets as panels. Changing an option keeps the
gesture going and draws the preview again, so options can apply mid-gesture:
let segments = 12;/** @param {modlumi.PluginContext} context */export function activate(context) { context.registerTool({ id: "polygon", title: "Polygon", options(ui) { segments = ui.integer("Sides", segments, { min: 3, max: 64 }); }, });}Example: Vertex Editor
Section titled “Example: Vertex Editor”Vertex Editor ships with Modlumi in examples/plugins/vertex-editor. Its tool
picks a vertex up on a click and places it on the next, snapping and locking
axes along the way; typing a distance moves the vertex that far toward the
pointer, and Escape puts it back.
// An example plugin: a tool that moves one vertex at a time.//// Click a vertex to pick it up, move the pointer and click again to place it. The pointer snaps// like the built-in tools: endpoints, midpoints and faces, and the arrow keys lock the red,// green or blue axis. Type a distance and press Enter to move the vertex that far toward the// pointer. Escape puts it back. Faces the move bends fold, as with the Move tool. The tool's// Step option rounds the distance to a multiple of a length.
const model = modlumi.activeModel;/** @type {modlumi.Color} */const kAccent = { r: 1, g: 0.58, b: 0.03, a: 1 };/** @type {modlumi.Color} */const kNeighbor = { r: 0.25, g: 0.25, b: 0.25, a: 1 };
/** @type {modlumi.Vertex | null} The vertex being moved. */let held = null;/** @type {modlumi.Vertex | null} The vertex under the pointer while nothing is held. */let hovered = null;/** @type {modlumi.Point3d | null} The point under the pointer while a vertex is held. */let pointer = null;/** Rounds the distance moved to a multiple of this many meters; 0 doesn't round. */let step = 0;
/** @param {modlumi.PluginContext} context */export function activate(context) { /** Puts the tool back in its first step. */ function reset() { held = null; hovered = null; pointer = null; tool.reference = null; tool.status = "Click a vertex to move it."; tool.measurement = { label: "Distance", value: "" }; }
/** * Moves the held vertex to point in one undo step, and reports a move the model refuses. * @param {modlumi.Point3d} point */ function place(point) { const vertex = held; if (vertex === null || !vertex.isValid) { reset(); return; } try { model.operation("Move Vertex", () => { vertex.position = point; }); } catch (error) { context.showMessage(`Can't move the vertex there: ${error instanceof Error ? error.message : error}`); } reset(); }
/** * Returns where the held vertex would go: toward the pointer, by a multiple of the step. * @returns {modlumi.Point3d | null} */ function target() { if (held === null || pointer === null || step <= 0) { return pointer; } const direction = held.position.vectorTo(pointer); if (direction.length === 0) { return pointer; } return held.position.offset(direction.normalize().scale(Math.round(direction.length / step) * step)); }
/** * Tracks the vertex under the pointer, or the point under it while a vertex is held. * @param {modlumi.ToolEvent} event */ function follow(event) { if (held === null) { // Only vertices of the context being edited can move. const vertex = event.point?.vertex ?? null; hovered = vertex !== null && model.activeEntities.vertices.includes(vertex) ? vertex : null; return; } pointer = event.point?.position ?? pointer; }
const tool = context.registerTool({ id: "move-vertex", title: "Move Vertex", tip: "Moves one vertex, with inference, axis locks and typed distances.", icon: "icons/vertex.svg", toolbar: true, shortcut: "Ctrl+Alt+V", activate: reset, deactivate: reset, onMouseMove: follow, onMouseUp(event) { // A click needn't follow a move, such as right after choosing the tool with its shortcut. follow(event); if (held === null) { if (hovered !== null) { held = hovered; hovered = null; pointer = held.position; tool.reference = held.position; tool.status = "Click where the vertex goes, or type a distance."; } return; } const point = target(); if (point !== null) { place(point); } }, onValue(text) { const distance = model.parseLength(text); if (held === null || pointer === null || distance === null) { context.showMessage(`Can't move by "${text}"`); return; } const direction = held.position.vectorTo(pointer); if (direction.length === 0) { context.showMessage("Move the pointer to show the direction first."); return; } place(held.position.offset(direction.normalize().scale(distance))); }, onCancel: reset, options(ui) { step = ui.length("Step", step, { min: 0, tip: "Rounds the distance to a multiple of this length; 0 doesn't round." }); }, draw(view) { if (held === null) { if (hovered !== null) { view.points([hovered.position], { color: kAccent, width: 10 }); } return; } const point = target(); if (point === null) { return; } tool.measurement = { label: "Distance", value: model.formatLength(held.position.distance(point)) }; // The edges at the vertex, as they would run after the move. for (const edge of held.edges) { const other = edge.start === held ? edge.end : edge.start; view.lines([other.position, point], { color: kNeighbor, stipple: "dashed" }); } view.lines([held.position, point], { color: kAccent, width: 2 }); view.points([point], { color: kAccent, width: 10 }); }, });}