Writing a plugin
A script runs once and is gone. A plugin stays loaded while Modlumi runs: it
adds commands to the menus, the right-click menu and the toolbar, shows panels
and dialogs, keeps settings and data of its own, and can react when the model
changes. Plugins
use the same modlumi API as scripts. Plugins run in
the desktop app; the browser version doesn’t run them yet.
For complete, working plugins to learn from, see the modlumi-plugins repository on GitHub and Learning from the built-in plugins.
A plugin’s folder
Section titled “A plugin’s folder”A plugin is a folder with a plugin.json manifest and an entry module:
face-counter/├── plugin.json├── index.js└── icons/ └── count.svgChoose Extensions → Manage Extensions… and click Open plugins folder
to find where your plugins go, put the folder there and restart Modlumi, or
click Install plugin… and choose its plugin.json; see
Sharing a plugin. The manifest names and describes the
plugin:
{ "id": "face-counter", "name": "Face Counter", "version": "1.0.0", "description": "Counts the faces you select and keeps a running total in the model.", "author": "Modlumi example", "license": "MIT", "apiVersion": 1}| Field | Required | Meaning |
|---|---|---|
id |
yes | Stable identifier: lowercase letters, digits, dots, dashes and underscores, starting and ending with a letter or digit. |
name |
yes | Shown in the Extensions menu and window. |
version |
yes | Your plugin’s version, shown in the Extensions window. |
apiVersion |
yes | The plugin API the plugin was written for; this Modlumi provides version 1. |
entry |
no | The entry module, index.js by default. |
description |
no | One sentence about what the plugin does. |
author |
no | Who wrote it. |
license |
no | The plugin’s license. |
permissions |
no | What the plugin needs beyond the model; see Permissions. |
requires |
no | Parts of the API added since version 1 first shipped that the plugin needs, such as ["network"]; see below. |
Keep id the same across versions: Modlumi files the plugin’s settings and
model data under it.
apiVersion changes only when plugins written for the previous version would
break. Parts added to the API within a version have names: viewport,
images, files, fileFormats, network, faceEdgeData, mergeVertices
and edgeMaterials. List the ones your plugin needs under requires, so an
older Modlumi that lacks them says so instead of failing halfway. A plugin
that can do without one checks
context.features instead.
The entry module may import other modules with relative paths such as
./lib/stairs.js, from the plugin’s own folder only.
Permissions
Section titled “Permissions”A plugin works with the model, its settings and its own folder freely.
Anything beyond that it declares under permissions, and the user allows it
when installing the plugin, or later in Extensions → Manage Extensions…:
{ "id": "csv-report", "name": "CSV Report", "version": "1.0.0", "apiVersion": 1, "permissions": { "files.dialog": true, "files.pluginData": true, "network": ["api.example.com", "*.cdn.example.com"] }}| Permission | Lets the plugin |
|---|---|
files.dialog |
Show file dialogs, and read or write the files the user picks. |
files.pluginData |
Keep files in a private data folder of its own. |
network |
Make requests to the listed hosts; *. allows a host’s subdomains. |
Until the user allows them, the plugin runs without them: calls that need one
throw a PermissionError. context.permissions
says what the user granted. A new version that asks for more keeps what was
granted, and waits for the user to allow the rest. Plugins that ship with
Modlumi have their permissions from the start.
Activation
Section titled “Activation”The entry module exports an activate function. Modlumi calls it once, with a
PluginContext, after the first model has
opened. Register everything the plugin offers there:
/** @param {modlumi.PluginContext} context */export function activate(context) { context.registerCommand({ id: "hello", title: "Say Hello", run: () => context.showMessage(`Hello from ${context.name}`), });}
export function deactivate() { console.log("Goodbye");}The module stays loaded, so module-level variables keep their values between
commands. The optional deactivate export runs when the user turns the
plugin off in the Extensions window. If loading or activate throws, the
Extensions menu lists the plugin as did not load with the error in its
tooltip, and the rest of Modlumi keeps working. console.log output goes to
the application log.
Commands
Section titled “Commands”registerCommand adds a command to
the plugin’s submenu of the Extensions menu. A command can also appear
elsewhere:
/** @param {modlumi.PluginContext} context */export function activate(context) { const model = modlumi.activeModel; context.registerCommand({ id: "count", title: "Count Selected Faces", tip: "Shows how many faces are selected.", when: "selection", contextMenu: true, toolbar: true, icon: "icons/count.svg", shortcut: "Ctrl+Alt+F", run() { const faces = model.selection.toArray().filter((element) => element instanceof modlumi.Face); context.showMessage(`${faces.length} faces`); }, });}when: "selection"disables the command until something is selected.contextMenu: truealso lists it in the viewport’s right-click menu while something is selected.toolbar: truegives it a toolbar button, in a group for your plugin after Modlumi’s tools. It needs anicon.shortcutis Ctrl, Alt or Shift with a letter, digit or F1 to F12, such as"Ctrl+Alt+F". Letters and digits need Ctrl or Alt, because plain keys choose tools and type measurements. Ctrl is Cmd on macOS. A shortcut that Modlumi or another plugin already uses is dropped, and the log says so.
Edit the model inside model.operation so each
run is one undo step. Errors that a command throws are shown to the user.
An icon is an SVG file in the plugin folder. Modlumi draws it as a mask in
the toolbar’s colors, so only the opacity of its shapes counts: draw in
black, and use fill-opacity for lighter areas. Draw on a 24-unit square
with 1.5-unit strokes to match Modlumi’s own icons, which are in the source
tree under src/tools/assets/icons. Icon files can be up to 256 KiB.
Enabled and checked
Section titled “Enabled and checked”isEnabled disables a command beyond what when says, and isChecked shows
a check mark, for commands that toggle something:
/** @param {modlumi.PluginContext} context */export function activate(context) { let snapping = false; context.registerCommand({ id: "snap", title: "Snap to Grid", isChecked: () => snapping, run() { snapping = !snapping; }, }); context.registerCommand({ id: "clear-tags", title: "Clear Tags", isEnabled: () => modlumi.activeModel.entities.length > 0, run() {}, });}Modlumi doesn’t ask every frame. It asks after the plugin activates, after each of the plugin’s commands, and after the model, selection, active context or document changes, so base the answer on those. The functions must return synchronously and may only read the model.
Settings
Section titled “Settings”context.settings keeps preferences for
the current user, outside any model. Values are JSON, saved right away and not
undoable:
/** @param {modlumi.PluginContext} context */export function activate(context) { const segments = context.settings.get("segments", 24); context.settings.set("segments", segments * 2); context.settings.set("unused", undefined); // removes the setting}Data in the model
Section titled “Data in the model”context.documentData keeps one JSON value
of your plugin in the open model. It is saved in .modl files and follows
undo and redo; a new or opened document starts with that document’s value:
/** @param {modlumi.PluginContext} context */export function activate(context) { const model = modlumi.activeModel; context.registerCommand({ id: "tally", title: "Add a Box and Count It", run() { model.operation("Add counted box", () => { model.entities.addBox([0, 0, 0], 1, 1, 1); const count = Number(context.documentData.get() ?? 0); context.documentData.set(count + 1); }); }, });}Inside model.operation the change joins that operation, so the box and the
count undo together. Outside an operation, each set is an undo step of its
own. Values are limited to 1 MiB of JSON.
Reacting to changes
Section titled “Reacting to changes”Listeners run after the model, the selection, the active group or component, or the open document changed:
/** @param {modlumi.PluginContext} context */export function activate(context) { const model = modlumi.activeModel; const stop = context.onSelectionChanged(() => { console.log(`${model.selection.length} selected`); }); context.onModelChanged(() => console.log(`${model.entities.length} entities`)); context.onDocumentOpened(() => stop());}Each registration returns a function that removes the listener. Listeners run
once per frame for everything that changed during it, and receive no
arguments: read what you need from modlumi.activeModel.
Listeners only read. They run while the user may be in the middle of drawing,
so editing the model or the selection, undo and redo throw ModelError;
put edits in a command. A listener that runs longer than a second is stopped
and the plugin restarts.
Code Modlumi runs without the user asking, such as listeners, timers, overlays and panel redraws, gets at most a second per call. All plugins together share a small part of each frame for it, so when they need more, the rest waits for the next frame and panels and overlays keep showing their last drawing. A panel whose draw fails tries again a second later; one that runs too long stops until the user reopens it.
Timers and animation
Section titled “Timers and animation”context.setTimeout runs a function once after a delay in milliseconds, and
context.setInterval runs it repeatedly. context.animate runs a function
every frame, with the seconds since it started and since the previous frame,
until it returns false. Each returns an id for context.clearTimer:
/** @param {modlumi.PluginContext} context */export function activate(context) { const reminder = context.setInterval(() => context.showMessage("Remember to save"), 10 * 60 * 1000); context.registerCommand({ id: "quiet", title: "Stop Reminders", run: () => context.clearTimer(reminder), }); context.animate((elapsed) => { console.log(`${elapsed.toFixed(1)}s since the plugin started`); return elapsed < 1; });}Timers run between frames, on the same thread as everything else, so a timer
can run up to a frame late and a slow callback holds up the app; split long
work into steps. A timer may edit the model inside model.operation().
Its edit ends what the user is drawing, as a command does, but a timer that
only reads leaves it alone. Timer functions must be synchronous. A timer whose
function throws stops, and all of a plugin’s timers stop when it is turned off
or reloads.
With the files.dialog permission,
context.files.open() and save() ask the
user for a file in Modlumi’s file dialog, with a title, file kinds and a
suggested name. They resolve with the file
the user chose, or null when they cancel. The plugin reads that file, and
writes it when the user chose it to save, as text or bytes; no other file is
within its reach. A command can await the dialog: its run finishes while the
dialog is up, and continues when the user answers.
With files.pluginData, context.files.data keeps files in a private folder
of the plugin’s own, by name, such as "cache/parts.json": readText,
readBytes, writeText, writeBytes, remove and list.
Importers and exporters
Section titled “Importers and exporters”context.registerImporter adds a File →
Import entry for a format: Modlumi asks the user for a file with one of its
extensions and hands it to import, which reads it into the model inside
one undoable operation. Dropping such a file on the window imports it too.
context.registerExporter adds a File → Export entry whose export writes
the model to the file the user picked. Neither needs a permission, since the
user chose the file in Modlumi’s own menu; both must be synchronous, and an
exporter only reads the model. Modlumi keeps .modl and .skp to itself.
The STL and OBJ plugins that ship with Modlumi, in plugins/stl and
plugins/obj, are complete importers and exporters.
/** @param {modlumi.PluginContext} context */export function activate(context) { const model = modlumi.activeModel; context.registerImporter({ id: "xyz", name: "Point List", extensions: ["xyz"], import(file) { for (const line of file.readText().trim().split("\n")) { const [x, y, z] = line.split(/\s+/).map(Number); model.activeEntities.addGuidePoint([x, y, z]); } }, });}Network
Section titled “Network”With the network permission for a host, and the user’s
approval, context.fetch(url, options)
sends a request there and resolves with its response. Only the hosts the
manifest lists are reachable, over http or https; redirects come back as
responses for the plugin to follow, so they never lead elsewhere. A command can
await the response like a file dialog. Read the body in the code that
awaits it, with text(), json() or bytes():
/** @param {modlumi.PluginContext} context */export function activate(context) { context.registerCommand({ id: "price", title: "Look Up Price", async run() { const response = await context.fetch("https://api.example.com/prices?part=beam"); if (response.ok) { context.showMessage(`Beams cost ${/** @type {{ price: number }} */ (response.json()).price}`); } }, });}Requests work in desktop builds on Linux and macOS for now.
Panels and dialogs
Section titled “Panels and dialogs”Panels and dialogs use the same widgets as Modlumi’s own panels.
addPanel adds a dockable panel, listed in
the Window menu where the user opens and closes it. Modlumi remembers which
panels were open and where. The panel’s draw function describes its widgets:
/** @param {modlumi.PluginContext} context */export function activate(context) { const model = modlumi.activeModel; const box = { width: 1, depth: 1, height: 1 }; const panel = context.addPanel({ id: "box", title: "Box", draw(ui) { box.width = ui.length("Width", box.width, { min: 0.01 }); box.depth = ui.length("Depth", box.depth, { min: 0.01 }); box.height = ui.length("Height", box.height, { min: 0.01 }); if (ui.button("Add Box", { primary: true })) { model.operation("Add box", () => model.entities.addBox([0, 0, 0], box.width, box.depth, box.height)); } }, }); context.registerCommand({ id: "box", title: "Box Panel", run: () => panel.show() });}Modlumi calls draw every frame while the panel is open. Each value widget
takes the current value and returns it, or what the user changed it to, so
box.width = ui.length("Width", box.width) keeps the plugin’s state and the
panel in step. A button returns true when it was clicked.
draw only reads the model, like a listener, with one exception: the draw
right after the user changed a widget or clicked a button may edit the model.
That is where widgets return the change, so if (ui.button(...)) model.operation(...) works as written. draw must be synchronous.
Widgets
Section titled “Widgets”| Kind | Widgets |
|---|---|
| Text | text (with a tone), heading, separator, spacing, readOnly |
| Typing | textField, textBox |
| Numbers | number, integer, length (in the model’s units), angle, slider |
| Choices | checkbox, toggle, radio, dropdown, color |
| Actions | button, with primary: true for the main one |
| Layout | section, row, columns, tabs, disabled |
| Progress | progress |
| Data | tree, table with sortable columns, canvas for 2D drawings |
Widgets shows each of them. Length fields accept what
the measurements box accepts, such as 1.2m, 18cm or 7", and angles are
radians shown in degrees. Every widget takes a tip and disabled option.
Consecutive fields line up in a form, labels on the left.
A widget is identified by its label within the sections, tabs and columns
around it. Keep labels stable, or give a widget whose label changes an id.
For simple parameter lists, ui.form draws one field
per schema entry and returns the changed values:
let options = { segments: 24, smooth: true };/** @param {modlumi.Ui} ui */function draw(ui) { options = ui.form(options, { segments: { type: "integer", label: "Segments", min: 3 }, smooth: { type: "checkbox", label: "Smooth" }, });}Dialogs
Section titled “Dialogs”showDialog shows a dialog with the
same kind of draw. Showing the same id again brings back that dialog. A
modal dialog blocks the rest of Modlumi until it closes. ui.close() closes
the window from inside draw; onClose runs when the user closes it:
/** @param {modlumi.PluginContext} context */export function activate(context) { let name = "Stairs"; context.registerCommand({ id: "rename", title: "Name the Stairs...", run() { context.showDialog({ id: "rename", title: "Name", modal: true, draw(ui) { name = ui.textField("Name", name); if (ui.button("OK", { primary: true })) { ui.close(); } }, }); }, });}For a quick message or question, use context.alert(message) or
context.confirm(message, { onConfirm }). onConfirm may edit the model.
Editable objects
Section titled “Editable objects”A plugin can keep a JSON value on each group or component instance it made,
with context.entityData. Like document data,
it is saved with the model and undoable, and copies of the object keep it.
Component definitions and materials hold values the same way, for data that
belongs to every instance, such as a part name for a cut list, or to a
material, such as its thickness. Faces and edges hold them too, such as a tile
pattern on a floor.
- A face or edge that splits, like a face cut by a new line, passes its value to every piece, so keep values that must be unique, such as ids, on groups.
- When two faces or edges become one, the value of the one that stays wins.
- Modlumi never reads your values. Put a version in them, like
{ version: 2, ... }, and convert older ones when you read them, so models saved with an earlier version of your plugin keep working.
While the selection is one group or instance holding the plugin’s value,
Modlumi shows the plugin’s
Properties sections. Their
draw receives the selected object, so the plugin can show its parameters and
rebuild it when they change:
/** @param {modlumi.PluginContext} context */export function activate(context) { context.addPropertiesSection({ id: "box", title: "Box", draw(ui, container) { const size = Number(context.entityData.get(container)); const changed = ui.length("Size", size, { min: 0.01 }); if (changed !== size && container instanceof modlumi.Group) { modlumi.activeModel.operation("Resize box", () => { container.makeUnique(); for (const nested of container.entities.ofType("Group")) { container.entities.erase(nested); } container.entities.addBox([0, 0, 0], changed, changed, changed); context.entityData.set(container, changed); }); } }, });}As in a panel, the draw that reports a change may edit the model. Call
makeUnique() before editing a group’s entities, so copies that share them
stay as they are.
Developing a plugin
Section titled “Developing a plugin”In the Extensions window, turn on Reload plugins when their files change.
Modlumi then watches your plugin folder: when you save a file, it calls
deactivate, reads plugin.json and the entry module again and activates the
plugin anew. Your commands and listeners are registered from scratch and
module variables start over; settings and model data stay. A message reports
each reload or why it failed.
The Extensions window also turns plugins on and off, lists their versions and authors, and shows why a plugin did not load.
Sharing a plugin
Section titled “Sharing a plugin”Zip your plugin folder to share it. The archive may hold plugin.json at its
root, or the plugin folder itself. Users click Install plugin… in
Extensions → Manage Extensions… and choose the zip, or the plugin.json
of an unzipped folder. Modlumi shows the plugin’s name, version, author, the
permissions it asks for and the SHA-256 of its files. Installing copies the
plugin to the plugins folder, allows those permissions and starts it, without a
restart. Installing a plugin with the same id replaces the installed version.
Hidden files and folders, whose names start with a dot such as .git, aren’t
part of a plugin and aren’t installed. A plugin can’t hold symbolic links.
Modlumi remembers the SHA-256 of each version it installed. If an installed
plugin’s files change afterwards, it doesn’t load until the user installs it
again; give each release a new version. Plugins put in the plugins folder by
hand aren’t checked, so your plugin keeps reloading while you develop it. Develop in
a folder of your own rather than in an installed copy: an installed plugin
stays checked, through Modlumi’s record and a hidden .modlumi-install file in
its folder.
The hash covers the plugin’s files, wherever they come from: for each file,
sorted by its path with / between folders, the path in UTF-8, a zero byte,
the file’s size as 8 little-endian bytes, then its content. Hovering a
plugin’s version line in the Extensions window shows it.
The plugin catalog
Section titled “The plugin catalog”A catalog is a JSON file that says which plugin versions were reviewed, for example one kept in a git repository. Paste its https URL under Plugin catalog in the Extensions window. Installed plugins and the install dialog then show Reviewed or Community with the catalog’s host, which vouches for them, or warn when a listed version has other files than the catalog describes:
{ "plugins": [ { "id": "csv-report", "status": "reviewed", "versions": [ { "version": "1.0.0", "sha256": "edf8e07b4629031a815fffe669bd22f2dc5972911c3c8405e249ae2ebbd1009a" }, { "version": "1.1.0", "sha256": "…", "status": "community" } ] } ]}status is reviewed when the catalog’s maintainers read the plugin’s code,
and community when its author listed it without a review. A version may set
its own status.
Type checking
Section titled “Type checking”Add a tsconfig.json next to your plugin folders, like the one in
Getting started,
including */*.js and modlumi.d.ts, and annotate activate with
/** @param {modlumi.PluginContext} context */ to get completion and type
errors in your editor.
Example: Stair Builder
Section titled “Example: Stair Builder”Stair Builder ships with Modlumi in examples/plugins/stair-builder. Its
panel holds stair parameters, a warning when the risers are too high and a
button that builds the stair as a group. The stair keeps its parameters, and
selecting it shows them in Properties, where changing one rebuilds the stair.
// An example plugin: a panel of stair parameters and a button that builds the stair, which// stays editable from Properties.//// A draw runs every frame. Each widget takes a value from the parameters and returns it,// changed when the user edited it, so the window always shows the plugin's state. The draw// that reports a change may edit the model: the panel builds on Build, and the Properties// section rebuilds the selected stair as soon as one of its parameters changes.
/** * @typedef {"Solid" | "Open treads"} Style * @typedef {{ * totalRise: number, steps: number, run: number, width: number, turn: number, * style: Style, nosing: number, posts: boolean, * }} Stair */
const model = modlumi.activeModel;// Comfortable risers are up to about 18 cm.const kMaxComfortableRiser = 0.18;/** @type {Style[]} */const kStyles = ["Solid", "Open treads"];
/** The parameters of the next stair the panel builds. @type {Stair} */const next = { totalRise: 2.7, steps: 15, run: 0.28, width: 1, turn: 0, style: "Solid", nosing: 10, posts: false,};
/** * Draws the stair parameters and returns them, changed where the user edited them. * @param {modlumi.Ui} ui * @param {Stair} stair * @returns {Stair} */function drawParameters(ui, stair) { const result = { ...stair }; result.totalRise = ui.length("Total rise", stair.totalRise, { min: 0.1 }); result.steps = ui.integer("Steps", stair.steps, { min: 1, max: 100 }); result.run = ui.length("Tread depth", stair.run, { min: 0.1 }); result.width = ui.length("Width", stair.width, { min: 0.3 }); result.turn = ui.angle("Turn", stair.turn, { tip: "Rotation about the vertical axis" }); result.style = ui.dropdown("Style", stair.style, kStyles); ui.section("Details", () => { result.nosing = ui.slider("Nosing", stair.nosing, { min: 0, max: 30, decimals: 0, suffix: "%", disabled: result.style === "Solid", tip: "How far open treads overhang the tread below, as a share of the tread depth", }); result.posts = ui.checkbox("Railing posts", stair.posts); }); const rise = result.totalRise / result.steps; ui.readOnly("Riser height", model.formatLength(rise)); ui.readOnly("Pitch", `${((Math.atan2(rise, result.run) * 180) / Math.PI).toFixed(1)}°`); if (rise > kMaxComfortableRiser) { ui.text("Risers this high are hard to climb; add steps.", { tone: "warning" }); } return result;}
/** * Draws the stair's steps and posts into entities, in the stair's own coordinates. * @param {modlumi.Entities} entities * @param {Stair} stair */function drawStair(entities, stair) { const rise = stair.totalRise / stair.steps; if (stair.style === "Solid") { // The side profile, from the bottom of the first riser up the steps and back down. /** @type {modlumi.Point3dLike[]} */ const profile = [[0, 0, 0]]; for (let step = 0; step < stair.steps; ++step) { profile.push([step * stair.run, 0, (step + 1) * rise], [(step + 1) * stair.run, 0, (step + 1) * rise]); } profile.push([stair.steps * stair.run, 0, 0]); entities.addFace(profile).extrude(stair.width); } else { const nosing = (stair.run * stair.nosing) / 100; const thickness = Math.min(0.04, rise / 2); for (let step = 0; step < stair.steps; ++step) { entities.addBox([step * stair.run - nosing, 0, (step + 1) * rise - thickness], stair.run + nosing, stair.width, thickness); } } if (stair.posts) { for (let step = 0; step < stair.steps; ++step) { entities.addCylinder([(step + 0.5) * stair.run, 0.05, (step + 1) * rise], 0.02, 0.9); } }}
/** * Rotates about the vertical axis through the group's own origin. * @param {modlumi.Group} group * @param {number} angle */function turn(group, angle) { const origin = group.transformation.multiply(new modlumi.Point3d(0, 0, 0)); group.transform(modlumi.Transformation.rotation(origin, [0, 0, 1], angle));}
/** @param {modlumi.PluginContext} context */export function activate(context) { /** Builds a stair as a new group at the origin that keeps its parameters. One undo step. */ function build() { model.operation("Build Stairs", () => { const group = model.entities.addGroup(); group.name = "Stairs"; drawStair(group.entities, next); turn(group, next.turn); context.entityData.set(group, next); }); }
/** * Replaces the stair in group with one built from new parameters. One undo step. * @param {modlumi.Group} group * @param {Stair} old * @param {Stair} stair */ function rebuild(group, old, stair) { model.operation("Change Stairs", () => { // Copies of the stair share its entities until one of them changes. group.makeUnique(); const entities = group.entities; for (const nested of entities.ofType("Group")) { entities.erase(nested); } entities.eraseEdges(entities.ofType("Edge")); drawStair(entities, stair); turn(group, stair.turn - old.turn); context.entityData.set(group, stair); }); }
const panel = context.addPanel({ id: "stairs", title: "Stair Builder", draw(ui) { Object.assign(next, drawParameters(ui, next)); ui.spacing(); if (ui.button("Build", { primary: true })) { build(); context.showMessage(`Built ${next.steps} steps`); } }, });
context.addPropertiesSection({ id: "stair", title: "Stair", draw(ui, container) { /** @type {Stair | undefined} */ const stair = context.entityData.get(container); if (stair === undefined || !(container instanceof modlumi.Group)) { return; } const changed = drawParameters(ui, stair); if (JSON.stringify(changed) !== JSON.stringify(stair)) { rebuild(container, stair, changed); } }, });
context.registerCommand({ id: "open", title: "Stair Builder", tip: "Opens the stair parameters.", run: () => panel.show(), });}Example: Face Counter
Section titled “Example: Face Counter”Face Counter ships with Modlumi in examples/plugins/face-counter. It counts
the selected faces into a running total kept in the model, with a toolbar
button, a right-click entry and a shortcut, a reset command that is only
enabled while there is a total, and a setting with a check mark.
// An example plugin: counts the selected faces and keeps a running total in the model.//// The total is document data, so it is saved in .modl files and each count is one undo step.// Whether hidden faces count is a setting of the user, kept outside the model.
/** * The plugin's value in the model. * @typedef {{ total: number, counts: number }} Tally */
/** @param {modlumi.PluginContext} context */export function activate(context) { const model = modlumi.activeModel; /** @returns {Tally} */ const tally = () => context.documentData.get() ?? { total: 0, counts: 0 }; const countHidden = () => context.settings.get("countHidden", false);
context.registerCommand({ id: "count", title: "Count Selected Faces", tip: "Adds the selected faces to the model's running total.", when: "selection", contextMenu: true, toolbar: true, icon: "icons/count.svg", shortcut: "Ctrl+Alt+F", run() { const faces = model.selection.toArray().filter( (element) => element instanceof modlumi.Face && (countHidden() || !element.hidden), ).length; const { total, counts } = tally(); // Outside model.operation, each set is an undo step of its own. context.documentData.set({ total: total + faces, counts: counts + 1 }); context.showMessage(`${faces} faces; ${total + faces} counted in this model`); }, });
context.registerCommand({ id: "reset", title: "Reset Face Count", // Asked again after every change to the model, so it follows undo and redo. isEnabled: () => tally().counts > 0, run() { context.documentData.set(undefined); context.showMessage("Face count reset"); }, });
context.registerCommand({ id: "count-hidden", title: "Count Hidden Faces", isChecked: countHidden, run() { context.settings.set("countHidden", !countHidden()); }, });
// Listeners may only read. This one reports the total of each model that opens. context.onDocumentOpened(() => { const { total } = tally(); if (total > 0) { console.log(`Face Counter: this model has ${total} counted faces`); } });}
export function deactivate() { console.log("Face Counter turned off");}Example: CSV Report
Section titled “Example: CSV Report”CSV Report ships with Modlumi in examples/plugins/csv-report. It asks only
for files.dialog, and writes a spreadsheet of the model’s groups and
components, with their faces and areas, to the file the user picks.
// An example plugin: writes a CSV report of the model's groups and components to a file the// user chooses. It asks only for the "files.dialog" permission, so it can write the file the// user picks in the save dialog, and no other.
const model = modlumi.activeModel;
/** * Quotes a CSV cell when it holds a comma, a quote or a line break. * @param {unknown} value */function cell(value) { const text = String(value); return /[",\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;}
/** Returns the report's rows, a header first: one row per group or component at the top level. */function getRows() { /** @type {unknown[][]} */ const rows = [["Name", "Type", "Faces", "Area"]]; for (const entity of model.entities) { /** @type {modlumi.Entities} */ let entities; let name; if (entity instanceof modlumi.Group) { entities = entity.entities; name = entity.name || "Group"; } else if (entity instanceof modlumi.ComponentInstance) { entities = entity.definition.entities; name = entity.name || entity.definition.name; } else { continue; } const faces = entities.ofType("Face"); // As placed in the model, which may scale the instance. const area = faces.reduce((sum, face) => sum + face.transformedArea(entity.transformation), 0); rows.push([name, entity instanceof modlumi.Group ? "Group" : "Component", faces.length, model.formatArea(area)]); } return rows;}
/** @param {modlumi.PluginContext} context */export function activate(context) { context.registerCommand({ id: "export", title: "Export CSV Report...", tip: "Writes the model's groups and components, with their faces and areas, to a CSV file.", async run() { const rows = getRows(); const file = await context.files.save({ title: "Export CSV report", name: "report.csv", filters: [{ name: "CSV spreadsheet", extensions: ["csv"] }], }); if (file === null) { return; } file.writeText(rows.map((row) => row.map(cell).join(",")).join("\n") + "\n"); context.showMessage(`Wrote ${rows.length - 1} rows to ${file.name}`); }, });}Learning from the built-in plugins
Section titled “Learning from the built-in plugins”The ten plugins that come with Modlumi are written with the same API, and their source is on the Plugins page and in the modlumi-plugins repository on GitHub. Each one shows a few techniques in a real plugin:
| Plugin | Shows |
|---|---|
| Face Area | The smallest one: a command in the right-click menu that walks into groups and components. |
| Select Similar | Reading and changing the selection, comparing faces by plane, normal and material. |
| Clean Up | A dialog of options remembered in settings, and many model edits in one undo step. |
| Modify Solids | Solid operations on groups and components that keep their names, paint and placement. |
| Shapes | Parametric objects: parameters kept with entityData and edited in Properties. |
| OBJ and STL | Importers and exporters, parsing text and binary files and building meshes with MeshBuilder. |
| Levels | Document data, a panel, a viewport overlay, pickable items and a snap provider. |
| Solid Inspector | A tool that highlights problems, steps the camera to each one and fixes them. |
| True Bend | A tool with a live preview, typed values, keyboard input and options. |