forma-dsl-mcp 0.3.7 → 0.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +18 -18
  2. package/package.json +3 -3
  3. package/src/bin.js +1 -1
  4. package/src/catalogue.js +15 -15
  5. package/src/document.js +8 -38
  6. package/src/examples.js +1 -1
  7. package/src/guide.js +24 -24
  8. package/src/reference/README.md +15 -5
  9. package/src/reference/api/describe-parameters.md +4 -4
  10. package/src/reference/api/evaluation-context.md +13 -13
  11. package/src/reference/api/export.md +7 -7
  12. package/src/reference/api/geometry-node.md +9 -9
  13. package/src/reference/api/kernel.md +8 -8
  14. package/src/reference/api/low-level.md +9 -9
  15. package/src/reference/api/render.md +8 -8
  16. package/src/reference/api/values.md +12 -12
  17. package/src/reference/cli.md +146 -0
  18. package/src/reference/errors.md +14 -14
  19. package/src/reference/language/control-flow.md +3 -3
  20. package/src/reference/language/expressions.md +10 -10
  21. package/src/reference/language/overview.md +11 -11
  22. package/src/reference/language/scope.md +13 -13
  23. package/src/reference/language/syntax.md +4 -4
  24. package/src/reference/reference/align.md +9 -9
  25. package/src/reference/reference/booleans.md +5 -5
  26. package/src/reference/reference/component.md +12 -12
  27. package/src/reference/reference/conversions.md +8 -8
  28. package/src/reference/reference/functions.md +8 -8
  29. package/src/reference/reference/local.md +4 -4
  30. package/src/reference/reference/model.md +10 -10
  31. package/src/reference/reference/param.md +8 -8
  32. package/src/reference/reference/part.md +12 -12
  33. package/src/reference/reference/refinement.md +10 -10
  34. package/src/reference/reference/shapes-2d.md +15 -15
  35. package/src/reference/reference/shapes-3d.md +13 -13
  36. package/src/reference/reference/transforms.md +10 -10
  37. package/src/reference.js +4 -3
  38. package/src/roots.js +2 -2
  39. package/src/server.js +2 -2
  40. package/src/tools.js +15 -15
  41. package/src/workspace.js +7 -7
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![M8ven Verified](https://m8ven.ai/api/agent-verify/badge?score=100)](https://m8ven.ai/verified/verify?id=d3529b4756004447)
6
6
 
7
7
  A [Model Context Protocol](https://modelcontextprotocol.io) server that knows
8
- [forma](https://www.npmjs.com/package/forma-dsl) — the declarative DSL for 3D solids — and
8
+ [forma](https://www.npmjs.com/package/forma-dsl) - the declarative DSL for 3D solids - and
9
9
  builds it with the real geometry kernel, so a `.forma` document is measured before anyone is
10
10
  told it works.
11
11
 
@@ -57,7 +57,7 @@ The server does not choose. It asks the client over MCP's
57
57
  writes only inside the directories the client hands over. A relative path is taken against
58
58
  the first of them, and every write reports the directory it landed in.
59
59
 
60
- **Every path is confined to those directories** — an absolute path outside them, a `..` that
60
+ **Every path is confined to those directories** - an absolute path outside them, a `..` that
61
61
  climbs out, or a symlink pointing out of the tree is refused rather than followed. When the
62
62
  client says its roots have changed, the server re-reads them before the next call.
63
63
 
@@ -76,7 +76,7 @@ cwd = "/absolute/path/to/your/models"
76
76
  A model that has read the language's documentation still writes forma that does not build.
77
77
  The difference this server makes is that it **runs the geometry kernel**. Every tool compiles
78
78
  the document, solves it with [Manifold](https://github.com/elalish/manifold) and reports back
79
- the bounding box, the volume, the triangle count and the genus of every part — so the
79
+ the bounding box, the volume, the triangle count and the genus of every part - so the
80
80
  question "is this the size that was asked for, and is it actually a solid?" is answered
81
81
  before the work is handed over. A part that silently solved to nothing is called out by name,
82
82
  and an error comes back with the line, the column and an excerpt with a caret on it.
@@ -84,7 +84,7 @@ and an error comes back with the line, the column and an excerpt with a caret on
84
84
  `forma_write` **refuses to write a document that does not render**.
85
85
 
86
86
  Refusals carry the vocabulary too: an unknown block comes back with every block there is, and
87
- an unknown attribute with the ones that block reads. That is the backstop, not the teacher —
87
+ an unknown attribute with the ones that block reads. That is the backstop, not the teacher -
88
88
  it is enough to correct a misspelling and not enough to learn a language, since it gives the
89
89
  name of a block and nothing about what that block means or what it takes. `forma_guide`,
90
90
  `forma_blocks` and `forma_reference` are what answer that, and they are bundled so they
@@ -120,24 +120,24 @@ is rendered by the test suite.
120
120
 
121
121
  The same knowledge in the form a person can attach to a conversation by hand:
122
122
 
123
- - `forma://guide` — the language in brief
124
- - `forma://catalogue` — every block and function, as JSON
125
- - `forma://reference/{path}` — one page of the manual
126
- - `forma://example/{name}` — one worked document
123
+ - `forma://guide` - the language in brief
124
+ - `forma://catalogue` - every block and function, as JSON
125
+ - `forma://reference/{path}` - one page of the manual
126
+ - `forma://example/{name}` - one worked document
127
127
 
128
128
  ## Asking for a part
129
129
 
130
130
  Install it in Codex as above, `cd` to wherever you keep your models, and ask:
131
131
 
132
- > **you** — build me a forma file with a tree in it
132
+ > **you** - build me a forma file with a tree in it
133
133
 
134
- > **codex** — Where should I put it?
134
+ > **codex** - Where should I put it?
135
135
 
136
- > **you** — the current folder
136
+ > **you** - the current folder
137
137
 
138
138
  Codex reads the language before it writes any: `forma_guide` for the shape of a document,
139
- `forma_blocks` for the attributes of the blocks it is about to use — here `cone`, `translate`
140
- and `for` — and `forma_examples` for a document of a similar shape. Then it writes, and
139
+ `forma_blocks` for the attributes of the blocks it is about to use - here `cone`, `translate`
140
+ and `for` - and `forma_examples` for a document of a similar shape. Then it writes, and
141
141
  `forma_write` builds what it wrote with the real kernel before anything reaches the disk.
142
142
 
143
143
  Where the vocabulary runs out, the refusals carry it. A guess at a block that does not exist
@@ -145,17 +145,17 @@ comes back with every block that does, and a guess at an attribute with the ones
145
145
  reads:
146
146
 
147
147
  ```
148
- unknown block "frustum" — the blocks are "align", "for", "if", "part", "box", "circle",
148
+ unknown block "frustum" - the blocks are "align", "for", "if", "part", "box", "circle",
149
149
  "cone", "cylinder", "difference", … "union"
150
150
 
151
- cone: unknown attribute "bottom_radius" — cone reads "center", "height", "radius",
151
+ cone: unknown attribute "bottom_radius" - cone reads "center", "height", "radius",
152
152
  "segments", "top_radius"
153
153
  ```
154
154
 
155
155
  Then it lands, and the tool answers with the part rather than with a claim about it:
156
156
 
157
157
  ```
158
- Wrote `tree.forma` under `/home/you/models` — 537 bytes.
158
+ Wrote `tree.forma` under `/home/you/models` - 537 bytes.
159
159
 
160
160
  | Part | Colour | Opacity | Size (X × Y × Z) | Volume | Triangles | Genus |
161
161
  | `trunk` | #6b4423 | 1 | 8 × 8 × 30 | 1004.682 | 108 | 0 |
@@ -243,7 +243,7 @@ this tarball, so it works with no network at all. There is no telemetry, no anal
243
243
  update check and no account.
244
244
 
245
245
  It writes only inside the directories your client declares as MCP roots, never replaces a file
246
- unless told to, and leaves nothing elsewhere — no cache, no config, no logs.
246
+ unless told to, and leaves nothing elsewhere - no cache, no config, no logs.
247
247
 
248
248
  Your MCP client is a separate program with its own policy. Documents you ask a model to write
249
249
  travel wherever that client sends them, which this server neither controls nor sees.
@@ -252,4 +252,4 @@ Full text: [PRIVACY.md](https://github.com/kalpak44/forma-dsl/blob/main/PRIVACY.
252
252
 
253
253
  ## License
254
254
 
255
- MIT — see [LICENSE](LICENSE).
255
+ MIT - see [LICENSE](LICENSE).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forma-dsl-mcp",
3
- "version": "0.3.7",
3
+ "version": "0.3.9",
4
4
  "description": "A stdio MCP server that knows the forma DSL and writes .forma documents that render.",
5
5
  "keywords": [
6
6
  "mcp",
@@ -45,8 +45,8 @@
45
45
  "sync:reference": "node scripts/sync-reference.mjs"
46
46
  },
47
47
  "dependencies": {
48
- "@modelcontextprotocol/sdk": "^1.32.0",
49
- "forma-dsl": "^0.3.7",
48
+ "@modelcontextprotocol/sdk": "^1.32.1",
49
+ "forma-dsl": "^0.3.9",
50
50
  "zod": "^4.1.13"
51
51
  }
52
52
  }
package/src/bin.js CHANGED
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * The executable: one MCP server, speaking over stdin and stdout.
4
4
  *
5
- * Nothing may be written to stdout but the protocol — a stray `console.log` corrupts the
5
+ * Nothing may be written to stdout but the protocol - a stray `console.log` corrupts the
6
6
  * JSON-RPC stream and the client disconnects with an error that names nothing. Everything
7
7
  * this file has to say goes to stderr, which clients collect as the server's log.
8
8
  *
package/src/catalogue.js CHANGED
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * What the server knows about every construct in the language.
3
3
  *
4
- * The descriptions and the attribute tables are written here; the structural facts — which
4
+ * The descriptions and the attribute tables are written here; the structural facts - which
5
5
  * blocks exist, what dimensionality each produces and takes, which functions exist and with
6
- * what arity — are read from the package's own registries at load time and merged in. Where
6
+ * what arity - are read from the package's own registries at load time and merged in. Where
7
7
  * the two disagree, the registry wins and `catalogueDrift` says so, which is what
8
8
  * `test/catalogue.test.js` asserts is empty. A block added to the language therefore cannot
9
9
  * ship without this file learning about it.
@@ -81,7 +81,7 @@ const BLOCK_ENTRIES = [
81
81
  CENTER,
82
82
  ],
83
83
  example: 'rounded_rect { size = [62, 13] radius = 6.5 center = true }',
84
- notes: ['`rounded_rect { size = [10, 10] radius = 5 }` is a circle of diameter 10 — `size` is the outer size.'],
84
+ notes: ['`rounded_rect { size = [10, 10] radius = 5 }` is a circle of diameter 10 - `size` is the outer size.'],
85
85
  },
86
86
  {
87
87
  name: 'circle', group: '2D shapes', summary: 'A circle, from a radius or a diameter.',
@@ -112,7 +112,7 @@ const BLOCK_ENTRIES = [
112
112
  { name: 'sides', type: 'integer', default: 'required', description: 'At least 3' },
113
113
  {
114
114
  name: 'width_across_flats', type: 'number',
115
- description: 'Distance between opposite flats — how a nut or a hex key is specified',
115
+ description: 'Distance between opposite flats - how a nut or a hex key is specified',
116
116
  },
117
117
  { name: 'width_across_corners', type: 'number', description: 'Distance between opposite corners' },
118
118
  { name: 'radius', type: 'number', default: '1', description: 'Centre to corner' },
@@ -479,17 +479,17 @@ const DECLARATIONS = [
479
479
  name: 'type', type: 'enum', values: ['number', 'string', 'bool', 'vector', 'list', 'angle'],
480
480
  description: 'Which control to build. Written bare, without quotes. Inferred from `default` when omitted',
481
481
  },
482
- { name: 'min', type: 'number', description: 'Lower bound of the control. Advisory — never enforced' },
483
- { name: 'max', type: 'number', description: 'Upper bound of the control. Advisory — never enforced' },
482
+ { name: 'min', type: 'number', description: 'Lower bound of the control. Advisory - never enforced' },
483
+ { name: 'max', type: 'number', description: 'Upper bound of the control. Advisory - never enforced' },
484
484
  { name: 'step', type: 'number', description: 'Granularity of the control. Advisory' },
485
485
  { name: 'description', type: 'string', description: 'Help text' },
486
486
  { name: 'options', type: 'list', description: 'A fixed set of choices, for a dropdown. Advisory' },
487
487
  ],
488
488
  example: 'param height { type = number default = 20 min = 8 max = 40 step = 1 }\nparam wall = 2.5',
489
489
  notes: [
490
- 'This is the one block that does not reject attributes it does not understand — a param block is metadata.',
490
+ 'This is the one block that does not reject attributes it does not understand - a param block is metadata.',
491
491
  '`min`, `max`, `step` and `options` shape the editor control and nothing else. Clamp in the model if a bound matters.',
492
- "A default may refer to params declared above it by their bare name, but never to `var` — `var` is assembled after every param resolves.",
492
+ "A default may refer to params declared above it by their bare name, but never to `var` - `var` is assembled after every param resolves.",
493
493
  'A param declared inside a `model` must have a default and cannot be set from outside.',
494
494
  '`param s` on its own is a syntax error; write `param s { }` for a required one.',
495
495
  ],
@@ -513,7 +513,7 @@ const DECLARATIONS = [
513
513
  notes: [
514
514
  'Params are read by their bare name inside the body; there is no `var.` for them.',
515
515
  'Only declared params may be passed. There is no pass-through.',
516
- 'A component body cannot see the call site — it is evaluated in a fresh scope rooted at the document.',
516
+ 'A component body cannot see the call site - it is evaluated in a fresh scope rooted at the document.',
517
517
  'A component cannot declare parts, cannot shadow a builtin, and cannot recurse.',
518
518
  'Reuse is free: the same call with the same arguments is one node in the tree, evaluated once.',
519
519
  ],
@@ -596,8 +596,8 @@ const FUNCTION_DESCRIPTIONS = {
596
596
  round: 'Rounds to the nearest integer, halves upwards',
597
597
  sqrt: 'Square root. A negative input gives NaN, which fails where it is used',
598
598
  sign: '-1, 0 or 1',
599
- pow: 'pow(x, y) — x raised to y',
600
- atan2: 'atan2(y, x) — degrees, in the correct quadrant',
599
+ pow: 'pow(x, y) - x raised to y',
600
+ atan2: 'atan2(y, x) - degrees, in the correct quadrant',
601
601
  log: 'Natural logarithm',
602
602
  exp: 'e raised to x',
603
603
  sin: 'Takes degrees, returns a ratio',
@@ -612,8 +612,8 @@ const FUNCTION_DESCRIPTIONS = {
612
612
  len: 'Length of a list or a string; 0 for anything else',
613
613
  concat: 'Every argument flattened into one list',
614
614
  reverse: 'A list, back to front',
615
- contains: 'contains(list, value) — whether the list holds it',
616
- join: 'join(separator, list) — the items as one string',
615
+ contains: 'contains(list, value) - whether the list holds it',
616
+ join: 'join(separator, list) - the items as one string',
617
617
  str: 'The value as a string',
618
618
  num: 'The value as a number; non-numeric text gives NaN',
619
619
  range: 'range(end), range(start, end) or range(start, end, step). Excludes the end; capped at 100000 values',
@@ -679,7 +679,7 @@ export function formatConstruct(entry) {
679
679
  /** @type {string[]} */
680
680
  const facts = [];
681
681
  if (entry.dim) facts.push(`produces ${entry.dim}D`);
682
- if (entry.leaf) facts.push('a leaf — it takes no children');
682
+ if (entry.leaf) facts.push('a leaf - it takes no children');
683
683
  else if (entry.takes === 'same') facts.push("takes 2D or 3D children, passing the dimensionality through");
684
684
  else if (entry.takes) facts.push(`takes ${entry.takes}D children`);
685
685
  if (entry.ordered) facts.push('the order of its children matters');
@@ -691,7 +691,7 @@ export function formatConstruct(entry) {
691
691
  const type = attribute.values
692
692
  ? attribute.values.map((value) => `\`"${value}"\``).join(' ')
693
693
  : attribute.type;
694
- lines.push(`| \`${attribute.name}\` | ${type} | ${attribute.default ?? '—'} | ${attribute.description} |`);
694
+ lines.push(`| \`${attribute.name}\` | ${type} | ${attribute.default ?? '-'} | ${attribute.description} |`);
695
695
  }
696
696
  lines.push('');
697
697
  }
package/src/document.js CHANGED
@@ -13,19 +13,17 @@ import {
13
13
  GeometryNode,
14
14
  Program,
15
15
  describeParameters,
16
+ excerpt,
16
17
  render,
17
18
  toBinarySTL,
18
19
  } from 'forma-dsl';
19
20
 
20
- /** How many lines of source to show either side of an error. */
21
- const CONTEXT_LINES = 2;
22
-
23
21
  /**
24
22
  * How much geometry one document may build before the server refuses it.
25
23
  *
26
24
  * This server exists to build text a model wrote, which is the case the library leaves a
27
25
  * budget off for. Nesting limits and `range` each bound one loop; only this bounds two of
28
- * them nested, and that shape asks for more geometry than the process can finish — on a pipe,
26
+ * them nested, and that shape asks for more geometry than the process can finish - on a pipe,
29
27
  * with no request timeout of its own, that is a server that never answers again.
30
28
  *
31
29
  * Set far above any honest document: the worked examples build tens of blocks. A model that
@@ -38,7 +36,7 @@ const MAX_NODES = 50_000;
38
36
  * that changed.
39
37
  *
40
38
  * Kept at module scope rather than per-request because that is the whole point of the digest
41
- * cache — a context that lives for one call caches nothing.
39
+ * cache - a context that lives for one call caches nothing.
42
40
  *
43
41
  * @type {EvaluationContext | null}
44
42
  */
@@ -56,38 +54,10 @@ export function disposeSharedContext() {
56
54
  shared = null;
57
55
  }
58
56
 
59
- /**
60
- * Renders a source excerpt with a caret under the column an error points at.
61
- *
62
- * An error message with a line number still makes a model count lines. An excerpt does not.
63
- *
64
- * @param {string} source The document.
65
- * @param {{ line: number, column: number } | null | undefined} loc Where the error was raised.
66
- * @returns {string | undefined} The excerpt, or undefined when there is no position.
67
- */
68
- export function excerpt(source, loc) {
69
- if (!loc || !Number.isInteger(loc.line)) return undefined;
70
-
71
- const lines = source.split('\n');
72
- const first = Math.max(1, loc.line - CONTEXT_LINES);
73
- const last = Math.min(lines.length, loc.line + CONTEXT_LINES);
74
- const width = String(last).length;
75
-
76
- /** @type {string[]} */
77
- const out = [];
78
- for (let n = first; n <= last; n++) {
79
- out.push(`${String(n).padStart(width)} | ${lines[n - 1]}`);
80
- if (n === loc.line) {
81
- out.push(`${' '.repeat(width)} | ${' '.repeat(Math.max(0, loc.column - 1))}^`);
82
- }
83
- }
84
- return out.join('\n');
85
- }
86
-
87
57
  /**
88
58
  * Turns a thrown error into the shape a tool result reports.
89
59
  *
90
- * @param {string} stage Which pass raised it — the stage is what tells a caller whether the
60
+ * @param {string} stage Which pass raised it - the stage is what tells a caller whether the
91
61
  * mistake was a typo, a misunderstanding of meaning, or a degenerate solid.
92
62
  * @param {unknown} error What was thrown.
93
63
  * @param {string} source The document, for the excerpt.
@@ -119,7 +89,7 @@ const tidy = (value) => Number.parseFloat(value.toFixed(4));
119
89
  * What a solved part is, measured.
120
90
  *
121
91
  * The bounding box and the volume are what let a caller check a model against a requirement
122
- * it was given — "it has to fit in 100 x 60" is answerable from here without a viewer.
92
+ * it was given - "it has to fit in 100 x 60" is answerable from here without a viewer.
123
93
  *
124
94
  * @param {Solid} solid The solved part.
125
95
  * @returns {object} Its measurements.
@@ -198,7 +168,7 @@ export async function checkDocument(source, options = {}) {
198
168
  ...declared,
199
169
  error: {
200
170
  stage: 'render',
201
- message: 'the document declares no model — add a `model "name" { ... }` block',
171
+ message: 'the document declares no model - add a `model "name" { ... }` block',
202
172
  },
203
173
  };
204
174
  }
@@ -276,7 +246,7 @@ export async function exportStl(source, options = {}) {
276
246
  const wanted = result.parts.find((candidate) => candidate.name === part);
277
247
  if (!wanted) {
278
248
  const names = result.parts.map((candidate) => `"${candidate.name}"`).join(', ');
279
- throw new Error(`no part named "${part}" in model "${result.name}" — it has ${names}`);
249
+ throw new Error(`no part named "${part}" in model "${result.name}" - it has ${names}`);
280
250
  }
281
251
  solid = wanted.concrete;
282
252
  name = wanted.name;
@@ -285,7 +255,7 @@ export async function exportStl(source, options = {}) {
285
255
  name = result.parts[0].name;
286
256
  } else {
287
257
  // Several parts in one file: STL has no notion of a part, so they are unioned rather
288
- // than concatenated — a slicer reading two overlapping shells makes a mess of them.
258
+ // than concatenated - a slicer reading two overlapping shells makes a mess of them.
289
259
  solid = await shared.evaluate(GeometryNode.union(result.parts.map((each) => each.node)));
290
260
  name = 'all';
291
261
  }
package/src/examples.js CHANGED
@@ -126,7 +126,7 @@ model "stacked_trays" {
126
126
 
127
127
  {
128
128
  name: 'revolved-profile',
129
- summary: 'A vase turned from a profile — the shortest route from a drawing to a solid.',
129
+ summary: 'A vase turned from a profile - the shortest route from a drawing to a solid.',
130
130
  shows: ['polygon from a table of points', 'revolve', 'a second profile derived from the first', 'segments'],
131
131
  source: `param wall { type = number default = 2.4 min = 1.2 max = 5 step = 0.2 }
132
132
  param base { type = number default = 3.5 min = 2 max = 8 step = 0.5 }
package/src/guide.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * The language, compressed to what someone writing a document has to hold in their head.
3
3
  *
4
- * This is deliberately not the reference manual — the manual is bundled too, and
4
+ * This is deliberately not the reference manual - the manual is bundled too, and
5
5
  * `forma_reference` serves it. This is the page that goes into a model's context before it
6
6
  * writes anything: the rules that are not guessable, the mistakes that are expensive, and
7
7
  * the shape of a working document.
@@ -43,7 +43,7 @@ model "riser" {
43
43
  }
44
44
  \`\`\`
45
45
 
46
- Order does not matter between declarations of different kinds — a model may use a component
46
+ Order does not matter between declarations of different kinds - a model may use a component
47
47
  declared below it. Order does matter among top-level \`local\`s, which resolve top to bottom.`,
48
48
 
49
49
  nesting: `## Nesting is composition
@@ -77,8 +77,8 @@ children is an error.`,
77
77
 
78
78
  Every piece of geometry is 2D or 3D, and the language tracks which.
79
79
 
80
- - **2D** — \`rect\` \`rounded_rect\` \`circle\` \`ellipse\` \`regular_polygon\` \`stadium\` \`polygon\`
81
- - **3D** — \`box\` \`sphere\` \`cylinder\` \`cone\` \`torus\`
80
+ - **2D** - \`rect\` \`rounded_rect\` \`circle\` \`ellipse\` \`regular_polygon\` \`stadium\` \`polygon\`
81
+ - **3D** - \`box\` \`sphere\` \`cylinder\` \`cone\` \`torus\`
82
82
  - **Operations** either fix what they take (\`extrude\` takes 2D, \`refine\` takes 3D) or pass
83
83
  their children's dimensionality through (\`translate\` \`rotate\` \`scale\` \`mirror\` \`hull\`
84
84
  \`simplify\` \`align\`, the booleans).
@@ -89,17 +89,17 @@ Three rules, each with its own error:
89
89
  | --- | --- |
90
90
  | Children of one block must all be the same dimensionality | \`"union" cannot mix 2D and 3D shapes\` |
91
91
  | A block that fixes what it takes must get it | \`"extrude" takes 2D geometry, got 3D\` |
92
- | Anything rendered must be 3D | \`part "body" is 2D — extrude or revolve it before rendering\` |
92
+ | Anything rendered must be 3D | \`part "body" is 2D - extrude or revolve it before rendering\` |
93
93
 
94
94
  2D → 3D with \`extrude\` or \`revolve\`; 3D → 2D with \`project\` or \`slice\`.`,
95
95
 
96
96
  units: `## Units and orientation
97
97
 
98
98
  - **Lengths are unitless.** Every consumer treats them as millimetres.
99
- - **Angles are always degrees.** Everywhere — \`rotate { z = 90 }\`, \`revolve { angle = 180 }\`,
99
+ - **Angles are always degrees.** Everywhere - \`rotate { z = 90 }\`, \`revolve { angle = 180 }\`,
100
100
  \`sin(30)\`, \`param a { type = angle }\`. There is no radian form.
101
101
  - **Z is up.** \`extrude\` sweeps along +Z. \`revolve\` spins a profile written in the XY plane
102
- about the Y axis, mapping the profile's Y to the result's Z — so a profile drawn upwards
102
+ about the Y axis, mapping the profile's Y to the result's Z - so a profile drawn upwards
103
103
  from \`[0, 0]\` produces a solid standing on the XY plane.
104
104
  - **3D rotations apply X, then Y, then Z**, matching OpenSCAD.
105
105
  - **Transforms act about the origin.** Centre a shape (\`center = true\`) before rotating it,
@@ -107,11 +107,11 @@ Three rules, each with its own error:
107
107
 
108
108
  syntax: `## Syntax
109
109
 
110
- **Comments** — \`// line\`, \`# line\`, \`/* block */\`. Block comments do not nest.
110
+ **Comments** - \`// line\`, \`# line\`, \`/* block */\`. Block comments do not nest.
111
111
 
112
- **Numbers** — \`12\` \`0.5\` \`.5\` \`1e3\` \`2.5e-3\`. No hex, no separators, no unit suffix.
112
+ **Numbers** - \`12\` \`0.5\` \`.5\` \`1e3\` \`2.5e-3\`. No hex, no separators, no unit suffix.
113
113
 
114
- **Strings** — double quotes only, no literal newline. Exactly six escapes: \`\\n\` \`\\t\` \`\\r\`
114
+ **Strings** - double quotes only, no literal newline. Exactly six escapes: \`\\n\` \`\\t\` \`\\r\`
115
115
  \`\\"\` \`\\\\\` \`\\$\`. \`\${ expr }\` splices any expression in.
116
116
 
117
117
  **Identifiers** start with a letter or \`_\` and continue with letters, digits, \`_\` **or \`-\`**.
@@ -127,7 +127,7 @@ Three rules, each with its own error:
127
127
  is \`name = expression\`; anything else that is followed by a brace is a nested block. Setting
128
128
  the same attribute twice is an error.
129
129
 
130
- **The newline rule.** Outside brackets, **a line break ends an expression** — that is what
130
+ **The newline rule.** Outside brackets, **a line break ends an expression** - that is what
131
131
  lets attributes be written one per line with no separators. Inside \`(...)\`, \`[...]\` or an
132
132
  argument list the rule is suspended, so break there to continue across lines:
133
133
 
@@ -138,7 +138,7 @@ local total = (
138
138
  )
139
139
 
140
140
  local b = base
141
- + extra // NOT a continuation — this is a parse error
141
+ + extra // NOT a continuation - this is a parse error
142
142
  \`\`\`
143
143
 
144
144
  Trailing commas are allowed in lists, objects and argument lists.`,
@@ -156,7 +156,7 @@ Trailing commas are allowed in lists, objects and argument lists.`,
156
156
 
157
157
  There is no vector type: **a vector is a list of numbers**, and a block says how many
158
158
  components it wants. Where a block wants a vector, **a single number stands for every
159
- component** — \`scale { factor = 2 }\` is \`scale { factor = [2, 2, 2] }\`.
159
+ component** - \`scale { factor = 2 }\` is \`scale { factor = [2, 2, 2] }\`.
160
160
 
161
161
  **Operators**, loosest first: \`?:\` · \`||\` · \`&&\` · \`== !=\` · \`< <= > >=\` · \`+ -\` ·
162
162
  \`* / %\` · prefix \`-x !x\` · postfix \`a.b a[i] f(x)\`.
@@ -174,21 +174,21 @@ errors**, not \`Infinity\`.
174
174
 
175
175
  \`<\` \`<=\` \`>\` \`>=\` need both sides to be the same type. \`==\` compares structurally.
176
176
 
177
- **Truthy / falsy** — falsy is \`false\`, \`0\`, \`""\`, \`[]\`, \`null\`. Everything else is truthy,
177
+ **Truthy / falsy** - falsy is \`false\`, \`0\`, \`""\`, \`[]\`, \`null\`. Everything else is truthy,
178
178
  including \`{}\`.
179
179
 
180
- **Members** — \`.x\` \`.y\` \`.z\` read index 0, 1, 2 off a list, so \`size.z\` works on a
180
+ **Members** - \`.x\` \`.y\` \`.z\` read index 0, 1, 2 off a list, so \`size.z\` works on a
181
181
  3-vector. \`a[i]\` indexes. \`var.NAME\` reads a param.`,
182
182
 
183
183
  scope: `## Scope and names
184
184
 
185
- - **\`var\` holds params only** — never locals. A local with the same name shadows the bare
185
+ - **\`var\` holds params only** - never locals. A local with the same name shadows the bare
186
186
  name but \`var.NAME\` still means the param.
187
187
  - **Component params are read by their bare name.** There is no \`var.\` for them.
188
188
  - **A component body cannot see the call site.** It is evaluated in a fresh scope rooted at
189
189
  the document, so a \`local\` where it is called does not leak in. Document params and other
190
190
  components are in scope.
191
- - **A param default may use earlier params by their bare name**, but never \`var\` — \`var\` is
191
+ - **A param default may use earlier params by their bare name**, but never \`var\` - \`var\` is
192
192
  assembled only after every param has resolved.
193
193
  - **Top-level locals are ordered**; locals inside a body are not, so a block may use a local
194
194
  written below it.
@@ -209,7 +209,7 @@ if cond { ... } else if other { ... } else { ... }
209
209
  backwards, so a loop can never spin forever.
210
210
  - There is no \`break\`, no \`continue\` and no accumulator. Filter the list instead.
211
211
  - A loop over an empty list produces nothing, which is an error where something was
212
- required — guard it with \`if\`.
212
+ required - guard it with \`if\`.
213
213
  - For a *value* that depends on a condition, use \`cond ? a : b\`, not \`if\`.
214
214
 
215
215
  The radial-array idiom, which is most of what loops are for:
@@ -237,7 +237,7 @@ model "assembly" {
237
237
  \`\`\`
238
238
 
239
239
  - Declare parts **directly in a model**, or inside a \`for\`/\`if\` in one. A part nested inside
240
- a transform or a boolean is **silently dropped** — no error, it is just gone.
240
+ a transform or a boolean is **silently dropped** - no error, it is just gone.
241
241
  - A part cannot contain another part, cannot be empty, and cannot come out of a component.
242
242
  - Geometry written outside any part becomes one default part named after the model.
243
243
  - The label is an expression: \`part "leg_\${i}"\`.
@@ -246,7 +246,7 @@ model "assembly" {
246
246
  pitfalls: `## The mistakes that cost the most time
247
247
 
248
248
  1. **Hyphens bind into names.** \`outer-inner\` is one identifier. Write \`outer - inner\`.
249
- 2. **A misspelled attribute is an error, not a silent no-op** — \`box: unknown attribute
249
+ 2. **A misspelled attribute is an error, not a silent no-op** - \`box: unknown attribute
250
250
  "colour"\`. The one exception is a \`param\` block, which passes anything through.
251
251
  3. **A part inside a transform disappears** with no warning. Put the transform inside the
252
252
  part.
@@ -260,11 +260,11 @@ model "assembly" {
260
260
  9. **\`min\`/\`max\`/\`step\`/\`options\` on a param are advisory** and never enforced. Clamp in
261
261
  the model if the bound matters: \`local n = max(3, min(16, var.bolts))\`.
262
262
  10. **Empty geometry is silent.** \`extrude { height = 0 }\`, a \`difference\` that removes
263
- everything and a backwards \`range\` all produce nothing rather than an error — and then
263
+ everything and a backwards \`range\` all produce nothing rather than an error - and then
264
264
  fail further out with \`model "m" produced no geometry\`.
265
265
  11. **A line break ends an expression** outside brackets. Continue inside \`(...)\` or end the
266
266
  line on an operator.
267
- 12. **\`rotate\` spells its 2D and 3D attributes differently** — \`angle\` in 2D, \`x\`/\`y\`/\`z\`
267
+ 12. **\`rotate\` spells its 2D and 3D attributes differently** - \`angle\` in 2D, \`x\`/\`y\`/\`z\`
268
268
  or \`angles\` in 3D.`,
269
269
 
270
270
  workflow: `## How to write one of these
@@ -274,7 +274,7 @@ model "assembly" {
274
274
  2. **Write the profile, then give it thickness.** Most real parts are a 2D outline extruded
275
275
  or revolved. Modelling in 2D and converting once is shorter and more robust than
276
276
  composing solids.
277
- 3. **Name repeated features as components** — including the *negative* of a feature. A
277
+ 3. **Name repeated features as components** - including the *negative* of a feature. A
278
278
  \`component "counterbore"\` subtracted in a loop reads far better than four inlined unions.
279
279
  4. **One \`part\` per physical piece**, each with a colour. It is what makes a render legible
280
280
  and what lets a single piece be exported.
@@ -307,7 +307,7 @@ export function guide(section) {
307
307
  return ['# The forma language, in brief', '', ...ORDER.map((name) => SECTIONS[name])].join('\n\n');
308
308
  }
309
309
  if (!SECTIONS[section]) {
310
- throw new Error(`no guide section "${section}" — it is one of ${ORDER.join(', ')}`);
310
+ throw new Error(`no guide section "${section}" - it is one of ${ORDER.join(', ')}`);
311
311
  }
312
312
  return SECTIONS[section];
313
313
  }
@@ -3,7 +3,7 @@
3
3
  `forma` is a declarative language for describing solids. A document is a list of top-level
4
4
  blocks; geometry is written by **nesting blocks**, where a block's children are its operands.
5
5
  The evaluator turns a document into a tree of immutable geometry nodes, and the
6
- [Manifold](https://github.com/elalish/manifold) CSG kernel — compiled to WebAssembly — solves
6
+ [Manifold](https://github.com/elalish/manifold) CSG kernel - compiled to WebAssembly - solves
7
7
  that tree into meshes.
8
8
 
9
9
  ```hcl
@@ -28,8 +28,8 @@ model "riser" {
28
28
  ```
29
29
 
30
30
  This reference documents every construct in the language and every function the JavaScript
31
- package exports. For a tour of the project itself — how to run the editor, how to consume the
32
- library from Node — see the [top-level README](../../../README.md).
31
+ package exports. For a tour of the project itself - how to run the editor, how to consume the
32
+ library from Node - see the [top-level README](../../../README.md).
33
33
 
34
34
  ---
35
35
 
@@ -79,7 +79,7 @@ Leaf blocks. They take attributes and never contain other blocks.
79
79
  | [`cone`](reference/shapes-3d.md#cone) | 3D | A cone, or a truncated cone |
80
80
  | [`torus`](reference/shapes-3d.md#torus) | 3D | A ring, optionally a partial arc |
81
81
 
82
- 2D shapes cannot be rendered on their own — [`extrude`](reference/conversions.md#extrude) or
82
+ 2D shapes cannot be rendered on their own - [`extrude`](reference/conversions.md#extrude) or
83
83
  [`revolve`](reference/conversions.md#revolve) them first.
84
84
 
85
85
  ---
@@ -149,5 +149,15 @@ the package root and typed by hand-written declarations.
149
149
 
150
150
  ## Errors
151
151
 
152
- [Every error message](errors.md), what raises it, and what to change — the language's errors
152
+ [Every error message](errors.md), what raises it, and what to change - the language's errors
153
153
  in one place, grouped by the stage that raises them.
154
+
155
+ ---
156
+
157
+ ## Running it
158
+
159
+ | Tool | What it is |
160
+ | --- | --- |
161
+ | [Command line](cli.md) | `forma render part.forma -o part.stl` - solving a document in a build, with no browser |
162
+ | [Editor](https://kalpak44.github.io/forma-dsl/editor/) | The same kernel in a browser, with live previews and a control per declared param |
163
+ | [MCP server](https://www.npmjs.com/package/forma-dsl-mcp) | The language as a tool an agent can write, with every document it produces solved before it answers |
@@ -15,7 +15,7 @@ const params = describeParameters(source);
15
15
  ### `describeParameters(source)`
16
16
 
17
17
  Parses `source` and returns one descriptor per top-level [`param`](../reference/param.md), in
18
- source order. No geometry is evaluated and no kernel is loaded — this is the call a front end
18
+ source order. No geometry is evaluated and no kernel is loaded - this is the call a front end
19
19
  makes to build controls before it can have any values to render with.
20
20
 
21
21
  #### Parameters
@@ -51,7 +51,7 @@ makes to build controls before it can have any values to render with.
51
51
  - **`min`, `max`, `step` and `options` are advisory.** Nothing enforces them, including
52
52
  [`render`](render.md). Clamp in the model if a bound matters.
53
53
  - **Model params are not listed.** A `param` inside a `model` is a constant, not an input.
54
- - It still throws on a **syntax error** — the document has to parse.
54
+ - It still throws on a **syntax error** - the document has to parse.
55
55
 
56
56
  #### Type inference
57
57
 
@@ -127,7 +127,7 @@ default, and a front end can act on that before rendering fails.
127
127
 
128
128
  ### Re-reading after an edit
129
129
 
130
- `describeParameters` is cheap — a parse, no kernel — so calling it on every edit is
130
+ `describeParameters` is cheap - a parse, no kernel - so calling it on every edit is
131
131
  reasonable. Keep any value the user has already set, for a param that still exists with a
132
132
  compatible type:
133
133
 
@@ -147,7 +147,7 @@ function reconcile(source, previous) {
147
147
 
148
148
  ### A descriptor's `default` is `undefined`
149
149
 
150
- Either the param declares no default — check `required` — or its default could not be
150
+ Either the param declares no default - check `required` - or its default could not be
151
151
  evaluated. A default that refers to a param declared **below** it cannot resolve; params are
152
152
  read in source order.
153
153