Plugins
Modlumi comes with ten plugins. Most are ports of well-known open-source SketchUp extensions, rewritten for Modlumi’s plugin API. Plugins run in the desktop app; the browser preview doesn’t run them yet.
Each plugin’s source code is under its description and in the modlumi-plugins repository on GitHub. They are complete, working plugins, so they’re a good place to see how to build your own; see Learning from the built-in plugins.
Use and manage plugins
Section titled “Use and manage plugins”Each plugin has a submenu in the Extensions menu with its commands. Some commands also appear in the viewport’s right-click menu while something is selected, importers and exporters add File → Import and File → Export items, and plugin panels are listed in the Window menu. A plugin’s edit to the model is one undo step.
Extensions → Manage Extensions… lists every plugin. The built-in ones are
marked Built in; turn any plugin off or on with its Enabled switch.
Built-in plugins can be turned off but not removed. To add someone else’s plugin,
choose Install plugin… and pick its zip archive or the plugin.json in its
folder. To make your own, see Writing a plugin.
Clean Up
Section titled “Clean Up”Tidies a model in one step: purges unused components and materials, merges coplanar faces, repairs edges split by an unneeded vertex, erases stray edges and, if you ask, hidden geometry. It can also merge identical materials and smooth edges between faces that meet at a shallow angle.
Choose Extensions → Clean Up → Clean Up…, pick what to clean and whether to clean the whole model or only the group you’re editing, and click Clean Up. Clean Up with Last Settings repeats it without the dialog, and the submenu has each step as a command of its own.
Port of CleanUp by Thomas Thomassen (MIT).
Source code
{ "id": "cleanup", "name": "Clean Up", "version": "1.0.0", "description": "Purges unused components and materials, merges coplanar faces and identical materials, repairs split edges, erases stray edges and hidden geometry, and smooths edges.", "author": "Modlumi, after CleanUp by Thomas Thomassen", "license": "MIT", "apiVersion": 1}// Cleans up models, especially imported ones: purges unused components and materials, merges// coplanar faces and identical materials, repairs split edges, erases stray edges and hidden// geometry, and smooths edges by angle, all in one undo step.//// A port of CleanUp (https://github.com/thomthom/cleanup)://// Copyright (c) 2009 Thomas Thomassen//// Permission is hereby granted, free of charge, to any person obtaining a copy of this software// and associated documentation files (the "Software"), to deal in the Software without// restriction, including without limitation the rights to use, copy, modify, merge, publish,// distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the// Software is furnished to do so, subject to the following conditions://// The above copyright notice and this permission notice shall be included in all copies or// substantial portions of the Software.//// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING// BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
/** * @typedef {"Whole model" | "Context being edited"} Scope * @typedef {{ * scope: Scope, * purge: boolean, * eraseHidden: boolean, * mergeFaces: boolean, * ignoreMaterials: boolean, * mergeMaterials: boolean, * repairEdges: boolean, * strayEdges: boolean, * smoothAngle: number, * }} Options * @typedef {Record<string, number>} Statistics */
const model = modlumi.activeModel;/** @type {Scope[]} */const kScopes = ["Whole model", "Context being edited"];
/** @type {Options} */const kDefaults = { scope: "Whole model", purge: true, eraseHidden: false, mergeFaces: true, ignoreMaterials: false, mergeMaterials: false, repairEdges: true, strayEdges: true, smoothAngle: 0,};
/** @type {Record<string, modlumi.FormField>} */const kSchema = { scope: { type: "radio", label: "Scope", choices: kScopes }, purge: { type: "checkbox", label: "Purge unused components and materials" }, eraseHidden: { type: "checkbox", label: "Erase hidden geometry" }, mergeFaces: { type: "checkbox", label: "Merge coplanar faces" }, ignoreMaterials: { type: "checkbox", label: "Merge faces with different materials", tip: "Coplanar faces merge even when their materials differ", }, mergeMaterials: { type: "checkbox", label: "Merge identical materials", tip: "Materials with the same color and image become one, across the whole model", }, repairEdges: { type: "checkbox", label: "Repair split edges" }, strayEdges: { type: "checkbox", label: "Erase stray edges", tip: "Edges that bound no face" }, smoothAngle: { type: "angle", label: "Smooth edges below", min: 0, max: Math.PI / 2, tip: "Softens and smooths edges between faces that meet at less than this angle; 0 smooths none", },};
/** * The drawing contexts in scope: the context being edited, or the model root and every * component definition. * @param {Scope} scope * @returns {modlumi.Entities[]} */function getContexts(scope) { if (scope === "Context being edited") { return [model.activeEntities]; } return [model.entities, ...model.definitions.map((definition) => definition.entities)];}
/** * Erases hidden faces, containers and guides, then hidden or soft edges that no visible face * needs. Returns how many elements went. * @param {modlumi.Entities} entities */function eraseHidden(entities) { let erased = 0; for (const element of entities.toArray()) { if (element.type !== "Edge" && element.isValid && element.hidden) { entities.erase(element); erased += 1; } } const edges = entities.ofType("Edge").filter((edge) => (edge.hidden || edge.soft) && edge.faces.every((face) => face.hidden)); return erased + eraseEdges(entities, edges);}
/** * Erases edges, skipping curves of which only some edges were chosen, because erasing one * edge of a curve erases all of it. Returns how many edges went. * @param {modlumi.Entities} entities * @param {modlumi.Edge[]} edges */function eraseEdges(entities, edges) { const chosen = new Set(edges); const erasable = edges.filter((edge) => edge.curve === null || edge.curve.edges.every((member) => chosen.has(member))); if (erasable.length > 0) { entities.eraseEdges(erasable); } return erasable.length;}
/** * Softens and smooths edges between two faces that meet at less than angle. Returns how many * edges changed. * @param {modlumi.Entities} entities * @param {number} angle */function smoothEdges(entities, angle) { let smoothed = 0; for (const edge of entities.ofType("Edge")) { const [first, second, ...rest] = edge.faces; if (!first || !second || rest.length > 0 || !first.normal || !second.normal || (edge.soft && edge.smooth)) { continue; } if (first.normal.angleBetween(second.normal) <= angle) { edge.soft = true; edge.smooth = true; smoothed += 1; } } return smoothed;}
/** * Whether two materials look the same: the same color and the same image at the same size. * @param {modlumi.Material} a * @param {modlumi.Material} b */function isIdentical(a, b) { const [ca, cb] = [a.color, b.color]; if (ca.r !== cb.r || ca.g !== cb.g || ca.b !== cb.b || ca.a !== cb.a) { return false; } const [ta, tb] = [a.texture, b.texture]; if (ta === null || tb === null) { return ta === tb; } return ta.width === tb.width && ta.height === tb.height && ta.image.length === tb.image.length && ta.image.every((byte, index) => byte === tb.image[index]);}
/** * Makes faces, edges and containers in every context use one of each set of identical materials, * and removes the others. Returns how many materials went. */function mergeMaterials() { /** @type {Map<modlumi.Material, modlumi.Material>} */ const replacements = new Map(); const materials = [...model.materials]; for (let index = 0; index < materials.length; ++index) { const kept = materials[index]; if (replacements.has(kept)) { continue; } for (const other of materials.slice(index + 1)) { if (!replacements.has(other) && isIdentical(kept, other)) { replacements.set(other, kept); } } } if (replacements.size === 0) { return 0; } /** @param {modlumi.Material | null} material */ const replace = (material) => (material === null ? null : replacements.get(material) ?? material); for (const entities of getContexts("Whole model")) { for (const face of entities.ofType("Face")) { face.material = replace(face.material); face.backMaterial = replace(face.backMaterial); } for (const edge of entities.ofType("Edge")) { edge.material = replace(edge.material); } for (const container of [...entities.ofType("Group"), ...entities.ofType("ComponentInstance")]) { container.material = replace(container.material); } } for (const material of replacements.keys()) { model.removeMaterial(material); } return replacements.size;}
/** * Adds amount to a statistic. * @param {Statistics} statistics * @param {string} name * @param {number} amount */function count(statistics, name, amount) { statistics[name] = (statistics[name] ?? 0) + amount;}
/** * Cleans up with the options as one undo step and returns what it did. * @param {Options} options * @returns {Statistics} */function cleanUp(options) { /** @type {Statistics} */ const statistics = {}; model.operation("Clean Up", () => { const purge = () => { const purged = model.purgeUnused(); count(statistics, "Components purged", purged.definitions); count(statistics, "Materials purged", purged.materials); }; if (options.eraseHidden) { for (const entities of getContexts(options.scope)) { count(statistics, "Hidden elements erased", eraseHidden(entities)); } } // Purging first leaves fewer contexts to clean; purging again removes what cleaning orphaned. if (options.purge) { purge(); } if (options.mergeMaterials) { count(statistics, "Materials merged", mergeMaterials()); } for (const entities of getContexts(options.scope)) { if (options.mergeFaces) { count(statistics, "Edges between coplanar faces erased", entities.mergeCoplanarFaces({ sameMaterials: !options.ignoreMaterials })); } if (options.strayEdges) { const stray = entities.ofType("Edge").filter((edge) => edge.faces.length === 0); count(statistics, "Stray edges erased", eraseEdges(entities, stray)); } if (options.repairEdges) { count(statistics, "Split edges repaired", entities.mergeCollinearEdges()); } if (options.smoothAngle > 0) { count(statistics, "Edges smoothed", smoothEdges(entities, options.smoothAngle)); } } if (options.purge) { purge(); } }); return statistics;}
/** * Describes the statistics in one line per change, or says nothing needed cleaning. * @param {Statistics} statistics */function describe(statistics) { const lines = Object.entries(statistics).filter(([, value]) => value > 0).map(([name, value]) => `${name}: ${value}`); return lines.length > 0 ? lines.join("\n") : "The model was already clean.";}
/** @param {modlumi.PluginContext} context */export function activate(context) { /** The last options used, which the dialog starts from. @returns {Options} */ const lastOptions = () => ({ ...kDefaults, ...context.settings.get("options", {}) });
/** @param {Options} options */ function run(options) { try { context.settings.set("options", options); } catch (error) { console.warn(`Could not remember the cleanup options: ${error}`); } context.alert(describe(cleanUp(options)), { title: "Clean Up" }); }
context.registerCommand({ id: "clean", title: "Clean Up...", tip: "Chooses what to clean, then cleans the model in one undo step.", run() { let options = lastOptions(); context.showDialog({ id: "options", title: "Clean Up", modal: true, draw(ui) { options = /** @type {Options} */ (ui.form(options, kSchema)); ui.spacing(); ui.row(() => { if (ui.button("Clean Up", { primary: true })) { ui.close(); run(options); } if (ui.button("Cancel")) { ui.close(); } }); }, }); }, }); context.registerCommand({ id: "clean-again", title: "Clean Up with Last Settings", tip: "Cleans the model with the options used last time.", run: () => run(lastOptions()), });
/** Commands that run one cleanup step on the context being edited, or the model. */ const steps = /** @type {const} */ ([ ["purge", "Purge Unused", "Removes components and materials nothing uses.", { purge: true }], ["merge-faces", "Merge Coplanar Faces", "Erases edges between coplanar faces with the same materials.", { mergeFaces: true }], ["merge-materials", "Merge Identical Materials", "Makes identical materials one.", { mergeMaterials: true }], ["repair-edges", "Repair Split Edges", "Joins collinear edges split by an unneeded vertex.", { repairEdges: true }], ["stray-edges", "Erase Stray Edges", "Erases edges that bound no face.", { strayEdges: true }], ["erase-hidden", "Erase Hidden Geometry", "Erases hidden faces, edges, groups and components.", { eraseHidden: true }], ]); /** @type {Options} */ const nothing = { scope: "Context being edited", purge: false, eraseHidden: false, mergeFaces: false, ignoreMaterials: false, mergeMaterials: false, repairEdges: false, strayEdges: false, smoothAngle: 0, }; for (const [id, title, tip, enabled] of steps) { context.registerCommand({ id, title, tip, run: () => context.showMessage(describe(cleanUp({ ...nothing, ...enabled })).replaceAll("\n", "; ")), }); }}Face Area
Section titled “Face Area”Totals the area of the selected faces, including faces inside selected groups and components, in the model’s units.
Select faces, then choose Total Face Area from the right-click menu or Extensions → Face Area. It changes nothing in the model.
Port of Eneroth Face Area Counter by Julia Christina Eneroth (MIT).
Source code
{ "id": "face-area", "name": "Face Area", "version": "1.0.0", "description": "Totals the area of the selected faces in the model's units.", "author": "Modlumi, after Eneroth Face Area Counter by Julia Christina Eneroth", "license": "MIT", "apiVersion": 1}// Totals the area of the selected faces, including faces inside selected groups and components.// A port of Eneroth Face Area Counter (MIT) by Julia Christina Eneroth:// https://github.com/Eneroth3/eneroth-face-area-counter//// Copyright (c) 2017 Julia Christina Eneroth//// Permission is hereby granted, free of charge, to any person obtaining a copy of this software// and associated documentation files (the "Software"), to deal in the Software without// restriction, including without limitation the rights to use, copy, modify, merge, publish,// distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the// Software is furnished to do so, subject to the following conditions://// The above copyright notice and this permission notice shall be included in all copies or// substantial portions of the Software.//// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING// BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
// Adds the area of every face in elements, descending into containers. Areas are measured in// each face's own context, so scaled instances report their definition's area./** * @param {Iterable<modlumi.DrawingElement>} elements * @param {{ area: number, faces: number }} totals */function countFaces(elements, totals) { for (const element of elements) { if (element instanceof modlumi.Face) { totals.area += element.area; totals.faces += 1; } else if (element instanceof modlumi.Group) { countFaces(element.entities, totals); } else if (element instanceof modlumi.ComponentInstance) { countFaces(element.definition.entities, totals); } } return totals;}
/** @param {modlumi.PluginContext} context */export function activate(context) { context.registerCommand({ id: "total-area", title: "Total Face Area", tip: "Shows the total area of the selected faces, including those inside groups and components.", when: "selection", contextMenu: true, run() { const model = modlumi.activeModel; const { area, faces } = countFaces(model.selection, { area: 0, faces: 0 }); if (faces === 0) { context.showMessage("The selection has no faces."); return; } context.showMessage(`${model.formatArea(area)} in ${faces} ${faces === 1 ? "face" : "faces"}`); }, });}Levels
Section titled “Levels”Keeps a building’s floor levels in the model. In front, back and side views each level shows as a dashed line with its name and height, and points snap to level heights while you draw.
Choose Extensions → Levels → Levels to open the panel. Add Level adds the ground floor first, then a level 3 m above the highest one; pick a level to rename it or change its elevation. Front Elevation glides to a parallel front view. Select a level line and press Delete to remove it. Levels are saved with the model.
Source code
{ "id": "levels", "name": "Levels", "version": "1.0.0", "description": "Keeps a building's levels in the model, draws them as dashed lines in elevations and sections, and snaps the cursor to their heights.", "author": "Modlumi", "license": "MIT", "apiVersion": 1}// Levels: the floors of a building, each a name and an elevation, kept in the model so they// travel with it and come back with Undo. A panel edits them. In elevations and sections,// where the camera looks sideways, each level draws as a dashed line across the view with its// name and height, and the Select tool picks a line so Delete removes its level. The cursor// snaps to level heights in every tool that snaps, with the level's name in the tooltip.
/** @typedef {{ id: string, name: string, elevation: number }} Level */
const model = modlumi.activeModel;// The height of a new level above the highest one.const kStoreyHeight = 3;const kLineColor = { r: 0.15, g: 0.42, b: 0.82, a: 0.95 };// Level lines show while the view direction is within this of horizontal, about 30°.const kSidewaysSine = 0.5;// How far from the viewport's left edge a level's label sits, in pixels.const kLabelInset = 12;
/** * Formats an elevation with its sign, such as "+3.00 m" or "-0.50 m". * @param {number} elevation */function formatElevation(elevation) { return `${elevation < 0 ? "-" : "+"}${model.formatLength(Math.abs(elevation))}`;}
/** * Reads the levels kept in the model, lowest first; anything malformed is left out. * @param {unknown} data * @returns {Level[]} */function readLevels(data) { const stored = /** @type {{ levels?: unknown }} */ (data ?? {}).levels; if (!Array.isArray(stored)) { return []; } return stored .filter((level) => typeof level?.id === "string" && typeof level.name === "string" && Number.isFinite(level.elevation)) .map((level) => ({ id: level.id, name: level.name, elevation: level.elevation })) .sort((a, b) => a.elevation - b.elevation);}
/** * Returns the horizontal direction across the view, or null when the camera looks too far up * or down for levels to show as lines. * @param {modlumi.Camera} camera */function getAcross(camera) { const forward = camera.eye.vectorTo(camera.target).normalize(); if (Math.abs(forward.z) > kSidewaysSine) { return null; } return new modlumi.Vector3d(forward.y, -forward.x, 0).normalize();}
/** @param {modlumi.PluginContext} context */export function activate(context) { /** @type {Level[]} */ let levels = readLevels(context.documentData.get()); let showLines = Boolean(context.settings.get("showLines", true)); let snapToLevels = Boolean(context.settings.get("snap", true));
// Undo, redo and other documents change the levels too. const reread = () => { levels = readLevels(context.documentData.get()); }; context.onModelChanged(reread); context.onDocumentOpened(reread);
/** * Stores levels in the model in one undo step. * @param {string} name * @param {Level[]} next */ function save(name, next) { model.operation(name, () => context.documentData.set({ levels: next })); reread(); }
/** * Returns the ends of a level's line across the view, through the camera's target at the * level's height, long enough to cross the whole view, or null when the view isn't sideways. * @param {Level} level */ function getLine(level) { const camera = context.viewport.camera; const across = getAcross(camera); if (across === null) { return null; } const reach = Math.max(100, 20 * camera.eye.distance(camera.target)); const middle = new modlumi.Point3d(camera.target.x, camera.target.y, level.elevation); return /** @type {[modlumi.Point3d, modlumi.Point3d]} */ ([ middle.offset(across.scale(-reach)), middle.offset(across.scale(reach)), ]); }
// Level lines are annotations, so they draw over the model, across the whole viewport. context.addOverlay((view) => { if (!showLines) { return; } const { width, height } = context.viewport; for (const level of levels) { const line = getLine(level); if (line === null) { return; } const [start, end] = line.map((point) => context.viewport.project(point)); if (start === null || end === null || start[0] === end[0]) { continue; } /** @param {number} x */ const at = (x) => start[1] + ((x - start[0]) / (end[0] - start[0])) * (end[1] - start[1]); if (Math.max(at(0), at(width)) < 0 || Math.min(at(0), at(width)) > height) { continue; } view.lines([[0, at(0)], [width, at(width)]], { screen: true, color: kLineColor, width: 1.5, stipple: "dashed" }); view.text([kLabelInset, at(kLabelInset) - 18], `${level.name} ${formatElevation(level.elevation)}`, { screen: true, color: kLineColor }); } });
context.addSnapProvider(() => { if (!snapToLevels) { return []; } return levels.map((level) => ({ plane: { point: [0, 0, level.elevation], normal: [0, 0, 1] }, label: `${level.name} (${formatElevation(level.elevation)})`, })); });
context.addPickableItems({ items() { if (!showLines) { return []; } return levels.flatMap((level) => { const line = getLine(level); return line === null ? [] : [{ id: level.id, lines: [line] }]; }); }, // Runs inside the Delete operation, so the levels come back with Undo. onDelete(ids) { context.documentData.set({ levels: levels.filter((level) => !ids.includes(level.id)) }); }, });
/** Adds a level a storey above the highest one, or at the ground for the first, and returns its id. */ function addLevel() { const top = levels.at(-1); const used = new Set(levels.map((level) => level.id)); let number = levels.length + 1; while (used.has(`level-${number}`)) { number += 1; } const id = `level-${number}`; save("Add Level", [...levels, { id, name: top === undefined ? "Ground Floor" : `Level ${levels.length}`, elevation: top === undefined ? 0 : top.elevation + kStoreyHeight, }]); return id; }
/** Glides to a front elevation that fits the model and its levels, in a parallel projection. */ function lookFromTheFront() { const bounds = model.entities.bounds; const top = Math.max(bounds?.max.z ?? 0, levels.at(-1)?.elevation ?? 0); const bottom = Math.min(bounds?.min.z ?? 0, levels[0]?.elevation ?? 0); const center = new modlumi.Point3d(bounds?.center.x ?? 0, bounds?.center.y ?? 0, (top + bottom) / 2); // A parallel view shows as much as a perspective one does at the camera's distance. const fieldOfView = 45; const aspect = context.viewport.width / Math.max(1, context.viewport.height); // Room around the model, and for panels docked over the viewport's sides. const half = 0.9 * Math.max(top - bottom, (bounds?.width ?? 0) / aspect, 1); const distance = half / Math.tan((fieldOfView * Math.PI) / 360) + (bounds?.depth ?? 0); const front = new modlumi.Camera(center.offset([0, -distance, 0]), center, { perspective: false, fieldOfView }); context.viewport.setCamera(front, { duration: 0.6 }); }
// The level the panel edits, which follows the level lines the user selects. /** @type {string | null} */ let chosen = null; let lastSelected = "";
const panel = context.addPanel({ id: "levels", title: "Levels", draw(ui) { const selected = context.selectedItems; if (selected.join("\n") !== lastSelected) { lastSelected = selected.join("\n"); chosen = selected.at(-1) ?? chosen; } if (levels.length === 0) { ui.text("No levels yet. Add the ground floor to start.", { tone: "muted" }); } else { // Highest first, as on a section drawing. const rows = [...levels].reverse().map((level) => ({ id: level.id, cells: [level.name, formatElevation(level.elevation)], })); chosen = ui.table("Levels", ["Name", { title: "Elevation", numeric: true }], rows, chosen, { visibleRows: 6 }); } const level = levels.find((candidate) => candidate.id === chosen); if (level !== undefined) { const name = ui.textField("Name", level.name, { id: "name" }); const elevation = ui.length("Elevation", level.elevation, { id: "elevation" }); if (name !== level.name || elevation !== level.elevation) { save("Change Level", levels.map((other) => (other.id === level.id ? { ...other, name, elevation } : other))); } } ui.row(() => { if (ui.button("Add Level", { primary: true })) { chosen = addLevel(); } if (ui.button("Remove", { disabled: level === undefined, tip: "Removes the chosen level" })) { save("Remove Level", levels.filter((other) => other.id !== chosen)); chosen = null; } if (ui.button("Front Elevation", { tip: "Glides to a parallel view of the model from the front" })) { lookFromTheFront(); } }, { id: "actions" }); ui.separator("Display"); const show = ui.checkbox("Show level lines", showLines, { tip: "Dashed lines across elevations and sections, which the Select tool picks" }); if (show !== showLines) { showLines = show; context.settings.set("showLines", show); } const snap = ui.checkbox("Snap to levels", snapToLevels, { tip: "Points snap to level heights, seen from the side" }); if (snap !== snapToLevels) { snapToLevels = snap; context.settings.set("snap", snap); } if (getAcross(context.viewport.camera) === null) { ui.text("Level lines show in front, back and side views.", { tone: "muted" }); } }, });
context.registerCommand({ id: "open", title: "Levels", tip: "Opens the building levels.", run: () => panel.show(), });}Modify Solids
Section titled “Modify Solids”Unites, subtracts, trims and intersects solid groups and components in place, so the changed solid keeps its name, paint and position, and every copy of a component changes along.
Select the solids to cut or add with and choose Extensions → Modify Solids → Mark Modifiers. Then select the solids to change and choose Union, Subtract, Trim or Intersect. Subtract erases the modifiers unless Keep Modifiers is on; Trim always keeps them. With two or more solids selected and none marked, Union and Intersect combine them into the largest one.
Port of Eneroth Solid Tools by Julia Christina Eneroth (MIT).
Source code
{ "id": "modify-solids", "name": "Modify Solids", "version": "1.0.0", "description": "Unites, subtracts, trims and intersects solids in place, keeping their names, paint and placement.", "author": "Modlumi, after Eneroth Solid Tools by Julia Christina Eneroth", "license": "MIT", "apiVersion": 1}// Changes solid groups and components in place with other solids, so they keep their names,// paint, placement and plugin data, and every instance of a component changes along.// A port of Eneroth Solid Tools (MIT) by Julia Christina Eneroth:// https://github.com/Eneroth3/eneroth-solid-tools//// Copyright (c) 2017 Julia Christina Eneroth//// Permission is hereby granted, free of charge, to any person obtaining a copy of this software// and associated documentation files (the "Software"), to deal in the Software without// restriction, including without limitation the rights to use, copy, modify, merge, publish,// distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the// Software is furnished to do so, subject to the following conditions://// The above copyright notice and this permission notice shall be included in all copies or// substantial portions of the Software.//// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING// BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.//// Eneroth's tools ask for a target and then each modifier with the mouse. Here the user marks// modifiers once, then selects targets and runs a command. Without marked modifiers, Union and// Intersect combine the selection into its largest solid, like Eneroth's instant mode.
/** @typedef {modlumi.Group | modlumi.ComponentInstance} Solid */
/** @type {Solid[]} */let modifiers = [];
/** * @param {unknown} element * @returns {element is Solid} */function isSolid(element) { if (!(element instanceof modlumi.Group || element instanceof modlumi.ComponentInstance)) { return false; } if (solidity === undefined) { return element.isSolid; } let solid = solidity.get(element); if (solid === undefined) { solid = element.isSolid; solidity.set(element, solid); } return solid;}
/** * Whether entities are solid, kept while the commands' isEnabled checks run: they only read, * and each looks at the whole selection, which can hold thousands of entities. * @type {WeakMap<Solid, boolean> | undefined} */let solidity;
/** * Runs a read-only check with solidity kept until the current run's code finishes. * @param {() => boolean} check */function withSolidity(check) { if (solidity === undefined) { solidity = new WeakMap(); Promise.resolve().then(() => { solidity = undefined; }); } return check();}
// Returns the marked modifiers that still exist and belong to the context being edited.function liveModifiers() { const entities = modlumi.activeModel.activeEntities.toArray(); modifiers = modifiers.filter((modifier) => modifier.isValid && entities.includes(modifier)); return modifiers;}
// Returns the selected solids that aren't marked as modifiers.function selectedTargets() { const marked = liveModifiers(); return modlumi.activeModel.selection .toArray() .filter(isSolid) .filter((solid) => !marked.includes(solid));}
/** * @param {number} count * @param {string} noun */function plural(count, noun) { return `${count} ${noun}${count === 1 ? "" : "s"}`;}
/** * Changes each target in place with each modifier in turn, as one undo step. * @param {"union" | "subtract" | "intersect"} operation * @param {Solid[]} targets * @param {Solid[]} cutters */function modify(operation, targets, cutters) { const entities = modlumi.activeModel.activeEntities; for (const target of targets) { for (const cutter of cutters) { entities.modifySolid(target, cutter, operation); } }}
/** @param {modlumi.PluginContext} context */export function activate(context) { const keepModifiers = () => context.settings.get("keepModifiers", false);
context.registerCommand({ id: "mark-modifiers", title: "Mark Modifiers", tip: "Remembers the selected solids as the modifiers the other commands change targets with.", when: "selection", contextMenu: true, isEnabled: () => withSolidity(() => modlumi.activeModel.selection.toArray().some(isSolid)), run() { modifiers = modlumi.activeModel.selection.toArray().filter(isSolid); context.showMessage(`Marked ${plural(modifiers.length, "modifier")}. Now select the targets.`); }, });
context.registerCommand({ id: "keep-modifiers", title: "Keep Modifiers", tip: "Keeps the modifiers after Subtract instead of erasing them.", isChecked: keepModifiers, run() { context.settings.set("keepModifiers", !keepModifiers()); }, });
/** * Registers a command that changes the selected targets with the marked modifiers. * @param {string} id * @param {string} title * @param {string} tip * @param {"union" | "subtract" | "intersect"} operation * @param {{ erase: () => boolean, instant: boolean }} options */ const registerTool = (id, title, tip, operation, { erase, instant }) => context.registerCommand({ id, title, tip, when: "selection", contextMenu: true, isEnabled() { return withSolidity(() => { const targets = selectedTargets(); return targets.length > 0 && (liveModifiers().length > 0 || (instant && targets.length > 1)); }); }, run() { const model = modlumi.activeModel; let targets = selectedTargets(); let cutters = liveModifiers(); if (cutters.length === 0 && instant) { // The largest solid takes in the others. targets.sort((a, b) => (b.volume ?? 0) - (a.volume ?? 0)); cutters = targets.slice(1); targets = targets.slice(0, 1); } if (targets.length === 0 || cutters.length === 0) { context.showMessage("Mark modifiers first, then select the solids to change."); return; } const erased = erase(); model.operation(title, () => { modify(operation, targets, cutters); if (erased) { for (const cutter of cutters) { model.activeEntities.erase(cutter); } } }); if (erased) { modifiers = []; } model.selection.clear(); for (const target of targets) { model.selection.add(target); } context.showMessage(`${title}: changed ${plural(targets.length, "solid")} with ${plural(cutters.length, "modifier")}.`); }, });
registerTool("union", "Union", "Adds the modifiers to the selected solids, which keep their names and paint.", "union", { erase: () => false, instant: true, }); registerTool( "subtract", "Subtract", "Cuts the modifiers out of the selected solids and erases them, unless Keep Modifiers is on.", "subtract", { erase: () => !keepModifiers(), instant: false }, ); registerTool("trim", "Trim", "Cuts the modifiers out of the selected solids and keeps them.", "subtract", { erase: () => false, instant: false, }); registerTool( "intersect", "Intersect", "Keeps only the parts of the selected solids that lie inside the modifiers.", "intersect", { erase: () => false, instant: true }, );}Imports and exports Wavefront OBJ models. An import becomes one group named after the file, with a group inside it for each object in the file.
Use File → Import OBJ Model… and File → Export OBJ Model…. OBJ files have no units, so Extensions → OBJ → OBJ Options… sets the units to read and write and whether Y is up, as most other tools write it. Export writes the faces of each group and component as an object of its own, and leaves out hidden items and materials.
Source code
{ "id": "obj", "name": "OBJ", "version": "1.0.0", "description": "Imports and exports Wavefront OBJ models, with their objects as groups.", "author": "Modlumi", "license": "MIT", "apiVersion": 1}// Wavefront OBJ import and export. OBJ files list vertices ("v"), and faces ("f") through them,// grouped into objects ("o") or groups ("g"); they have no units, and most tools write them// with Y up. Import makes a group per object inside one group for the file; export writes the// faces of each group and component as an object of its own.
/** * @typedef {"Millimeters" | "Centimeters" | "Meters" | "Inches" | "Feet"} Unit * @typedef {{ units: Unit, yUp: boolean }} Options * @typedef {{ name: string, polygons: number[][][] }} ObjObject */
const model = modlumi.activeModel;/** Meters per unit. @type {Record<Unit, number>} */const kMeters = { Millimeters: 0.001, Centimeters: 0.01, Meters: 1, Inches: 0.0254, Feet: 0.3048 };/** @type {Unit[]} */const kUnits = ["Meters", "Centimeters", "Millimeters", "Inches", "Feet"];/** @type {Options} */const kDefaults = { units: "Meters", yUp: true };
/** * Reads OBJ text into its objects' polygons, as points in meters with Z up. Faces refer to * vertices by position from 1, or from the end with negative numbers; texture coordinates and * normals after slashes are left out. * @param {string} text * @param {Options} options * @returns {ObjObject[]} */export function readObj(text, options) { const scale = kMeters[options.units] ?? 1; /** @type {number[][]} */ const vertices = []; /** @type {ObjObject[]} */ const objects = []; /** @type {ObjObject | null} */ let current = null; /** Objects "g" records started since the last "o", by name. @type {Map<string, ObjObject>} */ const groups = new Map(); // A backslash at a line's end continues the record on the next line, and "#" starts a comment. const records = text.replace(/\\\r?\n/g, " ").split(/\r?\n/).map((line) => line.replace(/#.*/, "")); for (const record of records) { const words = record.trim().split(/\s+/); if (words[0] === "v" && words.length >= 4) { const [x, y, z] = words.slice(1, 4).map((value) => Number(value) * scale); vertices.push(options.yUp ? [x, -z, y] : [x, y, z]); } else if (words[0] === "o" || words[0] === "g") { // Each "o" starts an object, even one with an earlier one's name; a "g" naming an earlier // group of the same object selects it again. const name = words.slice(1).join(" ") || "Object"; const group = words[0] === "g"; if (!group) { groups.clear(); } current = group ? groups.get(name) ?? null : null; if (current === null) { current = { name, polygons: [] }; objects.push(current); if (group) { groups.set(name, current); } } } else if (words[0] === "f" && words.length >= 4) { const corners = []; for (const word of words.slice(1)) { const index = Number.parseInt(word.split("/")[0], 10); const vertex = vertices[index < 0 ? vertices.length + index : index - 1]; if (vertex === undefined || !vertex.every(Number.isFinite)) { corners.length = 0; break; } corners.push(vertex); } if (corners.length >= 3) { if (current === null) { current = { name: "Object", polygons: [] }; objects.push(current); } current.polygons.push(corners); } } } return objects.filter((object) => object.polygons.length > 0);}
/** * Returns a polygon's corners with repeated neighbors left out, or null when fewer than three * different ones remain or they lie on a line. * @param {number[][]} polygon */function cleanPolygon(polygon) { const points = polygon.map((point) => new modlumi.Point3d(...point)) .filter((point, index, all) => !point.equals(all[(index + all.length - 1) % all.length])); if (points.length < 3) { return null; } let area = new modlumi.Vector3d(0, 0, 0); for (let index = 1; index + 1 < points.length; ++index) { area = area.add(points[0].vectorTo(points[index]).cross(points[0].vectorTo(points[index + 1]))); } return area.dot(area) > 1e-24 ? points : null;}
/** * Returns a face's outer boundary as positions, counterclockwise around its normal. * @param {modlumi.Face} face */function getOutline(face) { const points = face.outerLoop.edgeUses.map((use) => use.start.position); let normal = new modlumi.Vector3d(0, 0, 0); for (let index = 0; index < points.length; ++index) { const a = points[index]; const b = points[(index + 1) % points.length]; normal = normal.add(new modlumi.Vector3d((a.y - b.y) * (a.z + b.z), (a.z - b.z) * (a.x + b.x), (a.x - b.x) * (a.y + b.y))); } return face.normal !== null && normal.dot(face.normal) < 0 ? points.reverse() : points;}
/** * Writes the faces of elements and the groups and components in them as OBJ text, in world * coordinates, with an object per group and component. Faces with holes are written as their * triangles. * @param {Iterable<modlumi.DrawingElement>} elements * @param {Options} options */export function writeObj(elements, options) { const scale = 1 / (kMeters[options.units] ?? 1); const lines = ["# Exported by Modlumi"]; /** @type {Map<string, number>} */ const indices = new Map(); /** @param {modlumi.Point3d} point */ const vertex = (point) => { const [x, y, z] = [point.x * scale, point.y * scale, point.z * scale]; const written = (options.yUp ? [x, z, -y] : [x, y, z]).map((value) => (Object.is(value, -0) ? 0 : value)); const key = written.join(" "); let index = indices.get(key); if (index === undefined) { index = indices.size + 1; indices.set(key, index); lines.push(`v ${key}`); } return index; }; let faces = 0; /** * @param {Iterable<modlumi.DrawingElement>} list * @param {modlumi.Transformation} transformation * @param {string} name */ const visit = (list, transformation, name) => { const [x, y, z] = [[1, 0, 0], [0, 1, 0], [0, 0, 1]].map((axis) => new modlumi.Vector3d(...axis).transform(transformation)); const mirrored = x.dot(y.cross(z)) < 0; const shown = Array.from(list).filter((element) => !element.hidden); // All of this object's faces first, so none follow a child's "o" record. const own = shown.filter((element) => element instanceof modlumi.Face); if (own.length > 0) { lines.push(`o ${name}`); } for (const face of /** @type {modlumi.Face[]} */ (own)) { const polygons = face.loops.length === 1 ? [getOutline(face)] : face.triangles; for (const polygon of polygons) { const placed = polygon.map((point) => vertex(point.transform(transformation))); lines.push(`f ${(mirrored ? placed.reverse() : placed).join(" ")}`); faces += 1; } } for (const element of shown) { if (element instanceof modlumi.Group) { visit(element.entities, transformation.multiply(element.transformation), element.name || "Group"); } else if (element instanceof modlumi.ComponentInstance) { visit(element.definition.entities, transformation.multiply(element.transformation), element.name || element.definition.name); } } }; visit(elements, model.activeTransformation, "Model"); return { text: lines.join("\n") + "\n", faces };}
/** * Returns a file's name without its extension. * @param {string} name */function getStem(name) { return name.replace(/\.[^.]*$/, "") || "Untitled";}
/** @param {modlumi.PluginContext} context */export function activate(context) { /** @returns {Options} */ const getOptions = () => ({ ...kDefaults, ...context.settings.get("options", {}) });
context.registerImporter({ id: "obj", name: "OBJ Model", extensions: ["obj"], import(file) { const objects = readObj(file.readText(), getOptions()); let imported = 0; const group = model.activeEntities.addGroup(); group.name = getStem(file.name); for (const object of objects) { const mesh = new modlumi.MeshBuilder(); for (const polygon of object.polygons) { const points = cleanPolygon(polygon); if (points !== null) { mesh.addPolygon(points); } } if (mesh.polygonCount === 0) { continue; } // One object is the file's group itself; several are groups inside it. const holder = objects.length === 1 ? group : group.entities.addGroup(); if (holder !== group) { holder.name = object.name; } holder.entities.addMesh(mesh); imported += mesh.polygonCount; } if (imported === 0) { model.activeEntities.erase(group); context.showMessage(`${file.name} holds no faces to import.`); return; } context.showMessage(`Imported ${imported} faces from ${file.name}`); }, });
context.registerExporter({ id: "obj", name: "OBJ Model", extensions: ["obj"], export(file) { const { text, faces } = writeObj(model.activeEntities, getOptions()); file.writeText(text); context.showMessage(`Exported ${faces} faces to ${file.name}`); }, });
context.registerCommand({ id: "options", title: "OBJ Options...", tip: "Sets the units and up axis OBJ import and export use.", run() { const options = getOptions(); context.showDialog({ id: "options", title: "OBJ Options", modal: true, draw(ui) { options.units = /** @type {Unit} */ (ui.dropdown("Units", options.units, kUnits, { tip: "OBJ files have no units; this says what theirs are" })); options.yUp = ui.checkbox("Y is up", options.yUp, { tip: "As most other tools write OBJ files" }); ui.spacing(); ui.row(() => { if (ui.button("OK", { primary: true })) { context.settings.set("options", options); ui.close(); } if (ui.button("Cancel")) { ui.close(); } }); }, }); }, });}Select Similar
Section titled “Select Similar”Grows the selection to faces like the selected ones, or filters it by type.
Select a face, then choose a command from Extensions → Select Similar: coplanar faces, parallel faces, faces facing the same way, faces with the same material, or connected faces on the same plane. Keep Only Faces, Keep Only Edges and Keep Only Groups and Components filter the selection. The most used commands are also in the right-click menu.
Inspired by the selection tools in ThomThom’s SketchUp extensions.
Source code
{ "id": "select-similar", "name": "Select Similar", "version": "1.0.0", "description": "Grows or filters the selection by plane, direction, material or type.", "author": "Modlumi", "license": "MIT", "apiVersion": 1}// Grows the selection to faces that match the selected ones, or filters it by type.// Inspired by the selection commands in ThomThom's Selection Tools.
// Faces closer than this, in meters, or whose normals differ less than this, in radians, match.const kDistanceTolerance = 1e-6;const kAngleTolerance = 1e-6;
/** @typedef {{ normal: modlumi.Vector3d, distance: number }} Plane */
/** * @param {modlumi.Entity} element * @returns {element is modlumi.Face} */function isFace(element) { return element instanceof modlumi.Face;}
/** * @param {modlumi.Model} model * @returns {modlumi.Face[]} */function selectedFaces(model) { return model.selection.toArray().filter(isFace);}
/** * @param {modlumi.Model} model * @returns {modlumi.Face[]} */function contextFaces(model) { return model.activeEntities.toArray().filter(isFace);}
// Returns a face's unit normal and its plane's signed distance from the origin, or null for a// face without a normal./** * @param {modlumi.Face} face * @returns {Plane | null} */function planeOf(face) { const normal = face.normal; if (normal === null) { return null; } const point = face.outerLoop.edges[0].start.position; return { normal, distance: normal.dot(point.toArray()) };}
/** * @param {modlumi.Vector3d} a * @param {modlumi.Vector3d} b */function isParallel(a, b) { return Math.abs(Math.abs(a.dot(b)) - 1) <= kAngleTolerance;}
/** * @param {modlumi.Vector3d} a * @param {modlumi.Vector3d} b */function isSameDirection(a, b) { return Math.abs(a.dot(b) - 1) <= kAngleTolerance;}
// Whether two planes coincide, whichever way their normals point./** * @param {Plane} a * @param {Plane} b */function isCoplanar(a, b) { if (!isParallel(a.normal, b.normal)) { return false; } const distance = a.normal.dot(b.normal) > 0 ? b.distance : -b.distance; return Math.abs(a.distance - distance) <= kDistanceTolerance;}
// Adds the faces of the active context that match any selected face, and reports how many./** * @param {modlumi.PluginContext} context * @param {string} describe * @param {(seed: modlumi.Face, face: modlumi.Face) => boolean} matches */function growSelection(context, describe, matches) { const model = modlumi.activeModel; const seeds = selectedFaces(model); if (seeds.length === 0) { context.showMessage("Select at least one face first."); return; } let added = 0; for (const face of contextFaces(model)) { const matching = seeds.some((seed) => matches(seed, face)); if (matching && !model.selection.has(face) && model.selection.add(face)) { added += 1; } } const faces = added === 1 ? "face" : "faces"; context.showMessage(added === 0 ? `No more ${describe} faces.` : `Added ${added} ${describe} ${faces}.`);}
// Adds faces reachable from the selected ones through shared edges while staying on their plane./** @param {modlumi.PluginContext} context */function selectConnectedPlanar(context) { const model = modlumi.activeModel; const seeds = selectedFaces(model); if (seeds.length === 0) { context.showMessage("Select at least one face first."); return; } /** @type {Map<modlumi.Edge, modlumi.Face[]>} */ const facesByEdge = new Map(); for (const face of contextFaces(model)) { for (const loop of face.loops) { for (const edge of loop.edges) { const faces = facesByEdge.get(edge) ?? []; faces.push(face); facesByEdge.set(edge, faces); } } } const reached = new Set(seeds); const pending = [...seeds]; let added = 0; while (pending.length > 0) { const face = /** @type {modlumi.Face} */ (pending.pop()); const plane = planeOf(face); if (plane === null) { continue; } for (const loop of face.loops) { for (const edge of loop.edges) { for (const neighbor of facesByEdge.get(edge) ?? []) { const other = planeOf(neighbor); if (reached.has(neighbor) || other === null || !isCoplanar(plane, other)) { continue; } reached.add(neighbor); pending.push(neighbor); if (model.selection.add(neighbor)) { added += 1; } } } } } const faces = added === 1 ? "face" : "faces"; context.showMessage(added === 0 ? "No more connected faces on the same plane." : `Added ${added} connected ${faces}.`);}
// Keeps only the selected elements that pass keep, and reports what was removed./** * @param {modlumi.PluginContext} context * @param {string} describe * @param {(element: modlumi.DrawingElement) => boolean} keep */function filterSelection(context, describe, keep) { const selection = modlumi.activeModel.selection; let removed = 0; for (const element of selection.toArray()) { if (!keep(element) && selection.remove(element)) { removed += 1; } } const items = removed === 1 ? "item" : "items"; context.showMessage(`Kept ${selection.length} ${describe}; removed ${removed} other ${items}.`);}
/** @param {modlumi.PluginContext} context */export function activate(context) { /** * @param {string} id * @param {string} title * @param {string} tip * @param {string} describe * @param {(seed: modlumi.Face, face: modlumi.Face) => boolean} matches */ const grow = (id, title, tip, describe, matches, contextMenu = false) => context.registerCommand({ id, title, tip, when: "selection", contextMenu, run: () => growSelection(context, describe, matches), }); grow( "coplanar-faces", "Select Coplanar Faces", "Adds every face in the current context that lies on the plane of a selected face.", "coplanar", (seed, face) => { const a = planeOf(seed); const b = planeOf(face); return a !== null && b !== null && isCoplanar(a, b); }, true, ); grow( "parallel-faces", "Select Parallel Faces", "Adds every face in the current context that is parallel to a selected face, facing either way.", "parallel", (seed, face) => seed.normal !== null && face.normal !== null && isParallel(seed.normal, face.normal), ); grow( "same-direction-faces", "Select Faces Facing the Same Way", "Adds every face in the current context whose front faces the same direction as a selected face.", "same-direction", (seed, face) => seed.normal !== null && face.normal !== null && isSameDirection(seed.normal, face.normal), ); grow( "same-material-faces", "Select Faces with the Same Material", "Adds every face in the current context whose front material matches a selected face.", "same-material", (seed, face) => seed.material === face.material, true, ); context.registerCommand({ id: "connected-planar-faces", title: "Select Connected Faces on the Same Plane", tip: "Adds the faces that connect to a selected face through shared edges without leaving its plane.", when: "selection", contextMenu: true, run: () => selectConnectedPlanar(context), }); /** @type {{ id: string, title: string, describe: string, keep: (element: modlumi.DrawingElement) => boolean }[]} */ const filters = [ { id: "keep-faces", title: "Keep Only Faces", describe: "faces", keep: isFace }, { id: "keep-edges", title: "Keep Only Edges", describe: "edges", keep: (element) => element instanceof modlumi.Edge, }, { id: "keep-containers", title: "Keep Only Groups and Components", describe: "groups and components", keep: (element) => element instanceof modlumi.Group || element instanceof modlumi.ComponentInstance, }, ]; for (const { id, title, describe, keep } of filters) { context.registerCommand({ id, title, tip: `Removes everything but ${describe} from the selection.`, when: "selection", run: () => filterSelection(context, describe, keep), }); }}Shapes
Section titled “Shapes”Adds boxes, cylinders, cones, tori, tubes, prisms, pyramids, domes, spheres, helices and helical ramps from a dialog of their dimensions. A shape stays editable: select it and change its dimensions in Properties.
Choose a shape from Extensions → Shapes, set its dimensions and click Add. The shape is added as a group at the origin of the context you’re editing, and the dialog remembers the last values you used.
Port of Shapes by Trimble (MIT).
Source code
{ "id": "shapes", "name": "Shapes", "version": "1.0.0", "description": "Adds boxes, cylinders, cones, tori, tubes, prisms, pyramids, domes, spheres, helices and helical ramps from parameter dialogs, editable later in Properties.", "author": "Modlumi, after the Shapes extension by Trimble", "license": "MIT", "apiVersion": 1}// Adds parametric shapes from a dialog of their parameters. Each shape is a group that keeps// its parameters, so selecting it shows them in Properties, where changing one rebuilds it.//// A port of the Shapes extension (https://github.com/SketchUp/sketchup-shapes)://// Copyright (c) 2014 Trimble Navigation Ltd.//// Permission is hereby granted, free of charge, to any person obtaining a copy of this software// and associated documentation files (the "Software"), to deal in the Software without// restriction, including without limitation the rights to use, copy, modify, merge, publish,// distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the// Software is furnished to do so, subject to the following conditions://// The above copyright notice and this permission notice shall be included in all copies or// substantial portions of the Software.//// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING// BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
/** * @typedef {Record<string, number>} Parameters * @typedef {{ * id: string, * title: string, * tip: string, * defaults: Parameters, * schema: Record<string, modlumi.FormField>, * problem?: (parameters: Parameters) => string | undefined, * draw: (entities: modlumi.Entities, parameters: Parameters) => void, * }} Shape * @typedef {{ shape: string, parameters: Parameters }} ShapeData */
const model = modlumi.activeModel;// The shortest length a dimension may have.const kMinLength = 0.001;// Edges between faces that meet at a smaller angle are softened, so curved surfaces read as one.const kSmoothAngle = (46 * Math.PI) / 180;// Coordinates closer than this to a plane or axis lie on it.const kTolerance = 1e-9;
/** @param {string} label @returns {modlumi.FormField} */const length = (label) => ({ type: "length", label, min: kMinLength });/** @param {string} label @param {number} min @param {number} max @param {string} [tip] @returns {modlumi.FormField} */const count = (label, min, max, tip) => ({ type: "integer", label, min, max, tip });
/** * `segments` points around a circle of `radius` at height `z`, counterclockwise from `start` * radians off the X axis. * @param {number} radius * @param {number} segments * @returns {[number, number, number][]} */function ring(radius, segments, z = 0, start = 0) { return Array.from({ length: segments }, (_, index) => { const angle = start + (2 * Math.PI * index) / segments; return [radius * Math.cos(angle), radius * Math.sin(angle), z]; });}
/** * Points along a helix that turns `turns` times, counterclockwise when positive, rising * `pitch` per turn while its radius changes linearly from `startRadius` to `endRadius`. * @param {Parameters} helix * @param {number} startRadius * @param {number} endRadius * @returns {[number, number, number][]} */function helixPoints(helix, startRadius, endRadius) { const steps = Math.max(1, Math.round(Math.abs(helix.turns) * helix.segments)); const turnsPerStep = helix.turns / steps; return Array.from({ length: steps + 1 }, (_, step) => { const radius = startRadius + ((endRadius - startRadius) * step) / steps; const angle = helix.startAngle + 2 * Math.PI * turnsPerStep * step; return [radius * Math.cos(angle), radius * Math.sin(angle), helix.pitch * turnsPerStep * step]; });}
/** * Explains why a helix, or a ramp `width` wide, can't be drawn, or returns `undefined`. Without * a pitch, turns overlap unless the radius grows by more than the width each turn. * @param {Parameters} helix * @param {number} [width] */function helixProblem(helix, width = 0) { if (Math.abs(helix.turns) * helix.segments < 1) { return "Too few turns to draw one segment."; } const growth = Math.abs(helix.endRadius - helix.startRadius) / Math.abs(helix.turns); if (helix.pitch === 0 && Math.abs(helix.turns) > 1 && growth <= width) { return "Without a pitch, the turns would overlap."; } return undefined;}
/** * Softens and smooths the edges between faces that meet at a shallow angle, so curved * surfaces read as one. * @param {modlumi.Entities} entities */function smooth(entities) { /** @type {Map<modlumi.Edge, modlumi.Vector3d[]>} */ const normals = new Map(); for (const face of entities.ofType("Face")) { for (const loop of face.loops) { for (const edge of loop.edges) { const list = normals.get(edge) ?? []; if (face.normal) { list.push(face.normal); } normals.set(edge, list); } } } for (const [edge, [first, second, ...rest]] of normals) { if (first && second && rest.length === 0 && first.angleBetween(second) < kSmoothAngle) { edge.soft = true; edge.smooth = true; } }}
/** * The normal of the side `points` run counterclockwise around, by Newell's method. * @param {modlumi.Point3dLike[]} points */function windingNormal(points) { let normal = new modlumi.Vector3d(); points.forEach((point, index) => { const [x1, y1, z1] = point; const [x2, y2, z2] = points[(index + 1) % points.length]; normal = normal.add([(y1 - y2) * (z1 + z2), (z1 - z2) * (x1 + x2), (x1 - x2) * (y1 + y2)]); }); return normal;}
/** * Draws a face through each polygon, its front on the side the points run counterclockwise * around. Closing planar loops also fills them, like drawing them by hand would, so faces other * than these and `keep` are erased again. * @param {modlumi.Entities} entities * @param {modlumi.Point3dLike[][]} polygons * @param {modlumi.Face[]} [keep] */function addFaces(entities, polygons, keep = []) { const wanted = new Set(keep); for (const polygon of polygons) { const face = entities.addFace(polygon); // Drawing earlier faces may have filled this one already, facing either way. if (face.normal && face.normal.dot(windingNormal(polygon)) < 0) { face.reverse(); } wanted.add(face); } for (const face of entities.ofType("Face")) { if (!wanted.has(face)) { entities.erase(face); } }}
/** * Revolves a profile in the XZ plane around the Z axis, along a circle of `radius`. * @param {modlumi.Entities} entities * @param {modlumi.Point3dLike[]} profile * @param {number} radius * @param {number} segments */function revolve(entities, profile, radius, segments) { const path = ring(radius, segments); entities.addFace(profile).sweep([...path, path[0]]);}
/** * Revolving a profile edge along the X axis leaves a fan of triangles around the origin; erases * its spokes so they merge into one face. * @param {modlumi.Entities} entities */function mergeFan(entities) { const isHub = (/** @type {modlumi.Vertex} */ vertex) => vertex.position.distance([0, 0, 0]) < kTolerance; const spokes = entities.ofType("Edge").filter((edge) => Math.abs(edge.start.position.z) < kTolerance && Math.abs(edge.end.position.z) < kTolerance && (isHub(edge.start) || isHub(edge.end))); // Erasing the spokes also removes the hub vertex they leave behind. entities.eraseEdges(spokes);}
/** * Draws triangles from each edge of `base`'s outer loop up to `apex`, then turns the base to * face down, closing a solid. * @param {modlumi.Entities} entities * @param {modlumi.Face} base a face in the XY plane whose front faces up * @param {modlumi.Point3dLike} apex */function addApex(entities, base, apex) { // Taking the corners from the base keeps the triangles on its exact vertices. const corners = base.outerLoop.edgeUses.map((use) => use.start.position); base.reverse(); const sides = corners.map((corner, index) => [corner, corners[(index + 1) % corners.length], apex]); addFaces(entities, sides, [base]);}
/** @type {Shape[]} */const kShapes = [ { id: "box", title: "Box", tip: "Adds a box from its width, depth and height.", defaults: { width: 1, depth: 1, height: 1 }, schema: { width: length("Width"), depth: length("Depth"), height: length("Height") }, draw(entities, box) { entities.addRectangle([0, 0, 0], box.width, box.depth).extrude(box.height); }, }, { id: "cylinder", title: "Cylinder", tip: "Adds a cylinder standing on its base circle.", defaults: { radius: 0.5, height: 1, segments: 24 }, schema: { radius: length("Radius"), height: length("Height"), segments: count("Segments", 3, 360) }, draw(entities, cylinder) { entities.addCircle([0, 0, 0], cylinder.radius, { segments: cylinder.segments }).extrude(cylinder.height); // Extruding leaves the top's edges without a curve: retrace them as a circle. entities.addCircle([0, 0, cylinder.height], cylinder.radius, { segments: cylinder.segments }); smooth(entities); }, }, { id: "cone", title: "Cone", tip: "Adds a cone standing on its base circle.", defaults: { radius: 0.5, height: 1, segments: 24 }, schema: { radius: length("Radius"), height: length("Height"), segments: count("Segments", 3, 360) }, draw(entities, cone) { const base = entities.addCircle([0, 0, 0], cone.radius, { segments: cone.segments }); addApex(entities, base, [0, 0, cone.height]); smooth(entities); }, }, { id: "torus", title: "Torus", tip: "Adds a ring with a round profile, lying flat.", defaults: { outerRadius: 1, profileRadius: 0.25, profileSegments: 12, segments: 24 }, schema: { outerRadius: { ...length("Outer radius"), tip: "From the center to the outside of the ring" }, profileRadius: { ...length("Profile radius"), tip: "Radius of the ring's round cross-section" }, profileSegments: count("Profile segments", 3, 96), segments: count("Ring segments", 3, 96), }, problem: (torus) => torus.profileRadius * 2 >= torus.outerRadius ? "The profile radius must be less than half the outer radius." : undefined, draw(entities, torus) { const middle = torus.outerRadius - torus.profileRadius; const profile = ring(torus.profileRadius, torus.profileSegments).map(([x, y]) => [middle + x, 0, y]); revolve(entities, /** @type {[number, number, number][]} */ (profile), middle, torus.segments); smooth(entities); }, }, { id: "tube", title: "Tube", tip: "Adds a hollow cylinder from its outer radius and wall thickness.", defaults: { radius: 0.5, thickness: 0.05, height: 1, segments: 24 }, schema: { radius: { ...length("Outer radius") }, thickness: length("Wall thickness"), height: length("Height"), segments: count("Segments", 3, 360), }, problem: (tube) => (tube.thickness >= tube.radius ? "The wall must be thinner than the radius." : undefined), draw(entities, tube) { entities.addCircle([0, 0, 0], tube.radius, { segments: tube.segments }); // The inner circle cuts its own face out of the outer one, leaving a ring. entities.erase(entities.addCircle([0, 0, 0], tube.radius - tube.thickness, { segments: tube.segments })); entities.ofType("Face")[0].extrude(tube.height); smooth(entities); }, }, { id: "prism", title: "Prism", tip: "Adds a prism on a regular polygon.", defaults: { radius: 0.5, height: 1, sides: 6 }, schema: { radius: { ...length("Radius"), tip: "From the center to a corner" }, height: length("Height"), sides: count("Sides", 3, 360), }, draw(entities, prism) { entities.addPolygon([0, 0, 0], prism.radius, prism.sides).extrude(prism.height); }, }, { id: "pyramid", title: "Pyramid", tip: "Adds a pyramid on a regular polygon.", defaults: { radius: 0.5, height: 1, sides: 4 }, schema: { radius: { ...length("Radius"), tip: "From the center to a corner" }, height: length("Height"), sides: count("Sides", 3, 360), }, draw(entities, pyramid) { addApex(entities, entities.addPolygon([0, 0, 0], pyramid.radius, pyramid.sides), [0, 0, pyramid.height]); }, }, { id: "dome", title: "Dome", tip: "Adds a half sphere standing on its flat side.", defaults: { radius: 0.5, segments: 6 }, schema: { radius: length("Radius"), segments: count("Segments per 90°", 1, 24) }, draw(entities, dome) { /** @type {[number, number, number][]} */ const profile = [[0, 0, 0]]; for (let index = 0; index <= dome.segments; ++index) { const angle = (Math.PI / 2) * (index / dome.segments); profile.push([index === dome.segments ? 0 : dome.radius * Math.cos(angle), 0, dome.radius * Math.sin(angle)]); } revolve(entities, profile, dome.radius, dome.segments * 4); mergeFan(entities); smooth(entities); }, }, { id: "sphere", title: "Sphere", tip: "Adds a sphere around the origin.", defaults: { radius: 0.5, segments: 6 }, schema: { radius: length("Radius"), segments: count("Segments per 90°", 1, 24) }, draw(entities, sphere) { const rings = sphere.segments * 2; const profile = Array.from({ length: rings + 1 }, (_, index) => { const angle = (Math.PI * index) / rings - Math.PI / 2; const onAxis = index === 0 || index === rings; return /** @type {[number, number, number]} */ ([onAxis ? 0 : sphere.radius * Math.cos(angle), 0, sphere.radius * Math.sin(angle)]); }); revolve(entities, profile, sphere.radius, sphere.segments * 4); smooth(entities); }, }, { id: "helix", title: "Helix", tip: "Adds a helix or spiral line around the Z axis.", defaults: { startRadius: 0.5, endRadius: 0.5, pitch: 0.5, turns: 2, segments: 24, startAngle: 0 }, schema: { startRadius: length("Start radius"), endRadius: length("End radius"), pitch: { type: "length", label: "Pitch", tip: "Rise per turn; negative pitches go down" }, turns: { type: "number", label: "Turns", decimals: 2, tip: "Negative turns run clockwise" }, segments: count("Segments per turn", 3, 360), startAngle: { type: "angle", label: "Start angle", tip: "Where the helix starts, from the X axis" }, }, problem: (helix) => helixProblem(helix), draw(entities, helix) { entities.addEdges(helixPoints(helix, helix.startRadius, helix.endRadius)); // A flat helix that closes a full turn would fill it. for (const face of entities.ofType("Face")) { entities.erase(face); } }, }, { id: "helical-ramp", title: "Helical Ramp", tip: "Adds a spiral ramp surface around the Z axis.", defaults: { startRadius: 0.5, endRadius: 0.5, startWidth: 1, endWidth: 1, pitch: 2, turns: 1, segments: 24, startAngle: 0, }, schema: { startRadius: { ...length("Start radius"), tip: "Inner radius where the ramp starts" }, endRadius: { ...length("End radius"), tip: "Inner radius where the ramp ends" }, startWidth: length("Start width"), endWidth: length("End width"), pitch: { type: "length", label: "Pitch", tip: "Rise per turn; negative pitches go down" }, turns: { type: "number", label: "Turns", decimals: 2, tip: "Negative turns run clockwise" }, segments: count("Segments per turn", 3, 360), startAngle: { type: "angle", label: "Start angle", tip: "Where the ramp starts, from the X axis" }, }, problem: (ramp) => helixProblem(ramp, Math.max(ramp.startWidth, ramp.endWidth)), draw(entities, ramp) { const inner = helixPoints(ramp, ramp.startRadius, ramp.endRadius); const outer = helixPoints(ramp, ramp.startRadius + ramp.startWidth, ramp.endRadius + ramp.endWidth); /** @type {modlumi.Point3dLike[][]} */ const triangles = []; for (let step = 0; step + 1 < inner.length; ++step) { // Ordered so the ramp's front faces up while it turns counterclockwise. const first = [inner[step], outer[step], inner[step + 1]]; const second = [outer[step], outer[step + 1], inner[step + 1]]; triangles.push(...(ramp.turns > 0 ? [first, second] : [first.reverse(), second.reverse()])); } addFaces(entities, triangles); smooth(entities); }, },];
const kShapesById = new Map(kShapes.map((shape) => [shape.id, shape]));
/** * Draws the shape's parameters and returns them, changed where the user edited them, and why * they can't be drawn, if they can't. * @param {modlumi.Ui} ui * @param {Shape} shape * @param {Parameters} parameters * @returns {[Parameters, string | undefined]} */function drawParameters(ui, shape, parameters) { const changed = ui.form(parameters, shape.schema); const problem = shape.problem?.(changed); if (problem) { ui.text(problem, { tone: "warning" }); } return [changed, problem];}
/** * Replaces everything in entities with the shape. * @param {modlumi.Entities} entities * @param {Shape} shape * @param {Parameters} parameters */function redraw(entities, shape, parameters) { for (const element of entities) { if (!(element instanceof modlumi.Edge) && !(element instanceof modlumi.Face) && element.isValid) { entities.erase(element); } } entities.eraseEdges(entities.ofType("Edge")); shape.draw(entities, parameters);}
/** @param {modlumi.PluginContext} context */export function activate(context) { /** * Parameters shown in Properties that can't be drawn yet, by the shape they belong to, with * the stored parameters they were edited from. An undo or redo that changes those drops them. * @type {Map<modlumi.Group | modlumi.ComponentInstance, { from: string, parameters: Parameters }>} */ const drafts = new Map();
/** * Adds the shape as a new group in the active context and selects it. One undo step. * @param {Shape} shape * @param {Parameters} parameters */ function create(shape, parameters) { const group = model.operation(`Add ${shape.title}`, () => { const added = model.activeEntities.addGroup(); added.name = shape.title; shape.draw(added.entities, parameters); context.entityData.set(added, /** @type {ShapeData} */ ({ shape: shape.id, parameters })); return added; }); model.selection.clear(); model.selection.add(group); }
/** * Rebuilds the shape in group from new parameters. One undo step. * @param {modlumi.Group} group * @param {Shape} shape * @param {Parameters} parameters */ function rebuild(group, shape, parameters) { model.operation(`Change ${shape.title}`, () => { // Copies of the shape share its entities until one of them changes. group.makeUnique(); redraw(group.entities, shape, parameters); context.entityData.set(group, /** @type {ShapeData} */ ({ shape: shape.id, parameters })); }); }
/** @param {Shape} shape */ function showDialog(shape) { // The dialog starts from the shape's last parameters. /** @type {Parameters} */ let parameters = { ...shape.defaults, ...context.settings.get(shape.id, {}) }; context.showDialog({ id: "add", title: shape.title, draw(ui) { const [changed, problem] = drawParameters(ui, shape, parameters); parameters = changed; ui.spacing(); ui.row(() => { if (ui.button("Add", { primary: true, disabled: problem !== undefined })) { ui.close(); create(shape, parameters); try { context.settings.set(shape.id, parameters); } catch (error) { // Only the next dialog's starting values are lost. console.warn(`Could not remember the ${shape.title.toLowerCase()} parameters: ${error}`); } } if (ui.button("Cancel")) { ui.close(); } }); }, }); }
for (const shape of kShapes) { context.registerCommand({ id: shape.id, title: `${shape.title}...`, tip: shape.tip, run: () => showDialog(shape) }); }
context.addPropertiesSection({ id: "shape", title: "Shape", draw(ui, container) { /** @type {ShapeData | undefined} */ const data = context.entityData.get(container); const shape = kShapesById.get(data?.shape ?? ""); if (!data || !shape || !(container instanceof modlumi.Group)) { return; } ui.readOnly("Shape", shape.title); const stored = JSON.stringify(data.parameters); const draft = drafts.get(container); const [changed, problem] = drawParameters(ui, shape, draft?.from === stored ? draft.parameters : data.parameters); if (problem) { drafts.set(container, { from: stored, parameters: changed }); return; } drafts.delete(container); if (JSON.stringify(changed) !== stored) { rebuild(container, shape, changed); } }, });}Solid Inspector
Section titled “Solid Inspector”Finds what keeps a group or component from being a solid, shows each problem in red, and fixes what can be fixed. It finds reversed, internal and external faces, stray edges, holes, hidden faces, nested groups and, if you ask, short edges.
Select a group or component and choose Extensions → Solid Inspector → Solid Inspector. The panel lists the problems by kind; the arrow keys step through them and glide the camera to each one. Fix All, or the fix for one kind, makes the repairs in one undo step. Some problems, such as a missing face, can’t be fixed automatically.
Port of Solid Inspector² by Thomas Thomassen (MIT).
Source code
{ "id": "solid-inspector", "name": "Solid Inspector", "version": "1.0.0", "description": "Finds what keeps a group or component from being a solid, such as holes, stray edges, internal faces and reversed faces, shows each in the model and fixes what can be fixed in one undo step.", "author": "Modlumi, after Solid Inspector² by Thomas Thomassen", "license": "MIT", "apiVersion": 1}// Finds what keeps a group or component from being a solid: holes, stray edges, internal and// external faces, reversed faces, hidden faces and nested instances. The tool shows each in// the model, lists them in its options and fixes what it can in one undo step.//// A port of Solid Inspector² (https://github.com/thomthom/solid-inspector), whose shell// resolution is based on Shellify by Anders Lyhagen://// Copyright (c) 2010-2014 Thomas Thomassen//// Permission is hereby granted, free of charge, to any person obtaining a copy of this software// and associated documentation files (the "Software"), to deal in the Software without// restriction, including without limitation the rights to use, copy, modify, merge, publish,// distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the// Software is furnished to do so, subject to the following conditions://// The above copyright notice and this permission notice shall be included in all copies or// substantial portions of the Software.//// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING// BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
/** * @typedef {"reversedFace" | "internalFace" | "externalFace" | "strayEdge" | "faceHole" | "hiddenFace" * | "surfaceBorder" | "internalFaceEdge" | "sharedEdge" | "nestedInstance" | "shortEdge"} ErrorKind * @typedef {"erase" | "reverse" | "show" | null} Fix * @typedef {{ * kind: ErrorKind, * entities: modlumi.DrawingElement[], * triangles: modlumi.Point3d[][], * lines: modlumi.Point3d[], * }} SolidError * @typedef {{ * entities: modlumi.Entities, * transformation: modlumi.Transformation, * name: string, * }} Target */
const model = modlumi.activeModel;const kTwoPi = Math.PI * 2;/** @type {modlumi.Color} */const kEdgeColor = { r: 1, g: 0, b: 0, a: 1 };/** @type {modlumi.Color} */const kFaceColor = { r: 1, g: 0, b: 0, a: 0.5 };/** @type {modlumi.Color} */const kCurrentEdgeColor = { r: 1, g: 0.58, b: 0.03, a: 1 };/** @type {modlumi.Color} */const kCurrentFaceColor = { r: 1, g: 0.58, b: 0.03, a: 0.6 };
/** @type {Record<ErrorKind, { name: string, fix: Fix, description: string }>} */const kErrorKinds = { reversedFace: { name: "Reversed Faces", fix: "reverse", description: "The front of every face of a solid must face outward, or many applications won't treat it as " + "a solid. Fixed by reversing the faces.", }, internalFace: { name: "Internal Faces", fix: "erase", description: "Faces inside a mesh that should be a solid. Fixed by erasing them.", }, externalFace: { name: "External Faces", fix: "erase", description: "Faces sticking out of a mesh that should be a solid. Fixed by erasing them.", }, strayEdge: { name: "Stray Edges", fix: "erase", description: "Edges that bound no face, so they aren't part of any solid. Fixed by erasing them.", }, faceHole: { name: "Face Holes", fix: "erase", description: "Edges that cut a hole in a face. Fixed by erasing the hole's edges.", }, hiddenFace: { name: "Hidden Faces", fix: "show", description: "Hidden faces aren't exported to STL files and may leave holes in the mesh. Fixed by showing them.", }, surfaceBorder: { name: "Surface Borders", fix: null, description: "Edges that bound only one face: the border of a surface or a hole in the mesh. These can't be " + "fixed automatically: close the mesh by hand and inspect it again.", }, internalFaceEdge: { name: "Internal Face Edges", fix: null, description: "Edges of internal faces. While the mesh has holes, it isn't possible to tell which faces are " + "internal: fix the holes and inspect it again.", }, sharedEdge: { name: "Shared Edges", fix: null, description: "Edges where closed volumes meet, so they don't form one solid. These can't be fixed " + "automatically: move the volumes apart or join them into one.", }, nestedInstance: { name: "Nested Instances", fix: null, description: "Solid tools don't count groups and components inside a solid as part of it. Explode them to " + "make their geometry part of the solid.", }, shortEdge: { name: "Short Edges", fix: null, description: "Very small geometry can give unpredictable results through precision errors. These can't be " + "fixed automatically; scaling the model up by a factor of 10 can work around them.", },};
// The order errors are listed and cycled through.const kKindOrder = /** @type {ErrorKind[]} */ (Object.keys(kErrorKinds));
/** * Returns the vector from the start of the edge to its end. * @param {modlumi.Edge} edge */function edgeDirection(edge) { return edge.start.position.vectorTo(edge.end.position);}
// The faces, edges and vertices of one drawing context, with what the shell resolution looks up// often, by entity id.class Mesh { /** * @param {modlumi.Face[]} faces * @param {modlumi.Edge[]} edges */ constructor(faces, edges) { this.faces = faces; this.edges = edges; /** @type {Map<number, modlumi.Face[]>} */ this.edgeFaces = new Map(); /** @type {Map<number, modlumi.Edge[]>} The edges of each face's loops. */ this.faceEdges = new Map(); /** @type {Map<number, Map<number, boolean>>} Whether each face's loop runs along an edge backward. */ this.reversedUses = new Map(); /** @type {Map<number, modlumi.Vector3d>} */ this.normals = new Map(); /** @type {Map<number, modlumi.Edge[]>} */ this.vertexEdges = new Map(); /** @type {Map<number, modlumi.Vertex>} */ this.vertices = new Map(); for (const face of faces) { const uses = new Map(); const loopEdges = []; for (const loop of face.loops) { for (const use of loop.edgeUses) { uses.set(use.edge.entityId, use.isReversed); loopEdges.push(use.edge); } } this.reversedUses.set(face.entityId, uses); this.faceEdges.set(face.entityId, loopEdges); this.normals.set(face.entityId, face.normal ?? new modlumi.Vector3d(0, 0, 0)); } for (const edge of edges) { this.edgeFaces.set(edge.entityId, [...edge.faces]); for (const vertex of [edge.start, edge.end]) { this.vertices.set(vertex.entityId, vertex); const at = this.vertexEdges.get(vertex.entityId); if (at === undefined) { this.vertexEdges.set(vertex.entityId, [edge]); } else { at.push(edge); } } } }
/** @param {modlumi.Edge} edge */ getEdgeFaces(edge) { return this.edgeFaces.get(edge.entityId) ?? []; }
/** @param {modlumi.Vertex} vertex */ getVertexEdges(vertex) { return this.vertexEdges.get(vertex.entityId) ?? []; }}
// Resolves the outer shell of a mesh: the faces that would form a closed solid, the internal and// external faces that don't belong to it, and the shell faces that face inward.class Shell { /** @param {Mesh} mesh */ constructor(mesh) { this.mesh = mesh; /** @type {Set<number>} */ this.shellFaces = new Set(); /** @type {Set<number>} */ this.internalFaces = new Set(); /** @type {Set<number>} */ this.externalFaces = new Set(); /** @type {Set<number>} */ this.reversedFaces = new Set(); }
resolve() { const groups = this.findGeometryGroups(); if (groups.length < 2) { this.resolveGroups(groups, [0]); return; } // A part inside another, such as a cavity's boundary, faces the other way. Which faces // form each part's shell doesn't depend on that, so find them first, and count how many // other shells enclose each part without partitions and flaps in the way. const probe = new Shell(this.mesh); probe.resolveGroups(groups, groups.map(() => 0)); this.resolveGroups(groups, getNestingDepths(this.mesh, groups, probe.shellFaces)); }
/** * Resolves the shell of each part of the mesh, facing out at an even nesting depth. * @param {modlumi.Edge[][]} groups * @param {number[]} depths */ resolveGroups(groups, depths) { const allFaces = this.mesh.faces.map((face) => face.entityId); // First a shell of the faces oriented outward. const front = new Set(); for (const [index, group] of groups.entries()) { const start = this.findStartFace(group, depths[index] % 2 === 0); if (start !== null) { for (const face of this.findShell(start)) { front.add(face); } } } // The faces it doesn't reach are internal; it may still hold external "flaps". this.internalFaces = new Set(allFaces.filter((face) => !front.has(face))); // findShell changes the reversed faces, so keep the first pass's to restore them. const firstReversed = new Set(this.reversedFaces); // A second shell, looking for normals the other way, tells external faces apart. const back = new Set(); for (const [index, group] of groups.entries()) { const start = this.findStartFace(group, depths[index] % 2 === 1); if (start !== null) { for (const face of this.findShell(start)) { back.add(face); } } } this.shellFaces = new Set([...front].filter((face) => back.has(face))); this.externalFaces = new Set( allFaces.filter((face) => !this.internalFaces.has(face) && !this.shellFaces.has(face))); this.reversedFaces = new Set([...this.shellFaces].filter((face) => firstReversed.has(face))); }
// Whether the shell forms a manifold: every edge of its faces bounds two or more of them. isValid() { if (this.shellFaces.size === 0) { return false; } for (const face of this.shellFaces) { for (const edge of this.mesh.faceEdges.get(face) ?? []) { if (this.mesh.getEdgeFaces(edge).filter((other) => this.shellFaces.has(other.entityId)).length < 2) { return false; } } } return true; }
// Returns the edges of each part of the mesh whose faces connect through shared edges. Parts // that only touch at a vertex or through stray edges are separate, so each gets its own shell. findGeometryGroups() { /** @type {modlumi.Edge[][]} */ const groups = []; const visited = new Set(); for (const seed of this.mesh.faces) { if (visited.has(seed.entityId)) { continue; } visited.add(seed.entityId); /** @type {Map<number, modlumi.Edge>} */ const edges = new Map(); const stack = [seed]; while (stack.length > 0) { const face = /** @type {modlumi.Face} */ (stack.pop()); for (const edge of this.mesh.faceEdges.get(face.entityId) ?? []) { edges.set(edge.entityId, edge); for (const next of this.mesh.getEdgeFaces(edge)) { if (!visited.has(next.entityId)) { visited.add(next.entityId); stack.push(next); } } } } groups.push([...edges.values()]); } return groups; }
/** * The faces at an edge that aren't known to be internal. * @param {modlumi.Edge} edge */ getFaces(edge) { return this.mesh.getEdgeFaces(edge).filter((face) => !this.internalFaces.has(face.entityId)); }
/** @param {modlumi.Face} face */ getNormal(face) { const normal = /** @type {modlumi.Vector3d} */ (this.mesh.normals.get(face.entityId)); return this.reversedFaces.has(face.entityId) ? normal.negate() : normal; }
/** * @param {modlumi.Edge} edge * @param {modlumi.Face} face */ isEdgeReversedIn(edge, face) { const reversed = this.mesh.reversedUses.get(face.entityId)?.get(edge.entityId) ?? false; return this.reversedFaces.has(face.entityId) ? !reversed : reversed; }
/** @param {modlumi.Face} face */ reverseFace(face) { if (!this.reversedFaces.delete(face.entityId)) { this.reversedFaces.add(face.entityId); } }
/** * Finds a face on the shell: at the highest vertex, along its edge least aligned with the z * axis, the face whose normal is closest to it, turned up for the outside or down for the * inside. It can't pick the right side of a face at a hole whose normal is horizontal. * @param {modlumi.Edge[]} edges * @param {boolean} outside * @returns {modlumi.Face | null} */ findStartFace(edges, outside) { // Only the group's own edges count: another part may touch it at a vertex. const own = new Set(edges.map((edge) => edge.entityId)); /** @type {modlumi.Vertex | null} */ let highest = null; for (const edge of edges) { for (const vertex of [edge.start, edge.end]) { if ((highest === null || vertex.position.z > highest.position.z) && this.getFaces(edge).length > 0) { highest = vertex; } } } if (highest === null) { return null; } /** @type {modlumi.Edge | null} */ let edge = null; let flattest = Infinity; for (const candidate of this.mesh.getVertexEdges(highest)) { const z = Math.abs(edgeDirection(candidate).normalize().z); if (own.has(candidate.entityId) && this.getFaces(candidate).length > 0 && z < flattest) { flattest = z; edge = candidate; } } if (edge === null) { return null; } /** @type {modlumi.Face | null} */ let face = null; let steepest = -Infinity; for (const candidate of this.getFaces(edge)) { const z = Math.abs(this.getNormal(candidate).z); if (z > steepest) { steepest = z; face = candidate; } } if (face === null) { return null; } const z = this.getNormal(face).z; if (outside ? z < 0 : z > 0) { this.reverseFace(face); } return face; }
/** * A vector along the edge in the direction the face's loop runs. * @param {modlumi.Edge} edge * @param {modlumi.Face} face */ getEdgeVector(edge, face) { return this.isEdgeReversedIn(edge, face) ? edgeDirection(edge).negate() : edgeDirection(edge); }
/** * Given a shell face and one of its edges, returns the other shell face at the edge, reversed * to match the face, or null. * @param {modlumi.Edge} edge * @param {modlumi.Face} face * @returns {modlumi.Face | null} */ findOtherShellFace(edge, face) { const faces = this.getFaces(edge); if (faces.length < 2) { return null; } if (faces.length === 2) { const other = faces.find((candidate) => candidate.entityId !== face.entityId); if (other === undefined) { return null; } if (this.isEdgeReversedIn(edge, face) === this.isEdgeReversedIn(edge, other)) { this.reverseFace(other); } return other; } // With more faces at the edge, the shell continues into the one at the smallest angle // around the edge, turning from the face's back. const edgeVector = this.getEdgeVector(edge, face); const normal = this.getNormal(face); const product = normal.cross(edgeVector); const reversed = this.isEdgeReversedIn(edge, face); let smallest = kTwoPi; /** @type {modlumi.Face | null} */ let shellFace = null; for (const other of faces) { if (other.entityId === face.entityId) { continue; } let otherNormal = this.getNormal(other); if (this.isEdgeReversedIn(edge, other) === reversed) { otherNormal = otherNormal.negate(); } const otherProduct = edgeVector.cross(otherNormal); let angle = product.angleBetween(otherProduct); if (otherProduct.dot(normal) < 0) { angle = kTwoPi - angle; } if (angle < smallest) { smallest = angle; shellFace = other; } } if (shellFace === null) { return null; } if (this.isEdgeReversedIn(edge, shellFace) === reversed) { this.reverseFace(shellFace); } return shellFace; }
/** * Walks the mesh from a shell face and returns the ids of the faces of its outer shell. * @param {modlumi.Face} start */ findShell(start) { const stack = [start]; const processed = new Set([`f${start.entityId}`]); /** @type {number[]} */ const shell = []; while (stack.length > 0) { const face = /** @type {modlumi.Face} */ (stack.pop()); shell.push(face.entityId); for (const edge of this.mesh.faceEdges.get(face.entityId) ?? []) { if (processed.has(`e${edge.entityId}`) || this.getFaces(edge).length < 2) { continue; } processed.add(`e${edge.entityId}`); const other = this.findOtherShellFace(edge, face); if (other === null || processed.has(`f${other.entityId}`)) { continue; } stack.push(other); processed.add(`f${other.entityId}`); } } return shell; }}
// A ray direction unlikely to run along an edge or within a face of modeled geometry.const kRay = new modlumi.Vector3d(0.5773, 0.5774, 0.5776).normalize();
/** * Whether a ray from origin along kRay crosses a triangle, by the Möller-Trumbore test. * @param {modlumi.Point3d} origin * @param {readonly modlumi.Point3d[]} triangle */function isRayCrossing(origin, [a, b, c]) { const ab = a.vectorTo(b); const ac = a.vectorTo(c); const p = kRay.cross(ac); const determinant = ab.dot(p); if (Math.abs(determinant) < 1e-12) { return false; } const t = a.vectorTo(origin); const u = t.dot(p) / determinant; if (u < 0 || u > 1) { return false; } const q = t.cross(ab); const v = kRay.dot(q) / determinant; return v >= 0 && u + v <= 1 && ac.dot(q) / determinant > 1e-9;}
/** * Returns, for each part of the mesh, how many shells of other parts enclose it. * @param {Mesh} mesh * @param {modlumi.Edge[][]} groups * @param {Set<number>} shellFaces */function getNestingDepths(mesh, groups, shellFaces) { const triangles = groups.map((edges) => { /** @type {Map<number, modlumi.Face>} */ const faces = new Map(); for (const edge of edges) { for (const face of mesh.getEdgeFaces(edge)) { if (shellFaces.has(face.entityId)) { faces.set(face.entityId, face); } } } return [...faces.values()].flatMap((face) => { try { return face.triangles; } catch { return []; } }); }); return groups.map((_, index) => { // The middle of one of the part's own triangles, which no other part can merely touch. const triangle = triangles[index][0]; if (triangle === undefined) { return 0; } const [a, b, c] = triangle; const origin = new modlumi.Point3d((a.x + b.x + c.x) / 3, (a.y + b.y + c.y) / 3, (a.z + b.z + c.z) / 3); let depth = 0; for (const [other, others] of triangles.entries()) { if (other !== index && others.filter((triangle) => isRayCrossing(origin, triangle)).length % 2 === 1) { ++depth; } } return depth; });}
/** * Splits edges into groups that touch at their vertices. * @param {modlumi.Edge[]} edges */function groupConnectedEdges(edges) { const remaining = new Map(edges.map((edge) => [edge.entityId, edge])); /** @type {modlumi.Edge[][]} */ const groups = []; for (const seed of edges) { if (!remaining.delete(seed.entityId)) { continue; } const group = [seed]; const stack = [seed]; while (stack.length > 0) { const edge = /** @type {modlumi.Edge} */ (stack.pop()); for (const vertex of [edge.start, edge.end]) { for (const next of vertex.edges) { if (remaining.delete(next.entityId)) { group.push(next); stack.push(next); } } } } groups.push(group); } return groups;}
/** * Returns the ids of the faces that close volumes of their own: what is left after peeling * away, again and again, every face with an edge that fewer than two remaining faces bound. * Flaps and partitions peel away, while a box with a flap keeps its six faces. * @param {Mesh} mesh * @param {modlumi.Face[]} faces */function findClosedVolumes(mesh, faces) { const remaining = new Set(faces.map((face) => face.entityId)); let peeled = true; while (peeled) { peeled = false; for (const face of faces) { if (!remaining.has(face.entityId)) { continue; } const open = (mesh.faceEdges.get(face.entityId) ?? []).some((edge) => mesh.getEdgeFaces(edge).filter((other) => remaining.has(other.entityId)).length < 2); if (open) { remaining.delete(face.entityId); peeled = true; } } } return remaining;}
/** * Finds what keeps the entities from forming a solid. * @param {modlumi.Entities} entities * @param {number} shortEdge Edges shorter than this are reported; 0 reports none. * @returns {{ kind: ErrorKind, entities: modlumi.DrawingElement[] }[]} */function findErrors(entities, shortEdge) { /** @type {{ kind: ErrorKind, entities: modlumi.DrawingElement[] }[]} */ const errors = []; const faces = /** @type {modlumi.Face[]} */ (entities.ofType("Face")); const edges = /** @type {modlumi.Edge[]} */ (entities.ofType("Edge")); for (const face of faces) { if (face.hidden) { errors.push({ kind: "hiddenFace", entities: [face] }); } } const mesh = new Mesh(faces, edges); /** @type {modlumi.Edge[]} */ const borderEdges = []; /** @type {modlumi.Edge[]} */ const holeEdges = []; /** @type {modlumi.Edge[]} */ const internalEdges = []; for (const edge of edges) { const edgeFaces = mesh.getEdgeFaces(edge); if (edgeFaces.length === 0) { errors.push({ kind: "strayEdge", entities: [edge] }); } else if (edgeFaces.length === 1) { const outer = edgeFaces[0].outerLoop.edges.some((other) => other.entityId === edge.entityId); (outer ? borderEdges : holeEdges).push(edge); } else if (edgeFaces.length > 2) { internalEdges.push(edge); } } const shell = new Shell(mesh); shell.resolve(); if (shell.isValid()) { // Faces off the shell that close a volume of their own, such as a second box sharing an // edge with the first, are another solid rather than faces to erase. const volumes = findClosedVolumes(mesh, faces.filter((face) => shell.internalFaces.has(face.entityId) || shell.externalFaces.has(face.entityId))); for (const face of faces) { const kind = volumes.has(face.entityId) ? null : shell.internalFaces.has(face.entityId) ? "internalFace" : shell.externalFaces.has(face.entityId) ? "externalFace" : shell.reversedFaces.has(face.entityId) ? "reversedFace" : null; if (kind !== null) { errors.push({ kind, entities: [face] }); } } // Fixing erases the other faces; more than two that stay at an edge keep it non-manifold. for (const edge of internalEdges) { const staying = mesh.getEdgeFaces(edge).filter((face) => shell.shellFaces.has(face.entityId) || volumes.has(face.entityId)); if (staying.length > 2) { errors.push({ kind: "sharedEdge", entities: [edge] }); } } } else { for (const group of groupConnectedEdges(borderEdges)) { errors.push({ kind: "surfaceBorder", entities: group }); } for (const group of groupConnectedEdges(holeEdges)) { errors.push({ kind: "faceHole", entities: group }); } for (const edge of internalEdges) { errors.push({ kind: "internalFaceEdge", entities: [edge] }); } } for (const instance of [...entities.ofType("Group"), ...entities.ofType("ComponentInstance")]) { errors.push({ kind: "nestedInstance", entities: [instance] }); } if (shortEdge > 0) { for (const edge of edges) { if (edge.length < shortEdge) { errors.push({ kind: "shortEdge", entities: [edge] }); } } } return errors;}
/** * Adds what draws an error, in the coordinates of the context being edited. * @param {{ kind: ErrorKind, entities: modlumi.DrawingElement[] }} error * @param {modlumi.Transformation} transformation * @returns {SolidError} */function prepareError(error, transformation) { /** @type {modlumi.Point3d[][]} */ const triangles = []; /** @type {modlumi.Point3d[]} */ const lines = []; for (const entity of error.entities) { if (entity.type === "Edge") { const edge = /** @type {modlumi.Edge} */ (entity); lines.push(edge.start.position.transform(transformation), edge.end.position.transform(transformation)); } else if (entity.type === "Face") { const face = /** @type {modlumi.Face} */ (entity); try { for (const triangle of face.triangles) { triangles.push(triangle.map((corner) => corner.transform(transformation))); } } catch { // A degenerate face has no triangles; its edges still show it. } if (error.kind !== "internalFace" && error.kind !== "externalFace") { for (const edge of face.loops.flatMap((loop) => loop.edges)) { lines.push(edge.start.position.transform(transformation), edge.end.position.transform(transformation)); } } } else { const bounds = entity.bounds; if (bounds !== null) { // The instance's bounding box. const { min, max } = bounds; /** @param {number} corner */ const at = (corner) => new modlumi.Point3d(corner & 1 ? max.x : min.x, corner & 2 ? max.y : min.y, corner & 4 ? max.z : min.z).transform(transformation); for (const [a, b] of [[0, 1], [2, 3], [4, 5], [6, 7], [0, 2], [1, 3], [4, 6], [5, 7], [0, 4], [1, 5], [2, 6], [3, 7]]) { lines.push(at(a), at(b)); } } } } return { ...error, triangles, lines };}
/** * Returns the selected group or component instance to inspect, or the context being edited. * @returns {Target} */function findTarget() { for (const entity of model.selection.toArray()) { if (entity.type === "Group") { const group = /** @type {modlumi.Group} */ (entity); return { entities: group.entities, transformation: group.transformation, name: group.name || "Group" }; } if (entity.type === "ComponentInstance") { const instance = /** @type {modlumi.ComponentInstance} */ (entity); return { entities: instance.definition.entities, transformation: instance.transformation, name: instance.name || instance.definition.name, }; } } return { entities: model.activeEntities, transformation: new modlumi.Transformation(), name: "the context being edited" };}
/** * Fixes the errors that can be fixed in one undo step, and returns how many couldn't be. * @param {SolidError[]} errors * @param {modlumi.Entities} entities */function fixErrors(errors, entities) { /** @type {Map<number, modlumi.Face>} */ const eraseFaces = new Map(); /** @type {Map<number, modlumi.Edge>} */ const eraseEdges = new Map(); let unfixed = 0; for (const error of errors) { if (kErrorKinds[error.kind].fix === null) { ++unfixed; } else if (kErrorKinds[error.kind].fix === "erase") { for (const entity of error.entities) { if (entity.type === "Face") { eraseFaces.set(entity.entityId, /** @type {modlumi.Face} */ (entity)); } else if (entity.type === "Edge") { eraseEdges.set(entity.entityId, /** @type {modlumi.Edge} */ (entity)); } } } } // Edges between erased faces would be left stray. for (const face of eraseFaces.values()) { for (const edge of face.loops.flatMap((loop) => loop.edges)) { if (edge.faces.every((other) => eraseFaces.has(other.entityId))) { eraseEdges.set(edge.entityId, edge); } } } // Erasing an edge of a curve erases the whole curve, so keep curves that would lose more. const edges = [...eraseEdges.values()].filter((edge) => edge.curve === null || edge.curve.edges.every((other) => eraseEdges.has(other.entityId))); unfixed += eraseEdges.size - edges.length > 0 ? 1 : 0; model.operation("Repair Solid", () => { for (const face of eraseFaces.values()) { if (face.isValid) { entities.erase(face); } } const valid = edges.filter((edge) => edge.isValid); if (valid.length > 0) { entities.eraseEdges(valid); } for (const error of errors) { const fix = kErrorKinds[error.kind].fix; for (const entity of error.entities) { if (!entity.isValid) { continue; } if (fix === "reverse") { /** @type {modlumi.Face} */ (entity).reverse(); } else if (fix === "show") { entity.hidden = false; } } } }); return unfixed;}
/** @param {modlumi.PluginContext} context */export function activate(context) { let active = false; /** @type {Target | null} */ let target = null; /** @type {SolidError[]} */ let errors = []; /** @type {ErrorKind | null} The kind of error shown, or null for every kind. */ let shownKind = null; /** @type {number | null} The error cycled to with the arrow keys, in the shown errors. */ let current = null; /** Edges shorter than this many meters are reported; 0 reports none. */ let shortEdge = 0; /** Whether cycling to an error glides the camera to it. */ let zoomToErrors = Boolean(context.settings.get("zoomToErrors", true));
function getShownErrors() { return shownKind === null ? errors : errors.filter((error) => error.kind === shownKind); }
function showStatus() { const shown = getShownErrors(); tool.status = current === null || current >= shown.length ? "Click a group or component to inspect it. The arrow keys cycle through the errors." : `Error ${current + 1} of ${shown.length}: ${kErrorKinds[shown[current].kind].name}. The arrow keys ` + "cycle through the errors."; }
/** * Glides the camera to frame an error, looking in the same direction as before. * @param {SolidError} error */ function zoomTo(error) { // The error's points are in the coordinates of the context being edited. const toWorld = model.activeTransformation; const points = [...error.triangles.flat(), ...error.lines].map((point) => point.transform(toWorld)); if (points.length === 0) { return; } const low = points.reduce((a, b) => new modlumi.Point3d(Math.min(a.x, b.x), Math.min(a.y, b.y), Math.min(a.z, b.z))); const high = points.reduce((a, b) => new modlumi.Point3d(Math.max(a.x, b.x), Math.max(a.y, b.y), Math.max(a.z, b.z))); const box = new modlumi.BoundingBox(low, high); const camera = context.viewport.camera; const direction = camera.eye.vectorTo(camera.target).normalize(); // Far enough that the error fills about a third of the view across its narrower side, but // never inside it. The field of view is vertical; a narrow view sees less sideways. const radius = Math.max(box.diagonal / 2, 0.05); const aspect = context.viewport.width / Math.max(1, context.viewport.height); const reach = Math.tan((camera.fieldOfView * Math.PI) / 360) * Math.min(1, aspect || 1); const distance = (1.5 * radius) / reach + radius; const framed = new modlumi.Camera(box.center.offset(direction.scale(-distance)), box.center, { up: camera.up, perspective: camera.perspective, fieldOfView: camera.fieldOfView }); context.viewport.setCamera(framed, { duration: 0.4 }); }
// Inspects the selected group or component, or the context being edited. function analyze() { target = findTarget(); const { transformation } = target; errors = findErrors(target.entities, shortEdge) .sort((a, b) => kKindOrder.indexOf(a.kind) - kKindOrder.indexOf(b.kind)) .map((error) => prepareError(error, transformation)); if (shownKind !== null && !errors.some((error) => error.kind === shownKind)) { shownKind = null; } current = null; showStatus(); }
/** @param {SolidError[]} fixing */ function fix(fixing) { if (target === null) { return; } const unfixed = fixErrors(fixing, target.entities); analyze(); if (unfixed > 0) { context.showMessage("Some errors can't be fixed automatically and remain."); } else { const solid = errors.length === 0 && target.entities.ofType("Face").length > 0; context.showMessage(solid ? "All errors fixed: it's a solid." : "Errors fixed."); } }
const tool = context.registerTool({ id: "inspect", title: "Solid Inspector", tip: "Finds and fixes what keeps a group or component from being a solid.", snap: false, activate() { active = true; shownKind = null; analyze(); }, deactivate() { active = false; target = null; errors = []; }, onMouseUp(event) { // Clicking a group or component inspects it; clicking anything else, the context. model.selection.clear(); // What Select would pick: the container in the context being edited. const picked = event.picked?.path[model.activePath.length]; if (picked !== undefined) { model.selection.add(picked); } shownKind = null; analyze(); }, onKeyDown(event) { const shown = getShownErrors(); if (shown.length === 0) { return; } const step = event.key === "up" || event.key === "right" ? 1 : event.key === "down" || event.key === "left" ? -1 : 0; if (step !== 0) { current = current === null ? 0 : (current + step + shown.length) % shown.length; showStatus(); if (zoomToErrors) { zoomTo(shown[current]); } } }, onCancel() { current = null; showStatus(); }, options(ui) { if (target === null) { return; } ui.text(`Inspecting ${target.name}`, { tone: "muted" }); if (errors.length === 0) { const solid = target.entities.ofType("Face").length > 0; ui.text(solid ? "No errors: it's a solid." : "Nothing to inspect.", { tone: solid ? "success" : "normal" }); } else { const rows = kKindOrder .map((kind) => ({ kind, count: errors.filter((error) => error.kind === kind).length })) .filter(({ count }) => count > 0) .map(({ kind, count }) => ({ id: kind, cells: [kErrorKinds[kind].name, count] })); const chosen = /** @type {ErrorKind | null} */ (ui.table("Errors", ["Error", { title: "Count", numeric: true }], rows, shownKind, { visibleRows: Math.min(rows.length, 6) })); if (chosen !== shownKind) { shownKind = chosen; current = null; showStatus(); } if (shownKind !== null) { const kind = kErrorKinds[shownKind]; ui.text(kind.description, { tone: "muted" }); if (kind.fix !== null && ui.button(`Fix ${kind.name}`)) { fix(getShownErrors()); } } if (errors.some((error) => kErrorKinds[error.kind].fix !== null) && ui.button("Fix All", { primary: true })) { fix(errors); } } ui.separator(); const zoom = ui.checkbox("Zoom to errors", zoomToErrors, { tip: "The arrow keys glide the camera to each error" }); if (zoom !== zoomToErrors) { zoomToErrors = zoom; context.settings.set("zoomToErrors", zoom); } const findShort = ui.checkbox("Find short edges", shortEdge > 0); if (findShort !== shortEdge > 0) { shortEdge = findShort ? 0.001 : 0; analyze(); } if (shortEdge > 0) { const threshold = ui.length("Shorter than", shortEdge, { min: 0.000001 }); if (threshold !== shortEdge) { shortEdge = threshold; analyze(); } } }, draw(view) { if (target === null) { return; } const shown = getShownErrors(); const lines = []; for (const [index, error] of shown.entries()) { if (index === current) { continue; } for (const triangle of error.triangles) { view.polygon(triangle, { color: kFaceColor }); } lines.push(...error.lines); } if (lines.length > 0) { view.lines(lines, { color: kEdgeColor, width: 2 }); } const highlighted = current === null ? undefined : shown[current]; if (highlighted !== undefined) { for (const triangle of highlighted.triangles) { view.polygon(triangle, { color: kCurrentFaceColor }); } if (highlighted.lines.length > 0) { view.lines(highlighted.lines, { color: kCurrentEdgeColor, width: 3 }); } } }, });
// Undo, redo and edits elsewhere change what the inspected geometry gets wrong, and entering // or leaving a group, selecting another or opening a document changes what is inspected. const reanalyze = () => { if (active) { analyze(); } }; context.onModelChanged(reanalyze); context.onActiveContextChanged(reanalyze); context.onSelectionChanged(reanalyze); context.onDocumentOpened(reanalyze);}Imports and exports STL models, binary and ASCII, for 3D printing and other tools.
Use File → Import STL Model… and File → Export STL Model…. STL files have no units: Extensions → STL → STL Options… sets the units files are read in (millimeters by default) and written in, binary or ASCII, whether to export only the selection, and whether an import merges coplanar triangles or keeps its position.
Port of sketchup-stl by Trimble and its contributors (MIT).
Source code
{ "id": "stl", "name": "STL", "version": "1.0.0", "description": "Imports and exports STL models, ASCII and binary, for 3D printing and other tools.", "author": "Modlumi, after sketchup-stl by Trimble Navigation and its contributors", "license": "MIT", "apiVersion": 1}// STL import and export, ported from sketchup-stl.//// Copyright (c) 2014 Trimble Navigation Ltd.//// Permission is hereby granted, free of charge, to any person obtaining a copy of this software// and associated documentation files (the "Software"), to deal in the Software without// restriction, including without limitation the rights to use, copy, modify, merge, publish,// distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the// Software is furnished to do so, subject to the following conditions://// The above copyright notice and this permission notice shall be included in all copies or// substantial portions of the Software.//// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING// BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
/** * @typedef {"Millimeters" | "Centimeters" | "Meters" | "Inches" | "Feet"} Unit * @typedef {{ * importUnits: Unit, mergeFaces: boolean, preserveOrigin: boolean, * exportUnits: Unit | "Model Units", format: "Binary" | "ASCII", selectionOnly: boolean, * }} Options * @typedef {[modlumi.Point3d, modlumi.Point3d, modlumi.Point3d]} Triangle */
const model = modlumi.activeModel;/** Meters per unit. @type {Record<Unit, number>} */const kMeters = { Millimeters: 0.001, Centimeters: 0.01, Meters: 1, Inches: 0.0254, Feet: 0.3048 };/** @type {Unit[]} */const kUnits = ["Millimeters", "Centimeters", "Meters", "Inches", "Feet"];/** The STL unit of each model unit; yards export as feet. */const kModelUnits = /** @type {Record<string, Unit>} */ ({ mm: "Millimeters", cm: "Centimeters", m: "Meters", in: "Inches", ft: "Feet", yd: "Feet",});/** @type {Options} */const kDefaults = { importUnits: "Millimeters", mergeFaces: false, preserveOrigin: true, exportUnits: "Model Units", format: "Binary", selectionOnly: false,};// A binary STL file is an 80 byte header, a facet count, and 50 bytes per facet.const kHeaderBytes = 80;const kFacetBytes = 50;
/** * Whether bytes are a binary STL file: its size matches the facet count in its header. Some * binary files start with "solid" too, so the word alone tells nothing. * @param {Uint8Array} bytes */export function isBinaryStl(bytes) { if (bytes.length < kHeaderBytes + 4) { return false; } const count = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(kHeaderBytes, true); return bytes.length === kHeaderBytes + 4 + count * kFacetBytes;}
/** * Reads the triangles of a binary STL file, scaled to meters. * @param {Uint8Array} bytes * @param {number} scale * @returns {number[][][]} */function readBinary(bytes, scale) { const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); const count = Math.min(view.getUint32(kHeaderBytes, true), Math.floor((bytes.length - kHeaderBytes - 4) / kFacetBytes)); const triangles = []; for (let facet = 0; facet < count; ++facet) { // Each facet starts with its normal, which the corners' order already gives. const at = kHeaderBytes + 4 + facet * kFacetBytes + 12; /** @param {number} index */ const corner = (index) => [0, 1, 2].map((axis) => view.getFloat32(at + index * 12 + axis * 4, true) * scale); triangles.push([corner(0), corner(1), corner(2)]); } return triangles;}
/** * Reads the triangles of an ASCII STL file, scaled to meters: every three "vertex" lines. * @param {string} text * @param {number} scale * @returns {number[][][]} */function readAscii(text, scale) { const triangles = []; let triangle = []; for (const line of text.split(/\r?\n/)) { const words = line.trim().split(/\s+/); if (words[0] !== "vertex" || words.length < 4) { continue; } triangle.push(words.slice(1, 4).map((value) => Number(value) * scale)); if (triangle.length === 3) { triangles.push(triangle); triangle = []; } } return triangles;}
/** * Decodes ASCII text, in chunks small enough to pass as arguments. * @param {Uint8Array} bytes */function decodeAscii(bytes) { let text = ""; for (let at = 0; at < bytes.length; at += 8192) { text += String.fromCharCode(...bytes.subarray(at, at + 8192)); } return text;}
/** * Reads an STL file's triangles, scaled to meters. * @param {Uint8Array} bytes * @param {number} scale */export function readStl(bytes, scale) { if (isBinaryStl(bytes)) { return readBinary(bytes, scale); } const text = decodeAscii(bytes); const triangles = readAscii(text, scale); // Some files that claim to be ASCII hold binary facets, but an ASCII file that ends its solid // is ASCII, empty or not. if (triangles.length > 0 || bytes.length < kHeaderBytes + 4 || /\bendsolid\b/.test(text)) { return triangles; } return readBinary(bytes, scale);}
/** * Collects the triangles of the visible faces in entities and the groups and components in * them, in the coordinates transformation maps to, each counterclockwise around its outside. * @param {Iterable<modlumi.DrawingElement>} elements * @param {modlumi.Transformation} transformation * @param {Triangle[]} triangles */function collectTriangles(elements, transformation, triangles) { // A mirroring transformation turns counterclockwise triangles clockwise. const [x, y, z] = [[1, 0, 0], [0, 1, 0], [0, 0, 1]].map((axis) => new modlumi.Vector3d(...axis).transform(transformation)); const mirrored = x.dot(y.cross(z)) < 0; for (const element of elements) { if (element.hidden) { continue; } if (element instanceof modlumi.Face) { for (const triangle of element.triangles) { const [a, b, c] = triangle.map((point) => point.transform(transformation)); triangles.push(mirrored ? [a, c, b] : [a, b, c]); } } else if (element instanceof modlumi.Group) { collectTriangles(element.entities, transformation.multiply(element.transformation), triangles); } else if (element instanceof modlumi.ComponentInstance) { collectTriangles(element.definition.entities, transformation.multiply(element.transformation), triangles); } }}
/** * Returns a triangle's unit normal by the right-hand rule, or zero for a degenerate one. * @param {Triangle} triangle */function getNormal([a, b, c]) { const normal = a.vectorTo(b).cross(a.vectorTo(c)); const length = Math.hypot(normal.x, normal.y, normal.z); return length > 0 ? normal.scale(1 / length) : normal;}
/** * Writes triangles as a binary STL file, in units of scale per meter. * @param {Triangle[]} triangles * @param {number} scale * @param {string} name */export function writeBinary(triangles, scale, name) { const bytes = new Uint8Array(kHeaderBytes + 4 + triangles.length * kFacetBytes); // The header is ASCII; other characters become question marks. const header = `Modlumi STL ${name}`.slice(0, kHeaderBytes); for (let index = 0; index < header.length; ++index) { const code = header.charCodeAt(index); bytes[index] = code < 128 ? code : 63; } const view = new DataView(bytes.buffer); view.setUint32(kHeaderBytes, triangles.length, true); triangles.forEach((triangle, facet) => { let at = kHeaderBytes + 4 + facet * kFacetBytes; const normal = getNormal(triangle); for (const value of [normal.x, normal.y, normal.z]) { view.setFloat32(at, value, true); at += 4; } for (const point of triangle) { for (const value of [point.x, point.y, point.z]) { view.setFloat32(at, value * scale, true); at += 4; } } }); return bytes;}
/** * Writes triangles as an ASCII STL file, in units of scale per meter. * @param {Triangle[]} triangles * @param {number} scale * @param {string} name */export function writeAscii(triangles, scale, name) { const lines = [`solid ${name}`]; for (const triangle of triangles) { const normal = getNormal(triangle); lines.push(`facet normal ${normal.x} ${normal.y} ${normal.z}`, " outer loop"); for (const point of triangle) { lines.push(` vertex ${point.x * scale} ${point.y * scale} ${point.z * scale}`); } lines.push(" endloop", "endfacet"); } lines.push(`endsolid ${name}`); return lines.join("\n") + "\n";}
/** * Returns a file's name without its extension. * @param {string} name */function getStem(name) { return name.replace(/\.[^.]*$/, "") || "Untitled";}
/** @param {modlumi.PluginContext} context */export function activate(context) { /** @returns {Options} */ const getOptions = () => ({ ...kDefaults, ...context.settings.get("options", {}) });
context.registerImporter({ id: "stl", name: "STL Model", extensions: ["stl"], import(file) { const options = getOptions(); const triangles = readStl(file.readBytes(), kMeters[options.importUnits] ?? 0.001); const mesh = new modlumi.MeshBuilder(); let skipped = 0; for (const triangle of triangles) { // Triangles with a zero side or area make no face. const [a, b, c] = triangle.map((point) => new modlumi.Point3d(...point)); if (a.vectorTo(b).cross(a.vectorTo(c)).dot(a.vectorTo(b).cross(a.vectorTo(c))) <= 1e-24) { skipped += 1; continue; } mesh.addPolygon([a, b, c]); } if (mesh.polygonCount === 0) { context.showMessage(`${file.name} holds no triangles to import.`); return; } const group = model.activeEntities.addGroup(); group.name = getStem(file.name); group.entities.addMesh(mesh); if (options.mergeFaces) { group.entities.mergeCoplanarFaces({ sameMaterials: true }); } if (!options.preserveOrigin) { const bounds = group.entities.bounds; if (bounds !== null) { group.transform(modlumi.Transformation.translation(bounds.min.vectorTo([0, 0, 0]))); } } context.showMessage(`Imported ${mesh.polygonCount} triangles from ${file.name}` + (skipped > 0 ? `, skipping ${skipped} degenerate ones` : "")); }, });
context.registerExporter({ id: "stl", name: "STL Model", extensions: ["stl"], export(file) { const options = getOptions(); const elements = options.selectionOnly && model.selection.length > 0 ? model.selection.toArray() : model.activeEntities; /** @type {Triangle[]} */ const triangles = []; collectTriangles(elements, model.activeTransformation, triangles); const unit = options.exportUnits === "Model Units" ? kModelUnits[model.units.lengthUnit] ?? "Millimeters" : options.exportUnits; const scale = 1 / kMeters[unit]; const name = getStem(file.name); if (options.format === "ASCII") { file.writeText(writeAscii(triangles, scale, name)); } else { file.writeBytes(writeBinary(triangles, scale, name)); } context.showMessage(`Exported ${triangles.length} triangles to ${file.name}, in ${unit.toLowerCase()}`); }, });
context.registerCommand({ id: "options", title: "STL Options...", tip: "Sets the units and other options STL import and export use.", run() { /** @type {Options} */ const options = getOptions(); context.showDialog({ id: "options", title: "STL Options", modal: true, draw(ui) { ui.heading("Import"); options.importUnits = /** @type {Unit} */ (ui.dropdown("Units", options.importUnits, kUnits, { tip: "STL files have no units; this says what theirs are" })); options.mergeFaces = ui.checkbox("Merge coplanar faces", options.mergeFaces, { tip: "Joins the triangles of flat surfaces into one face" }); options.preserveOrigin = ui.checkbox("Preserve drawing origin", options.preserveOrigin, { tip: "Otherwise moves the import so its bounds start at the origin" }); ui.heading("Export"); options.exportUnits = /** @type {Unit | "Model Units"} */ (ui.dropdown("Units", options.exportUnits, ["Model Units", ...kUnits])); options.format = /** @type {"Binary" | "ASCII"} */ (ui.dropdown("Format", options.format, ["Binary", "ASCII"])); options.selectionOnly = ui.checkbox("Export only the selection", options.selectionOnly); ui.spacing(); ui.row(() => { if (ui.button("OK", { primary: true })) { context.settings.set("options", options); ui.close(); } if (ui.button("Cancel")) { ui.close(); } }); }, }); }, });}True Bend
Section titled “True Bend”Bends a group or component along a curve, to a typed angle or a dragged amount, with a live preview, in smooth or visible segments.
Select a group or component and choose Extensions → True Bend → True Bend.
Click and move to set the bend, then click again, or type an angle such as 90.
Press Enter, double-click, or click Bend to apply it. Type a count such as
24s to set the number of segments, and choose in the panel whether they show as
facets or are softened.
Port of True Bend by Thomas Thomassen (MIT).
Source code
{ "id": "true-bend", "name": "True Bend", "version": "1.0.0", "description": "Bends a group or component to a typed angle or a dragged amount, along a true or segmented curve, with a live preview.", "author": "Modlumi, after True Bend by Thomas Thomassen", "license": "MIT", "apiVersion": 1}// Bends a group or component along its red axis, as if around a cylinder: drag to set the// amount, or type an angle in degrees, a bend distance, or a number of segments such as 24s.// The bend follows a true curve, or one segment per slice; a preview shows the result before// it is applied in one undo step.//// A port of True Bend (https://github.com/thomthom/true-bend)://// Copyright (c) 2016 Thomas Thomassen//// Permission is hereby granted, free of charge, to any person obtaining a copy of this software// and associated documentation files (the "Software"), to deal in the Software without// restriction, including without limitation the rights to use, copy, modify, merge, publish,// distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the// Software is furnished to do so, subject to the following conditions://// The above copyright notice and this permission notice shall be included in all copies or// substantial portions of the Software.//// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING// BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
/** * @typedef {modlumi.Group | modlumi.ComponentInstance} Container * @typedef {{ * instance: Container, * middle: modlumi.Point3d, * xAxis: modlumi.Vector3d, * normal: modlumi.Vector3d, * zAxis: modlumi.Vector3d, * length: number, * handle: modlumi.Point3d, * size: number, * edges: modlumi.Point3d[], * }} Setup * What a bend works from, in the coordinates of the context being edited: the reference line * through `middle` along the instance's red axis, `length` long; the `normal` a positive bend * curls toward; the handle on the instance's front; and the instance's edges, two points each. */
const model = modlumi.activeModel;const { Point3d, Vector3d } = modlumi;const kTau = Math.PI * 2;/** @type {modlumi.Color} */const kReferenceColor = { r: 0, g: 0.6, b: 0, a: 1 };/** @type {modlumi.Color} */const kHandleColor = { r: 0.9, g: 0, b: 0, a: 1 };/** @type {modlumi.Color} */const kPreviewColor = { r: 0.5, g: 0, b: 0, a: 1 };
/** @param {Container} container */function getContents(container) { return container.type === "Group" ? /** @type {modlumi.Group} */ (container).entities : /** @type {modlumi.ComponentInstance} */ (container).definition.entities;}
/** @param {modlumi.Entities} entities */function getContainers(entities) { return /** @type {Container[]} */ ([...entities.ofType("Group"), ...entities.ofType("ComponentInstance")]);}
/** * Adds the edges of a container's contents, and of the containers inside it, to points as pairs. * @param {Container} container * @param {modlumi.Transformation} transformation * @param {modlumi.Point3d[]} points */function collectEdges(container, transformation, points) { const entities = getContents(container); for (const edge of /** @type {modlumi.Edge[]} */ (entities.ofType("Edge"))) { if (!edge.hidden) { points.push(edge.start.position.transform(transformation), edge.end.position.transform(transformation)); } } for (const child of getContainers(entities)) { collectEdges(child, transformation.multiply(child.transformation), points); }}
/** * Prepares to bend a container, or returns null when it can't be bent. * @param {Container} instance * @returns {Setup | null} */function prepare(instance) { const bounds = getContents(instance).bounds; const transformation = instance.transformation; if (bounds === null) { return null; } const { min, max } = bounds; /** @param {number} value @param {number} low @param {number} high */ const clamp = (value, low, high) => Math.min(high, Math.max(low, value)); // The reference line runs along the red axis through the origin, or the nearest point of // the bounds when the origin lies outside them. const y = clamp(0, min.y, max.y); const z = clamp(0, min.z, max.z); const start = new Point3d(min.x, y, z).transform(transformation); const end = new Point3d(max.x, y, z).transform(transformation); const length = start.distance(end); if (length <= 1e-9) { return null; } const xAxis = start.vectorTo(end).normalize(); // The front of the instance faces down its green axis. const front = new Vector3d(0, -1, 0).transform(transformation); const across = front.subtract(xAxis.scale(front.dot(xAxis))); if (across.length <= 1e-9) { return null; } const normal = across.normalize(); const corners = [0, 1, 2, 3, 4, 5, 6, 7].map((corner) => new Point3d(corner & 1 ? max.x : min.x, corner & 2 ? max.y : min.y, corner & 4 ? max.z : min.z).transform(transformation)); /** @type {modlumi.Point3d[]} */ const edges = []; collectEdges(instance, transformation, edges); return { instance, middle: start.offset(xAxis.scale(length / 2)), xAxis, normal, zAxis: xAxis.cross(normal), length, handle: new Point3d((min.x + max.x) / 2, min.y, (min.z + max.z) / 2).transform(transformation), size: corners[0].distance(corners[7]), edges, };}
/** @param {modlumi.PluginContext} context */export function activate(context) { /** @type {Setup | null} */ let setup = null; /** The handle's offset along the normal, in meters; positive curls toward the front. */ let amount = 0; /** @type {{ start: modlumi.Point3d, base: number } | null} Where a drag started. */ let dragging = null; /** Whether the next release is the end of a double-click that applied the bend. */ let releaseApplied = false; let subdivisions = Number(context.settings.get("subdivisions", 24)); let segmented = Boolean(context.settings.get("segmented", true)); let softSmooth = Boolean(context.settings.get("softSmooth", true));
// The largest bend, a full circle, moves the handle by the circle's diameter. function getMaxAmount() { return setup === null ? 0 : (2 * setup.length) / kTau; }
function getAngle() { const max = getMaxAmount(); return max <= 0 ? 0 : (kTau * Math.min(Math.abs(amount), max)) / max; }
function isBending() { return getAngle() > 1e-9; }
/** * Where a point goes when the reference line bends by angle around a circle that keeps its * length, as one chord per subdivision when segmented. * @param {modlumi.Point3d} point */ function bendPoint(point) { const { middle, xAxis, normal, zAxis, length } = /** @type {Setup} */ (setup); const angle = getAngle(); const side = amount > 0 ? 1 : -1; const radius = length / angle; const offset = middle.vectorTo(point); const along = offset.dot(xAxis) / radius; let distance = radius - side * offset.dot(normal); if (segmented) { const step = angle / subdivisions; const index = Math.min(subdivisions - 1, Math.max(0, Math.floor((along + angle / 2) / step))); const chordMiddle = -angle / 2 + (index + 0.5) * step; distance = (distance * Math.cos(step / 2)) / Math.cos(along - chordMiddle); } const center = middle.offset(normal.scale(side * radius)); return center.offset(normal.scale(-side * distance * Math.cos(along)) .add(xAxis.scale(distance * Math.sin(along))) .add(zAxis.scale(offset.dot(zAxis)))); }
// Offsets of the slicing planes along the reference line, from its middle. function getPlaneOffsets() { const { length } = /** @type {Setup} */ (setup); return Array.from({ length: subdivisions - 1 }, (_, index) => -length / 2 + ((index + 1) * length) / subdivisions); }
/** * Splits the pairs of points at the slicing planes, so straight edges show the curve. * @param {modlumi.Point3d[]} pairs */ function slice(pairs) { const { middle, xAxis } = /** @type {Setup} */ (setup); const planes = getPlaneOffsets(); /** @type {modlumi.Point3d[]} */ const result = []; for (let index = 0; index + 1 < pairs.length; index += 2) { const a = pairs[index]; const b = pairs[index + 1]; const ua = middle.vectorTo(a).dot(xAxis); const ub = middle.vectorTo(b).dot(xAxis); const crossings = planes.filter((u) => (u - ua) * (u - ub) < 0) .map((u) => (u - ua) / (ub - ua)) .sort((s, t) => s - t); let previous = a; for (const t of crossings) { const point = a.offset(a.vectorTo(b).scale(t)); result.push(previous, point); previous = point; } result.push(previous, b); } return result; }
function showState() { tool.status = setup === null ? "Click a group or component to bend it." : dragging !== null ? "Move to adjust the bend, then click. Type an angle, a distance, or segments like 24s." : "Click and move to bend, or type an angle. Press Enter or double-click to apply."; tool.measurement = { label: "Angle", value: setup === null ? "" : `${((getAngle() * 180) / Math.PI).toFixed(1)}°`, }; tool.reference = dragging === null || setup === null ? null : setup.handle.offset(setup.normal.scale(amount)); }
/** @param {Container | null} instance */ function choose(instance) { setup = instance === null ? null : prepare(instance); amount = 0; dragging = null; showState(); }
function chooseFromSelection() { const selected = model.selection.toArray().find((entity) => entity.type === "Group" || entity.type === "ComponentInstance"); choose(/** @type {Container | undefined} */ (selected) ?? null); }
// Bends the instance in one undo step: slices it at the planes, then moves every vertex. function commit() { if (setup === null || !isBending()) { return; } const { instance, middle, xAxis, normal, zAxis, size } = setup; const planes = getPlaneOffsets(); try { model.operation("Bend", () => { instance.makeUnique(); /** @type {{ entities: modlumi.Entities, transformation: modlumi.Transformation }[]} */ const contexts = []; /** @param {Container} container @param {modlumi.Transformation} transformation */ const collect = (container, transformation) => { const entities = getContents(container); contexts.push({ entities, transformation }); for (const child of getContainers(entities)) { child.makeUnique(); collect(child, transformation.multiply(child.transformation)); } }; collect(instance, instance.transformation);
// Large squares across the instance, in a group beside it so they don't cut themselves. const cutter = model.activeEntities.addGroup(); const squares = planes.map((u) => { const center = middle.offset(xAxis.scale(u)); const a = normal.scale(size); const b = zAxis.scale(size); return cutter.entities.addFace([center.offset(a.add(b)), center.offset(a.subtract(b)), center.offset(a.negate().subtract(b)), center.offset(a.negate().add(b))]); }); for (const { entities, transformation } of contexts) { const toLocal = transformation.inverse(); const before = new Set(entities.ofType("Face").map((face) => face.entityId)); const cut = entities.intersectFaces(entities.ofType("Face"), squares.map((face) => ({ face, transformation: toLocal }))); const cutIds = new Set(cut.map((edge) => edge.entityId)); // Cut edges that close a loop across the inside fill it; such faces aren't wanted. for (const face of /** @type {modlumi.Face[]} */ (entities.ofType("Face"))) { if (!before.has(face.entityId) && face.loops.every((loop) => loop.edges.every((edge) => cutIds.has(edge.entityId)))) { entities.erase(face); } } if (!segmented || softSmooth) { for (const edge of cut) { if (edge.isValid) { edge.soft = true; edge.smooth = true; } } } // Loose edges cross no face, so they are split at the planes by hand, last crossing // first so the remaining part keeps starting where the edge did. for (const edge of /** @type {modlumi.Edge[]} */ (entities.ofType("Edge"))) { if (edge.faces.length > 0) { continue; } const ua = middle.vectorTo(edge.start.position.transform(transformation)).dot(xAxis); const ub = middle.vectorTo(edge.end.position.transform(transformation)).dot(xAxis); const crossings = planes.filter((u) => (u - ua) * (u - ub) < 0) .map((u) => (u - ua) / (ub - ua)) .sort((s, t) => t - s); let rest = edge; let length = 1; for (const t of crossings) { // The remaining part runs from the edge's start to the previous crossing. rest = entities.splitEdge(rest, t / length)[0]; length = t; } } } model.activeEntities.erase(cutter);
for (const { entities, transformation } of contexts) { const toLocal = transformation.inverse(); /** @type {Map<number, modlumi.Vertex>} */ const vertices = new Map(); for (const edge of /** @type {modlumi.Edge[]} */ (entities.ofType("Edge"))) { vertices.set(edge.start.entityId, edge.start); vertices.set(edge.end.entityId, edge.end); } const moving = [...vertices.values()]; const offsets = moving.map((vertex) => vertex.position.vectorTo(bendPoint(vertex.position.transform(transformation)).transform(toLocal))); if (moving.length > 0) { entities.moveVertices(moving, offsets); } } }); } catch (error) { context.showMessage(`Can't bend it: ${error instanceof Error ? error.message : error}`); return; } choose(instance.isValid ? instance : null); }
/** @param {string} text */ function applyValue(text) { const trimmed = text.trim(); const segments = /^(\d+)\s*s$/i.exec(trimmed); if (segments !== null) { subdivisions = Math.min(360, Math.max(2, Number(segments[1]))); context.settings.set("subdivisions", subdivisions); return true; } if (setup === null) { return false; } const side = amount < 0 ? -1 : 1; const degrees = /^(-?[\d.,]+)\s*(deg|°)?$/i.exec(trimmed); if (degrees !== null) { const angle = (Number(degrees[1].replace(",", ".")) * Math.PI) / 180; if (!Number.isFinite(angle) || angle <= 0 || angle > kTau) { return false; } amount = (side * getMaxAmount() * angle) / kTau; return true; } const distance = model.parseLength(trimmed); if (distance === null || distance <= 0) { return false; } amount = side * Math.min(distance, getMaxAmount()); return true; }
const tool = context.registerTool({ id: "bend", title: "True Bend", tip: "Bends a group or component to an angle, along a true or segmented curve.", activate: chooseFromSelection, deactivate() { setup = null; dragging = null; releaseApplied = false; }, onMouseMove(event) { if (setup !== null && dragging !== null && event.point !== null) { const moved = dragging.start.vectorTo(event.point.position).dot(setup.normal); amount = Math.max(-getMaxAmount(), Math.min(getMaxAmount(), dragging.base + moved)); showState(); } }, onMouseUp(event) { if (releaseApplied) { releaseApplied = false; return; } if (dragging !== null) { dragging = null; showState(); return; } // Before bending, a click chooses what to bend. const picked = event.picked?.path[model.activePath.length] ?? null; if (setup === null || (amount === 0 && picked !== null && picked.entityId !== setup.instance.entityId)) { model.selection.clear(); if (picked !== null) { model.selection.add(picked); } choose(picked); return; } if (event.point !== null) { dragging = { start: event.point.position, base: amount }; showState(); } }, onDoubleClick() { // The double-click's own release follows; it ends the gesture rather than starting another. releaseApplied = true; commit(); }, onKeyDown(event) { if (event.key === "enter") { commit(); } }, onValue(text) { if (!applyValue(text)) { context.showMessage(`Can't bend by "${text}"`); } showState(); }, onCancel(reason) { if (dragging !== null) { amount = dragging.base; dragging = null; } else if (reason === "escape") { amount = 0; } showState(); }, options(ui) { if (setup === null) { ui.text("Click a group or component to bend it.", { tone: "muted" }); return; } const chosen = ui.integer("Segments", subdivisions, { min: 2, max: 360 }); if (chosen !== subdivisions) { subdivisions = chosen; context.settings.set("subdivisions", subdivisions); } const chord = ui.checkbox("Segmented", segmented, { tip: "Bend along one straight segment per slice." }); if (chord !== segmented) { segmented = chord; context.settings.set("segmented", segmented); } const soften = ui.checkbox("Soften segment edges", segmented ? softSmooth : true, { disabled: !segmented }); if (segmented && soften !== softSmooth) { softSmooth = soften; context.settings.set("softSmooth", softSmooth); } const angle = getAngle(); ui.readOnly("Angle", `${((angle * 180) / Math.PI).toFixed(1)}°`); ui.readOnly("Radius", isBending() ? model.formatLength(setup.length / angle) : "Straight"); ui.row(() => { if (ui.button("Reset", { disabled: !isBending() })) { amount = 0; dragging = null; showState(); } if (ui.button("Bend", { primary: true, disabled: !isBending() })) { commit(); } }); }, draw(view) { if (setup === null) { return; } showState(); const { middle, xAxis, normal, length, handle, size } = setup; const reference = Array.from({ length: subdivisions + 1 }, (_, index) => middle.offset(xAxis.scale(-length / 2 + (index * length) / subdivisions))); const tip = handle.offset(normal.scale(amount)); view.lines([handle.offset(normal.scale(-size / 2)), handle.offset(normal.scale(size / 2))], { color: kHandleColor, stipple: "dashed" }); view.lines([handle, tip], { color: kHandleColor, width: 2 }); view.points([tip], { color: kHandleColor, width: 10 }); if (!isBending()) { view.polyline(reference, { color: kReferenceColor, width: 2 }); view.points(reference, { color: kReferenceColor, width: 5 }); return; } const bent = reference.map(bendPoint); view.polyline(bent, { color: kReferenceColor, width: 2 }); view.points(bent, { color: kReferenceColor, width: 5 }); view.lines(slice(setup.edges).map(bendPoint), { color: kPreviewColor, width: 1 }); }, });
// Undo, redo and edits elsewhere may change or remove what is being bent. const refresh = () => { if (setup !== null && dragging === null) { choose(setup.instance.isValid ? setup.instance : null); } }; context.onModelChanged(refresh); // The target and its frame belong to the context being edited, like the selection. context.onActiveContextChanged(chooseFromSelection); context.onDocumentOpened(() => choose(null));}