Skip to content

Vibe coding a plugin? 😎 Teach your agent Modlumi first. Works with Claude Code, Codex, Cursor and more.

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 is a folder with a plugin.json manifest and an entry module:

face-counter/
├── plugin.json
├── index.js
└── icons/
└── count.svg

Choose 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:

plugin.json
{
"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.

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…:

plugin.json
{
"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.

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:

hello.js
/** @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.

registerCommand adds a command to the plugin’s submenu of the Extensions menu. A command can also appear elsewhere:

commands.js
/** @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: true also lists it in the viewport’s right-click menu while something is selected.
  • toolbar: true gives it a toolbar button, in a group for your plugin after Modlumi’s tools. It needs an icon.
  • shortcut is 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.

isEnabled disables a command beyond what when says, and isChecked shows a check mark, for commands that toggle something:

toggle.js
/** @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.

context.settings keeps preferences for the current user, outside any model. Values are JSON, saved right away and not undoable:

settings.js
/** @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
}

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:

tally.js
/** @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.

Listeners run after the model, the selection, the active group or component, or the open document changed:

listeners.js
/** @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.

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:

timers.js
/** @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.

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.

points.js
/** @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]);
}
},
});
}

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():

prices.js
/** @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 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:

panel.js
/** @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.

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:

form.js
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" },
});
}

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:

dialog.js
/** @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.

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:

editable.js
/** @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.

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.

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.

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:

catalog.json
{
"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.

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.

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.

index.js
// 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(),
});
}

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.

index.js
// 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");
}

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.

index.js
// 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}`);
},
});
}

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.