Appearance
MotorScript DSL
1. Identity
A MotorScript is a math.js-evaluated DSL whose curated geometry / constraint / transform builtins mutate a freshly-empty SceneModelV2 on each run, returning the new scene, constraint list, and parameter slider definitions to the editor store.
The executor lives at app/src/lib/fea2d/executor/index.ts and exposes executeMotorScript (sync) and executeMotorScriptAsync (yielding). The scene is rebuilt from emptySceneV2 every run; the previous scene is discarded by runScript in the store. Stable node identity across re-runs is preserved by seeding the executor with the previous scene's nodes (seedScene arg, executor/index.ts:116) — point calls whose coordinates match a seeded node reuse that node's id via claimedNodeIds (executor/index.ts:142).
2. When to use it
- Authoring a parametric machine cross-section that must regenerate on slider change.
- Round-tripping a canvas-drawn sketch into editable text and back (
codegen.ts↔reverse-codegen.ts). - Sharing a reproducible design as a single text artifact (saved as part of the project file).
- Driving programmatic patterning (
radial,linearArray,mirror) that would be tedious by hand.
3. Inputs
- Code panel:
app/src/components/fea2d/CodePanel.tsxMonaco editor; "Run" button orCmd+EntertriggersrunScript. - Canvas tool actions: every primitive tool (line, arc, circle, spline, fillet, rect, polygon, blockLabel) emits an appended script statement via
codegen.ts. - Node drag:
reverse-codegen.ts:reverseCodegenMoveNodepatches thepointcoords of the dragged node's binding statement. - Param slider (Parameters panel): edits the literal in the script's
param(...)call viareverseCodegenPatchParam(reverse-codegen.ts:672). - Command Palette: not yet wired for MotorScript-level actions; palette currently dispatches canvas tools only.
- Monaco completions:
app/src/lib/fea2d/motorscript-completions.tssupplies builtin signatures, snippets, andparamoverload hints.
4. State machine
The executor is a single-shot evaluator; the "state machine" is the run pipeline, not a tool flow.
- Normalize:
normaliseLineEndings(code)CRLF→LF, thenstripCommentsremoves%-comments and C-style//,/* */(executor/parser.ts). After step 1:cleanCodeis the source the rest of the pipeline indexes for line/column reporting. - AST pre-walks:
checkImplicitMultiplication(executor/index.ts:402) — rejects mathjs implicit-mult nodes .checkMatrixSizeLimit(executor/index.ts:496) — rejectszeros/ones/eye/identity(N)withN > 1000.
- Evaluate:
- Sync (
executeMotorScript): onemath.evaluate(cleanCode, scope)call. Chosen whenscript.length ≤ 1024bytes (SYNC_FAST_PATH_BYTESinstore/io.ts:67). - Async (
executeMotorScriptAsync):splitStatementsproduces top-level chunks (paren/bracket/brace/string-aware,executor/index.ts:575), evaluated one at a time. Yields a microtask everyYIELD_BUDGET_MS = 16ms or every 100 statements (executor/index.ts:840). Each iteration re-checks the entity cap so a runawaylinearArraystops within one burst.
- Sync (
- Post-evaluate guards:
restoreBuiltinScope— re-installs any builtin the user script shadowed (point = -1).rejectNonNumericScopeValues— throws if any user-assigned scope key resolved to aUnit, string, array, or plain object.
- Cap check:
countSceneEntities(scene) > MAX_SCRIPT_ENTITIES(5000) → friendly error performatCapMessage. - Finalize (
finaliseExecutorRun,executor/index.ts:236):- Pair
paramcallsites with their assignment target viafindParamCallsites+findAssignedNameBefore→ParamDef[]. - Walk the surviving scope for
{kind: 'point' | 'line' | 'arc' | 'spline' | 'circle' | 'label'}records →varNames[entityId] = scriptVarName(first-binding-wins). - Scrub
pendingSelectionagainst final entity ids.
- Pair
Cancel: the store's runScriptGen token (store/io.ts:619) is incremented on every run; an in-flight async result whose token no longer matches is dropped before it touches the scene.
5. Committed state
After a successful run the store replaces:
scene: SceneModelV2— freshgeometry(nodes, segments, arcs, circles, splines, blockLabels) built by the script.constraints: Constraint[]— collected in evaluation order, see geometric and dimensional.params: ParamDef[]— one perparamcall,{name, lineNumber, value, label, unit, min, max, step?}.varNames: Record<entityId, scriptVarName>— used byreverse-codegen.tsto find the textual binding when a node is dragged.pendingSelection: Selection[]— populated byselect(...)builtin .errors: ExecutorError[]— empty on success; mirrored to Monaco markers atCodePanel.tsx:686-705.
6. Builtin catalogue
| Group | Names | Source |
|---|---|---|
| Geometry | point, line, arc, arcCtr, circle, spline, fillet, rect, polygon, blockLabel | executor/builtins/primitives.ts |
| Geometric constraints | horizontal, vertical, parallel, perpendicular, tangent, coincident, equal, midpoint, symmetric, onLine, onCircle, fixed | executor/builtins/constraints.ts — see geometric.md |
| Dimensional constraints | distance, distanceH, distanceV, angle, radius, diameter | same — see dimensional.md |
| Transforms | radial, mirror, linearArray | executor/builtins/transforms.ts |
| FEA | material, boundary, blockType, blockLabel | executor/builtins/primitives.ts |
| Selection | select | executor/builtins/primitives.ts |
| Math | pi, PI, deg2rad (= π/180), rad2deg (= 180/π) | scope literals (executor/index.ts:222-225) |
| Parameters | param(value, label?, unit?, min?, max?, step?) | executor/index.ts:160; step added in |
Per-primitive specs live under docs/spec/primitives/. Refer to them for arg shapes, snap behavior, and constraint compatibility.
param semantics
param returns the value argument at call time so the script evaluates with the slider's default. Names are recovered AFTER math.evaluate returns, by scanning cleanCode for the textual param( callsite and walking left to the nearest name = (findAssignedNameBefore, executor/parser.ts). Order-of-callsites must match order-of-paramCalls.push; statements that throw mid-script can desynchronize this pairing (callsite count > pushed count). Throws inside param itself: non-finite default, max < min, default out of range, non-positive step, non-string label/unit (catches the EU param(1,5,"Width") decimal-comma pitfall, executor/index.ts:188-200).
vars.X claim — REMOVED
removed the vars.<name> namespace; no such builtin exists in scope. Only param is user-facing for runtime knobs. Any pre- script referencing vars.foo will throw "Undefined symbol vars" at evaluate.
7. Codegen ↔ canvas round-trip
Forward (canvas gesture → script append): app/src/lib/fea2d/codegen.ts emits a single statement appended to the script buffer per tool finalize — e.g. codegenLine, codegenArc, codegenCircle, codegenSpline, codegenBlockLabel. The new statement uses fresh variable names (p1, p2, ...) generated against the current script's name set.
Backward (node drag → script patch): reverse-codegen.ts:reverseCodegenMoveNode (line 80) finds the point call bound to the dragged node via varNames and rewrites its two numeric literals in place. Segment / arc / circle / blockLabel patches follow the same pattern at lines 258, 299, 336, 384.
Gaps:
- Spline control-point drag is NOT codegenned.
spline.ts:564-594post-processes the executor result to graft CP nodes onto the spline, butreverse-codegen.tshas noreverseCodegenPatchSplineCP— dragging an interior spline CP mutates the runtime scene only, and the nextrunScriptre-evaluation discards the move. Tracked (originally tracked: spline preview vs commit divergence and phantom CP nodes). filletis side-effect-only. The emitted statement modifies the two referenced segments by inserting an arc; there is no return value the script can reuse, and dragging the fillet arc has no reverse-codegen counterpart.- Constraint creation has no per-constraint reverse-codegen beyond value patches; deleting a constraint from the palette appends nothing — the script must already lack it.
8. Error reporting
Errors land in result.errors: ExecutorError[]. Line/column resolution is makeLineMapper (executor/index.ts:337):
err.charnumeric field → exact line + start column + identifier-tail end column.(char N)substring in the message → same path.line Nsubstring → line only, no columns.funcName(heuristic → find the offending call incleanCode, underline the name.- ** fallback**:
lastExecutedStatementLine(async path only, tracked atexecutor/index.ts:816) anchors the marker on the statement that actually threw instead of returningline: -1. CodePanel was previously clamping-1to line 1, which misled users into thinking every error was at the top of the file. - Otherwise
{ line: -1 }.
The consumer at CodePanel.tsx:686-705 maps each error into a Monaco IMarkerData with severity: MarkerSeverity.Error, and clears the previous marker set on every run. The implicit-multiplication and matrix-cap pre-walks short-circuit before math.evaluate and produce errors at line: 1 / line: -1 respectively.
9. Param sliders
The Parameters panel renders one slider per ParamDef. Slider drag invokes reverseCodegenPatchParam(script, name, newValue) (reverse-codegen.ts:672) which rewrites the numeric value argument in the corresponding param(...) call, then runs the script again. step is optional; when present it must be a positive finite number (executor/index.ts:183-185).
Cross-ref parameters-and-smart-dim.md for the slider UI rules and smart-dimension promotion.
10. Safety / failure modes
| Failure | Detection | User-visible result |
|---|---|---|
| Parse error (unbalanced paren, bad token) | math.evaluate throw | Marker on the line mathjs reports; scene unchanged. |
Implicit multiplication (2x, (a)(b)) | AST pre-walk, | Error at line 1: "Implicit multiplication is not supported …". |
Matrix bomb (zeros(10000,10000)) | AST pre-walk, | Error before any allocation: "matrix size limit exceeded". |
Non-numeric scope write (w = "50" or w = 50 mm) | rejectNonNumericScopeValues post-evaluate | Error "MotorScript variables must be plain numbers" or units-specific hint. |
Builtin shadowed (point = 1) | restoreBuiltinScope snapshot diff | Silently restored; subsequent script lines see the real builtin again. |
MAX_SCRIPT_ENTITIES = 5000 exceeded | Post-evaluate count + async per-burst check | Error: "Script would create N entities; limit is 5000. Reduce slot count or split into multiple scripts." |
linearArray(..., count) with count > cap | Fast-fail in transform builtin BEFORE allocation | Same friendly message, but stops before any node is minted. |
| Async run superseded | runScriptGen token mismatch in store/io.ts:619 | The stale result is dropped on resolve; the latest run wins. |
param callsite count desync (script throws mid-list) | Math.min(callsites, paramCalls) in finaliseExecutorRun | Some sliders may be missing; surviving ones are still named. |
11. Example
motorscript
% three free points + a horizontal segment between two of them
p1 = point(0, 0)
p2 = point(50, 0)
p3 = point(25, 20)
s12 = line(p1, p2)
horizontal(s12)After run: 3 nodes, 1 segment, 1 horizontal constraint. varNames = { <p1.id>: 'p1', <p2.id>: 'p2', <p3.id>: 'p3', <s12.id>: 's12' }. Dragging the node bound to p2 rewrites point(50, 0) to the new coordinates; the horizontal constraint then pulls p1.y to match on the next solve.
12. Figures
Figure 1 (codegen path): a canvas-drawn segment between p1 and p2 produces appended script statements p1 = point(0,0), p2 = point(10,0), line(p1, p2) via codegen.ts.
Figure 2 (reverse-codegen): dragging p2 on canvas patches the point literals in the Monaco panel via reverseCodegenMoveNode; the gap for spline control-point drags is flagged in red — they mutate the runtime scene only and are discarded on next runScript.
13. Known bugs
Per-constraint and per-primitive bugs are listed on their own spec pages.
10. Class API
In the current model, scene is a Scene instance (app/src/lib/fea2d/model/Scene.ts) rather than a plain SceneModelV2 object. The legacy snapshot-shape getters on the class (scene.geometry.nodes, scene.geometry.segments, …) are preserved as compatibility surfaces, so existing consumer code — including the script-emit path described above — keeps reading the same shape. The script's builtin output collection on the executor context (scene: SceneModelV2) is unchanged; the store's wrap on commit promotes the plain shape into a Scene instance via Scene.fromJSON(snap).
The executor itself still produces plain primitive snapshots through ctx.scene.geometry.nodes.push(...) etc.; may move builtin emit onto class constructors. Until then the script-side flow is unchanged.