@mulmoclaude/shapescript-plugin
v7.1.0
Published
presentShapeScript — interactive 3D visualizations authored in the ShapeScript language (parser + Three.js renderer). Core (tool definition + execute) on `.`, Vue View/Preview on `./vue`.
Readme
@mulmoclaude/shapescript-plugin
presentShapeScript — interactive 3D visualizations authored in the ShapeScript language.
The plugin ships its own ShapeScript parser / evaluator and a Three.js renderer (CSG via
three-bvh-csg), so a model is described as text and rendered in the chat canvas.
Ported from @gui-chat-plugin/present3d
(MIT, same authors). The tool is named presentShapeScript here — the upstream present3D
name, and the Present3D* type names, are renamed throughout.
Exports
| Entry | Contents |
| ------------- | ------------------------------------------------------------------------------------------------------------------------ |
| . | TOOL_NAME, TOOL_DEFINITION, executePresentShapeScript, pluginCore, samples, parseShapeScript, astToThreeJS, executeShapeScriptDispatch + the artifacts/shapes path rules |
| . (export) | shapeScriptToUsdz, sceneToUsdz, USDZ_MIME_TYPE, USDZ_EXTENSION, and the exportShapeScriptUsdz tool (executeExportShapeScriptUsdz, EXPORT_USDZ_*); shapeScriptToGlb / sceneToGlb / GLB_* and shapeScriptToStl / sceneToStl / STL_* alongside. Browser-safe: none needs a canvas, so the View's download buttons and a host's MCP tool run the same code. |
| ./render | server-only — renderShapeScriptSheet and the render page. Rasterises a model to a PNG with Puppeteer's headless Chromium (an OPTIONAL peer); a host without one gets RenderUnavailableError carrying the install hint. |
| ./vue | the ToolPlugin (View + Preview + SYSTEM_PROMPT), plus everything on . |
| ./style.css | the compiled component styles (Vite lib mode does not auto-inject them) |
import type { ToolContext } from "gui-chat-protocol";
import { executePresentShapeScript } from "@mulmoclaude/shapescript-plugin";
// The handler does not read the context; the host passes its own.
const context = {} as ToolContext;
const result = await executePresentShapeScript(context, {
title: "Circular Pattern",
script: `
define count 12
for i in 1 to count {
define angle ((i / count) * 6.283)
cube {
position (cos(angle) * 3) 0 (sin(angle) * 3)
color (i / count) 0.5 (1 - i / count)
size 0.5
}
}`,
});USDZ export
exportShapeScriptUsdz writes a model out as a USDZ archive (AR Quick Look on Apple devices, or
any USD viewer). A host wires it against the same { files: { artifacts, byPath? } } context shape
executeShapeScriptDispatch takes — a full FileOps, or just read / write / exists
(ShapeFileOps) — no browser, no node:*:
import { executeExportShapeScriptUsdz, EXPORT_USDZ_TOOL_NAME, EXPORT_USDZ_DESCRIPTION, EXPORT_USDZ_SCHEMA, EXPORT_USDZ_PROMPT } from "@mulmoclaude/shapescript-plugin";
// register { name: EXPORT_USDZ_TOOL_NAME, description: EXPORT_USDZ_DESCRIPTION, inputSchema: EXPORT_USDZ_SCHEMA }
const { message, filePath } = await executeExportShapeScriptUsdz({ files: shapeFiles }, args);The file lands at artifacts/shapes/<slug>-<epoch-ms>-<token>.usdz. The View's Download
menu (USDZ item) builds the same archive in the browser with shapeScriptToUsdz and saves it locally.
USDZ units are metres, so size 1 is one metre in AR.
The same menu also offers GLB (binary glTF, for the web and game engines; vertex colours
survive as COLOR_0) and STL (binary, geometry only, in world space, for slicers),
built the same way by shapeScriptToGlb and shapeScriptToStl. Neither has an MCP tool yet.
The gallery: manageShapeScript
manageShapeScript is the user's models in the public gallery on mulmoserver
(server.mulmocast.com/shapes), one tool with an action — as manageCollection is:
| action | Does | Needs |
| --------- | ------------------------------------------------------------------------------------------------------ | ---------------------------------- |
| publish | Posts a new model and answers its URL. | title, script or path; acceptLicense: true unless a draft |
| update | Changes the user's own post in place, sending only the fields given. | id |
| delete | Removes the user's own post and every object under it. | id |
| get | One post's readable fields and its ShapeScript source — anyone's published one, or the user's draft. | id; save: true writes a .shape |
| getList | The user's own posts, drafts included, newest first. | limit (default 20, at most 100) |
The tool's contract — schema, description, the document a post is (SHAPE_POST_KEYS, which
mulmoserver's rules pin with hasOnly), the keyword normalisation — is the package's; Firebase is
not. A host supplies a ShapeGalleryWriter over its own signed-in session (the remote-host
session, which is the user's account on mulmoserver's Firebase) and a renderThumbnail —
renderShapeThumbnail from ./render, which answers null where no headless browser is installed,
so a post still lands, without a picture:
import { executeManageShapeScript, MANAGE_TOOL_NAME, MANAGE_DESCRIPTION, MANAGE_SCHEMA, MANAGE_PROMPT } from "@mulmoclaude/shapescript-plugin";
import { renderShapeThumbnail } from "@mulmoclaude/shapescript-plugin/render";
// gallery: { uid, authorName,
// createPost(id, doc), readPost(id), updatePost(id, patch, expect), deletePost(id, expect),
// listPosts(uid, limit), readScript(ownerUid, id, scriptId),
// uploadThumbnail(id, png), uploadScript(id, script), deleteObject(id, objectId) }
// — every member required; null when not signed in
const result = await executeManageShapeScript({ files: shapeFiles, gallery, renderThumbnail: renderShapeThumbnail }, args);
result.message; // the sentence (publish / update / delete) or the JSON (get / getList) the agent readscreatePost must add createdAt / updatedAt as serverTimestamp(); the rules refuse a client
clock. deleteObject is what takes an uploaded thumbnail back out when createPost is refused, so
no object is left that nothing references. With gallery: null the tool throws
NOT_CONNECTED_MESSAGE, which tells the user to connect Remote Host.
Publishing a post licenses it under CC BY 4.0 (SHAPE_LICENSE, SHAPE_LICENSE_URL), as the
gallery's own editor asks before publishing. The tool asks the same way: publishing a public post,
making a draft public, or editing a public post that has no license yet needs acceptLicense: true
— the user's explicit agreement, which MANAGE_PROMPT tells the model to ask for and never to pass
on its own — and is refused with LICENSE_REQUIRED_MESSAGE, before any upload, without it. A draft
(published: false) needs none and records none. The document carries license ("CC-BY-4.0" or
null, a pinned key), and the host stamps licenseAcceptedAt as serverTimestamp() beside a
grant — in createPost when doc.license is set, in updatePost when the patch carries license
(the owner's first agreement; the plugin never sends it for a post already licensed). The rules let
a grant be made once and never moved or removed, so updatePost must drop both keys if the stored
document turns out to be licensed already; the mulmoclaude adapter does that inside its transaction.
get / getList answer license and licenseAcceptedAt (an ISO string, "" when none). A
public post with license: null predates the gallery's asking and carries no grant; the prompt
tells the model not to present such a model as reusable.
This is a breaking change of the ShapeGalleryWriter contract (5.x → 6.0.0): a host built
against 5.x does not stamp licenseAcceptedAt, so with this plugin its createPost / updatePost
would send license without the stamp and the rules would refuse every public write. A host
takes 6.x only once its adapter stamps the grant.
update rewrites the user's own post id in place: readPost fetches it, the tool refuses it
unless its uid is the writer's (only the publisher may change a post; the gallery's rules say the
same, but without a reason), and updatePost merges a PATCH — only the fields the caller gave,
plus new object ids — with a server updatedAt and no createdAt, which the rules freeze. It must
be a field-level update (Firestore updateDoc), never a whole-document write, so a field not given
keeps what the document holds now rather than what the read saw; and it must be CONDITIONAL on
expect (owner, object ids and published state as read), refusing with POST_CHANGED_MESSAGE when the post changed
meanwhile — a runTransaction that re-reads, compares and updates — so two racing edits cannot
orphan each other's objects. A field given replaces the post's (an explicit "" clears
description / prompt / aiModel), one omitted keeps it. A new script / path uploads a new
script object and thumbnail and removes the replaced ones once the document points at the new ids.
delete is the same owner check, then deletePost — the document first, so the post is gone at
once, and conditional on expect exactly as updatePost is (a transaction that re-reads, compares
and deletes), so an update that landed meanwhile keeps its post and its new objects. It answers the
document as deleted, and that version's objects are what deleteObject then removes — the script,
the thumbnail and any reference photos, a photo the web editor swapped in meanwhile included, since
expect pins the model and not the photos; an object that will not go is a warning, since nothing
links to it.
get and getList are reads: readPost / listPosts answer documents as stored (the server
stamps may stay whatever the SDK returns — a Date, or anything with toDate() — the tool turns
them into ISO strings), and readScript downloads the source from under the post's OWNER, which
need not be the session user: the Storage rule opens the objects to anyone. readPost must answer
null, not throw, for a document the rules hide (another account's draft), as it does for a wrong
id. listPosts is the gallery's own "My models" query — uid == me, createdAt descending — which
the rules admit and the composite index serves.
ShapeScript language
- Primitives:
cube,sphere,icosphere,cylinder,cone,torus,circle,square,roundrect,polygon - Properties:
position X Y Z,orientation ROLL YAW PITCH(aliasrotation),size X Y Z,detail,smoothing,name - Materials (as properties or scoped commands):
color,opacity,metallicity,roughness,glow,material NAME;textureis accepted with a warning - CSG:
union,difference,intersection,xor,stencil - Builders:
extrude,loft,lathe,fill,hull - Variables & expressions:
define, arithmetic / comparison / boolean operators,in, ranges, parentheses, custom functions - Control flow:
for … in …,if/else,switch/case - Built-ins:
round floor ceil abs sign sqrt pow min max,sin cos tan asin acos atan atan2(radians),dot cross length normalize sum,rgb hsb,join split trim,rnd - Commands:
detail N,seed N,smoothing N,background,print,assert, and the relativerotate/translate/scale;camera/lightblocks are skipped with a warning
A call is either C-like, with no space before its parenthesis (sin(x); sin (x) is not a
call), or bare, as upstream: max 0 (j - 1), sqrt 9, sin pi / 2 — the function takes every
value after it, so parenthesise it inside a larger expression: (sqrt 9) + (sqrt 16). Arguments are
a value list; commas (max(0, j - 1)) also work here but not in the upstream app. One statement per
line, as upstream: define a 1 define b 2 on one line is a parse error naming the rule.
Storage
A new script is saved to artifacts/shapes/<slug>-<epoch-ms>.shape and the
result names it as data.filePath; pass path instead of script to present a
source that already exists — a .shape this tool wrote, or any other on disk —
and it is rendered in place rather than copied. The two are mutually exclusive.
The View's source editor writes edits back to that same file (dispatch kinds
loadShape / saveShape), and refreshes from disk on open, so a model the agent
rewrote is what the user sees.
All file access goes through the host's generic gui-chat-protocol capability:
files.artifacts for artifacts/shapes/**, files.byPath for anything else.
A host that supplies neither keeps the pre-1.1 behaviour — the script travels
inside the tool result and nothing is written — rather than failing.
Validation and errors
executePresentShapeScript checks arguments, parses the source, evaluates expressions,
and builds geometry headlessly before returning success. Temporary geometry is disposed.
Failures are returned as values, with no data field, so the host does not open a
broken visualization. The same diagnostic is available as error and jsonData.error
(the latter is included in the calling agent's tool response):
const result = await executePresentShapeScript(context, {
title: "Example", script: "cube { size missing }",
});
if ("error" in result) {
console.log(result.error.code, result.error.message);
// EVALUATION_ERROR, "Undefined variable: missing"
}Codes: INVALID_ARGUMENT, PARSE_ERROR, EVALUATION_ERROR, LIMIT_EXCEEDED.
Parse diagnostics include line and column when available. Invalid input does not
throw from the tool handler. Successful results retain their existing { title, data: { script } }
contract. Validation executes the script, including geometry construction; the browser
constructs it again for display. The source editor also validates geometry before saving.
Builders and additional expressions
// Loft joins sections and caps the ends; hull forms a convex envelope.
loft {
square
translate 0 0 2
circle
}
hull {
cube { position -1 0 0 }
cube { position 1 0 0 }
}
extrude { polygon { sides 5 } }
fill { square }
lathe path {
point 0 0
point 1 0
curve 1.5 1
point 1 2
point 0 2
}
// Paths: absolute points, Bézier control points, a frame moved by rotate/translate/scale.
extrude path {
point 0 0
point 1 0
point 1 1
point 0 1
point 0 0
}
fill path {
for 0 to 8 {
curve 0 1
rotate 1 / 8
}
}
// Stencil changes surface material without cutting away the first shape.
stencil {
cube { color 1 0 0 }
cube {
position 0.5 0 0
color 0 1 0
}
}
define offsets ((1 2 3), (4 5 6))
cube { position offsets[0].x offsets[1].y offsets.count }
// Materials, as upstream: hex and named colours, alpha, PBR properties, bundles.
define brass material {
color #d4a017
metallicity 1
roughness 0.3
}
material brass
sphere
cube {
position 2
color red 0.5 // red at 50% alpha
glow orange * 0.3 // emissive
}
// Ranges, the `in` operator, custom functions and bare calls.
define steps 0 to 1 step 0.25
define ease(t) { t * t * (3 - 2 * t) }
for t in steps {
if t in 0.25 to 0.75 {
cube {
position (t * 4) (ease t) 0
size max 0.1 (t / 2)
}
}
}
// A rounded slab from two arcs, extruded; a bare path draws as a line.
extrude path {
arc { angle -0.5 }
point -0.5 0
point 1.5 0
arc {
position 1 0
orientation 0.5
angle -0.5
}
curve 0 0.5
}Also supported: pi (no tau, as upstream; write 2 * pi), true, false, scientific notation, unary +,
short-circuit and/or, string literals, join/split/trim, tuple arguments to min/max,
zero-based tuple/string subscripts (negative from the end, or by name: v["y"]), .count, ordinal
members .first … .tenth, .last, .allButFirst, .allButLast, vector .x/.y/.z/.w, size
.width/.height/.depth, rotation .roll/.yaw/.pitch, color .red/.green/.blue/.alpha (or
.r/.g/.b/.a) and .hue/.saturation/.brightness, custom shape definitions with options (placed
and coloured through position / orientation / size / color / material on the call), and
polygon { sides N } (integer 3–256). Lathe samples curved profiles (drawn on either side of the
axis), and inline builder paths use the same parser as nested paths, including loops and
definitions. A material command inside a builder block applies to the builder's result, and
size on a builder or group scales it (an extrude's Z is its depth). print lines and the
commands that were skipped come back on the root group's userData (sceneInfoOf(group)) and in
the tool result message.
Units and path semantics — same as upstream
Since 2.0.0 the plugin follows the upstream ShapeScript conventions, so a script written against the upstream docs renders the same here:
sizeis the diameter ofsphere,cylinder,cone,circle,polygonandtorus(a barespherefits the unit cube) and the edge length ofcube/square.orientation(aliasrotation) androtatetake half-turns asroll yaw pitch— rotations about Z, Y and X applied in that order,0.5= 90°, positive clockwise (Euclid's sign). A lone value is a roll; four values areangle x y z. Trig functions still use radians.- Path
point/curvecoordinates are absolute in the path's frame;rotate,translateandscaleinside a path move that frame for later points.curveis a quadratic Bézier control point — the outline passes through the neighbouringpoints, and twocurves in a row get an implicit on-curve midpoint (eight in an octagon draw a circle). A path block may carryposition/orientation/sizeof its own, which is how aloftsection is placed in 3D;latherefuses a placed profile. rnduses upstream's generator (x = x · 1664525 + 1013904223 mod 2³², seed 0) andseed Nreseeds it for the enclosing block only.- Scope (2.1.0): a shape block,
group, builder or custom block resets transforms and materials at its closing brace;for/if/switchbodies scope only symbols, so atranslateinside a loop carries on after it, as upstream's scope rules say. - A bare
pathdraws as a line (2.1.0), as upstream;fill/extrude/lathe/loftmake a surface or solid of it. size 1 2pads to1 2 1(Euclid'sVector(size:)), socylinder { size 1 2 }is a cylinder of diameter 1 and height 2.extrudeis centred on its profile plane, spanning ±depth/2 (2.1.0; it ran 0…depth before), and a lathe always faces outward whichever way its profile is drawn.
Deviations that remain: an open path whose first or last point is a curve treats it as a
corner (upstream extrapolates a tangent), path points are 2D, nested sub-paths (holes) are not
supported, and smoothing is flat (0) or smooth rather than an angle threshold. The gap list
lives in plans/feat-shapescript-upstream-parity.md.
Compatibility and limits
This is the plugin's documented modeling subset, not complete compatibility with upstream ShapeScript.
Loft accepts ordered, closed planar sections with one perimeter each, resamples differing
vertex counts, interpolates linearly, and triangulates the end caps. Sections must enclose
a volume. Hull accepts geometry from child meshes and filled paths. Primitive profiles
for fill/extrude must lie in XY; holes, twisted extrusion (twist) and arbitrary 3D path
commands are not implemented. extrude … along sweeps a section along a path (mitred at
corners, capped at the ends of an open path); an open path extrudes to a two-sided wall, as
upstream. minkowski { a b } is the hull of two convex solids' vertex sums, and for a
non-convex operand the merged per-face hulls (overlapping shells, not a boolean union — fine to
draw, not to feed to another boolean); inset(mesh d) slides every vertex to where its faces'
offset planes meet. text "Hello" lays glyph outlines out as upstream does — left margin at
x = 0, first baseline at y = 0, one world unit per line, size scaling it, wrapwidth and
linespacing as options, values interpolated (text "Bob has " apples " apples") — in a bundled
Helvetica-like face (Helvetiker; font is accepted and skipped with a warning, and a character
the face lacks draws as ?). Bare text draws outlines; fill and extrude turn it into faces
and solids, holes included, and it is a value with .bounds. import, svgpath, object values
and paths as values are refused with a message naming the feature. Textures,
normal maps, camera and light blocks are accepted and skipped with a warning that the
tool result and the View both report. Unsupported commands and failed CSG operations return
errors instead of silently substituting different geometry. As with other polygonal CSG
engines, degenerate or self-intersecting inputs may fail.
The upstream project's own example scripts are test fixtures
(test/fixtures/upstream-examples/, MIT): all nine — Ball, Chessboard, Cog, Dodecahedron, Earth,
Fillet, Spirals, Spring and Train — render.
Shapes as values, meshes and polygons
Since 2.2.0 a shape is a value, as upstream: define ico icosphere { detail 0 } keeps its mesh, ico
places it (with position / orientation / size on the call), and its members are readable —
polygons and triangles (each with .center, .points, .bounds), bounds (.min .max
.center .size .width .height .depth) and volume. The icosphere follows Euclid's
construction face for face, so scripts that index its faces (upstream's Dodecahedron) get the same
faces. for v in … { expr } and if c { a } else { b } are expressions, a function may build
shapes (define face(data) { polygon { … } }) and be called bare as a statement (face data), and
mesh { … } assembles the polygons its body produces — polygon { color red … } with one point per line
with 3D points, a tuple per point allowed — into one flat-shaded, vertex-coloured mesh. A value a
statement produces that is not a shape is an "unused value" error, as upstream. Paths as values and
object values remain unsupported.
rnd and rand() draw from a seeded generator (randomSeed, default
DEFAULT_RANDOM_SEED = 0, overridable in-script with seed) rather than Math.random():
one script is evaluated twice — once on the server, which validates it, and again in the
browser, which renders it — and an unseeded generator lets those two runs take different
branches.
Conversion limits cover nodes (100,000), loop/path work (100,000 iterations), detail (3–256),
aggregate vertices (5,000,000, including CSG intermediates), and a coarse 30-second wall-clock
budget checked between nodes — it refuses to start the next node once the budget is spent, but
cannot interrupt one long boolean. Every one is overridable through ConversionOptions.
Measured, those ceilings bind in different places: a grid of 22,500 cubes is ~540k vertices and
converts in under 200 ms, 930 spheres at detail 64 reach the vertex budget, and the wall clock
is in practice the CSG budget alone — 240k vertices of plain geometry take ~95 ms, while 100
boolean subtractions take 5.3 s. Remember the script is built TWICE, once on the server to
validate it and once in the browser to draw it, and that the browser's copy is what the user's
tab has to keep rendering.
Scripts
yarn build # vite build + d.ts emit
yarn typecheck # vue-tsc --noEmit
yarn lint
yarn test # node:test — tool execute + parser + Three.js conversion