forma-dsl-mcp 0.1.4

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 (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +97 -0
  3. package/package.json +52 -0
  4. package/src/bin.js +30 -0
  5. package/src/catalogue.js +703 -0
  6. package/src/document.js +285 -0
  7. package/src/examples.js +212 -0
  8. package/src/guide.js +313 -0
  9. package/src/reference/README.md +153 -0
  10. package/src/reference/api/describe-parameters.md +167 -0
  11. package/src/reference/api/evaluation-context.md +221 -0
  12. package/src/reference/api/export.md +185 -0
  13. package/src/reference/api/geometry-node.md +215 -0
  14. package/src/reference/api/kernel.md +182 -0
  15. package/src/reference/api/low-level.md +239 -0
  16. package/src/reference/api/render.md +217 -0
  17. package/src/reference/api/values.md +223 -0
  18. package/src/reference/errors.md +253 -0
  19. package/src/reference/language/control-flow.md +160 -0
  20. package/src/reference/language/expressions.md +206 -0
  21. package/src/reference/language/overview.md +156 -0
  22. package/src/reference/language/scope.md +184 -0
  23. package/src/reference/language/syntax.md +210 -0
  24. package/src/reference/reference/align.md +189 -0
  25. package/src/reference/reference/booleans.md +231 -0
  26. package/src/reference/reference/component.md +248 -0
  27. package/src/reference/reference/conversions.md +269 -0
  28. package/src/reference/reference/functions.md +314 -0
  29. package/src/reference/reference/local.md +197 -0
  30. package/src/reference/reference/model.md +213 -0
  31. package/src/reference/reference/param.md +230 -0
  32. package/src/reference/reference/part.md +229 -0
  33. package/src/reference/reference/refinement.md +284 -0
  34. package/src/reference/reference/shapes-2d.md +309 -0
  35. package/src/reference/reference/shapes-3d.md +279 -0
  36. package/src/reference/reference/transforms.md +246 -0
  37. package/src/reference.js +160 -0
  38. package/src/server.js +219 -0
  39. package/src/tools.js +523 -0
  40. package/src/workspace.js +204 -0
@@ -0,0 +1,221 @@
1
+ # `EvaluationContext`
2
+
3
+ Evaluates a geometry tree against the Manifold kernel, memoized by node digest. It is both
4
+ the cache that makes editing cheap and the owner of every WebAssembly object produced.
5
+
6
+ ```js
7
+ import { EvaluationContext } from 'forma-dsl';
8
+
9
+ const context = await EvaluationContext.create();
10
+ // … render with it …
11
+ context.dispose();
12
+ ```
13
+
14
+ ---
15
+
16
+ ## Reference
17
+
18
+ ### `EvaluationContext.create()`
19
+
20
+ The only way to build a context, because the kernel loads asynchronously.
21
+
22
+ Returns `Promise<EvaluationContext>`, with the WASM module ready.
23
+
24
+ ### `context.evaluate(node)`
25
+
26
+ Solves a [`GeometryNode`](geometry-node.md) and returns the kernel object for it, memoized by
27
+ the node's digest.
28
+
29
+ | | |
30
+ | --- | --- |
31
+ | **Parameter** | `node: GeometryNode` |
32
+ | **Returns** | `Promise<Solid \| Shape>` — a `Manifold` for a 3D node, a `CrossSection` for a 2D one |
33
+ | **Throws** | If the context has been disposed |
34
+
35
+ Concurrent requests for the same node share one evaluation, so a shape used twice in a tree
36
+ walked concurrently is built once rather than twice with one copy leaked.
37
+
38
+ ### `context.collect(roots, options?)`
39
+
40
+ Frees every cached object the given roots can no longer reach.
41
+
42
+ | | |
43
+ | --- | --- |
44
+ | **`roots`** | `readonly GeometryNode[]` — the trees still in use |
45
+ | **`options.keep`** | `number`, default `512`. How many unreachable entries to keep as a most-recently-used tail |
46
+ | **Returns** | `number` — how many objects were freed |
47
+
48
+ The retained tail matters more than it looks: editing churns the tree, so the entries that
49
+ just fell out of it are the ones most likely to come straight back — on an undo, or on the
50
+ next character of a number being typed.
51
+
52
+ ### `context.dispose()`
53
+
54
+ Frees every WASM object the context has produced. The context cannot be used again;
55
+ evaluations still in flight free their own results as they land. Disposing twice is harmless.
56
+
57
+ ### Properties
58
+
59
+ | Property | Type | Description |
60
+ | --- | --- | --- |
61
+ | `wasm` | `Kernel` | The loaded module. Throws if read before `create()` resolved |
62
+ | `size` | `number` | How many objects the cache holds |
63
+ | `disposed` | `boolean` | Whether `dispose()` has run |
64
+ | `stats` | `{ evaluated, hits, freed }` | Running totals for the context's whole life |
65
+
66
+ ---
67
+
68
+ ## Why a context exists
69
+
70
+ WebAssembly objects are not garbage-collected. Something has to own them, and something has
71
+ to decide when they are no longer needed — that is this class.
72
+
73
+ It is also the cache. Nothing is freed as evaluation proceeds, because an intermediate result
74
+ is also a cache entry and a later node may still need it. The two ways out are `collect`,
75
+ which frees what the current tree can no longer reach, and `dispose`, which frees the lot.
76
+
77
+ **A context is meant to outlive a single render.** Reusing one across renders is what makes
78
+ the digest cache worth having: a re-render after an edit re-evaluates only the subtrees whose
79
+ content actually changed.
80
+
81
+ ---
82
+
83
+ ## Usage
84
+
85
+ ### One context per session
86
+
87
+ ```js
88
+ const context = await EvaluationContext.create();
89
+
90
+ try {
91
+ for (const params of variants) {
92
+ const result = await render(source, { params, context });
93
+ await write(result);
94
+ context.collect(result.parts.map((part) => part.node));
95
+ }
96
+ } finally {
97
+ context.dispose();
98
+ }
99
+ ```
100
+
101
+ The `try`/`finally` matters: a throw halfway through a batch would otherwise leak every solid
102
+ built so far.
103
+
104
+ ### In a browser, for the life of the page
105
+
106
+ The editor keeps one context forever and collects after each render:
107
+
108
+ ```js
109
+ const context = await EvaluationContext.create();
110
+
111
+ async function rerender(source, params) {
112
+ const result = await render(source, { params, context });
113
+ draw(result.parts);
114
+ context.collect(result.parts.map((part) => part.node));
115
+ }
116
+ ```
117
+
118
+ There is no `dispose` here, and that is correct — the page unloading frees the whole heap.
119
+
120
+ ### Tuning what is kept
121
+
122
+ The default tail of 512 entries suits an editor. A batch job that renders a thousand
123
+ unrelated variants has no use for it:
124
+
125
+ ```js
126
+ context.collect(roots, { keep: 0 }); // free everything unreachable
127
+ ```
128
+
129
+ A renderer showing a draft and then a final pass of the *same* model might raise it, so the
130
+ draft's subtrees survive into the final render.
131
+
132
+ ### Watching the cache work
133
+
134
+ ```js
135
+ const before = { ...context.stats };
136
+ await render(source, { params: { height: 21 }, context });
137
+
138
+ const evaluated = context.stats.evaluated - before.evaluated;
139
+ const hits = context.stats.hits - before.hits;
140
+ console.log(`${evaluated} solved, ${hits} reused`);
141
+ ```
142
+
143
+ On a document where one dimension changed, `evaluated` should be a handful and `hits` most of
144
+ the tree. If `evaluated` is the whole document every time, something is defeating the cache —
145
+ see [Troubleshooting](#every-render-re-evaluates-everything).
146
+
147
+ ### Evaluating a node directly
148
+
149
+ `render` is the front door, but the context takes any node:
150
+
151
+ ```js
152
+ import { GeometryNode, EvaluationContext } from 'forma-dsl';
153
+
154
+ const context = await EvaluationContext.create();
155
+
156
+ const box = GeometryNode.shape(3, 'box', { size: [10, 10, 10], center: false });
157
+ const solid = await context.evaluate(box);
158
+
159
+ solid.volume(); // 1000
160
+ context.dispose();
161
+ ```
162
+
163
+ ---
164
+
165
+ ## Ownership rules
166
+
167
+ 1. **Everything a context returns belongs to the context.** Never call `delete()` on a
168
+ `Solid`, a `Shape` or anything hanging off `part.concrete`.
169
+ 2. **A result is valid only while its context is alive.** After `dispose()`, every object it
170
+ produced is gone. Copy out what you need first — `toRenderMesh` and `toBinarySTL` both
171
+ produce plain typed arrays that outlive the context.
172
+ 3. **`render` does not dispose a context you supplied.** It disposes only one it created
173
+ itself, and only on failure.
174
+
175
+ ---
176
+
177
+ ## Troubleshooting
178
+
179
+ ### `EvaluationContext has been disposed`
180
+
181
+ An `evaluate` or a `render` used a context after `dispose()`. Usually an async render that
182
+ was still in flight when a page or a test tore the context down — await it first.
183
+
184
+ ### `EvaluationContext used before create() resolved`
185
+
186
+ The constructor was called directly. Use the factory:
187
+
188
+ ```js
189
+ new EvaluationContext(); // ✗ no kernel
190
+ await EvaluationContext.create(); // ✓
191
+ ```
192
+
193
+ ### Memory grows without bound
194
+
195
+ Add a `collect` after each render, passing the roots that are still in use:
196
+
197
+ ```js
198
+ context.collect(result.parts.map((part) => part.node));
199
+ ```
200
+
201
+ Without it, every intermediate solid from every render is retained — which is the correct
202
+ default, because the context cannot know whether you still hold a reference.
203
+
204
+ ### `collect` freed nothing
205
+
206
+ It returns `0` when the number of unreachable entries is at or below `keep` — the default 512
207
+ tail. That is expected on a small document. Pass `{ keep: 0 }` to see whether anything is
208
+ genuinely unreachable.
209
+
210
+ ### Every render re-evaluates everything
211
+
212
+ Either a fresh context is being created per render — check that the same one is passed back
213
+ in — or the document's content genuinely changes on every render. A param that feeds a
214
+ dimension changes the digest of everything downstream of it, which is correct; a param that
215
+ feeds only one wall should not.
216
+
217
+ ### Solids behave oddly after an error
218
+
219
+ If `render` threw on a context it created, that context was disposed before the error
220
+ propagated, and anything you captured from an earlier successful render on it is now freed.
221
+ Supply your own context when you need results to survive a failure.
@@ -0,0 +1,185 @@
1
+ # Export
2
+
3
+ Turning a solved solid into buffers a renderer can upload, or a file a slicer can read.
4
+
5
+ ```js
6
+ import { toRenderMesh, toBinarySTL } from 'forma-dsl';
7
+ ```
8
+
9
+ Both take a `Solid` — the `concrete` field of a
10
+ [rendered part](render.md#returns) — and both return plain typed arrays that **outlive the
11
+ context**, so they are what to copy out before disposing.
12
+
13
+ ---
14
+
15
+ ## `toRenderMesh`
16
+
17
+ ### `toRenderMesh(solid)`
18
+
19
+ Converts a solid into flat arrays for a renderer.
20
+
21
+ | | |
22
+ | --- | --- |
23
+ | **Parameter** | `solid: Solid` |
24
+ | **Returns** | `RenderMesh` |
25
+
26
+ | Field | Type | Description |
27
+ | --- | --- | --- |
28
+ | `positions` | `Float32Array` | Three vertices per triangle, expanded — `triangleCount * 9` floats |
29
+ | `normals` | `Float32Array` | One face normal, repeated for all three corners |
30
+ | `triangleCount` | `number` | Triangles |
31
+ | `vertexCount` | `number` | Vertices **before** expansion, as the kernel counted them |
32
+
33
+ #### Why vertices are expanded
34
+
35
+ The kernel returns indexed triangles sharing vertices, which is what a viewer wants for
36
+ upload size. But a shared vertex across a hard edge gets one averaged normal and is
37
+ smooth-shaded, so a cube reads as a sphere-ish blob. Normals are therefore computed per face
38
+ and the vertices expanded, which is what keeps the edge hard.
39
+
40
+ The cost is that `positions.length / 3` is `triangleCount * 3` rather than `vertexCount`. Use
41
+ `vertexCount` only as a measure of the underlying mesh.
42
+
43
+ #### Caveats
44
+
45
+ - A degenerate triangle keeps a zero normal rather than dividing by zero and poisoning the
46
+ buffer with NaN.
47
+ - [`render`](render.md) already calls this for every part — `part.mesh` is the result. Call
48
+ it yourself only for a solid you built directly.
49
+
50
+ ---
51
+
52
+ ## `toBinarySTL`
53
+
54
+ ### `toBinarySTL(solid, header?)`
55
+
56
+ Encodes a solid as binary STL.
57
+
58
+ | | |
59
+ | --- | --- |
60
+ | **`solid`** | `Solid` |
61
+ | **`header`** | `string`, default `'forma-dsl'`. Free text for the 80-byte header, truncated to fit |
62
+ | **Returns** | `Uint8Array` — the file |
63
+
64
+ Binary rather than ASCII: a model of any size is several times smaller and every slicer reads
65
+ it.
66
+
67
+ #### Caveats
68
+
69
+ - **STL has no units.** Every consumer treats the numbers as millimetres, which is what the
70
+ language's lengths are usually meant to be, but nothing records that.
71
+ - **STL has no colour and no parts.** A [`part`](../reference/part.md)'s colour and opacity do
72
+ not survive. Export each part to its own file if they need to stay distinct.
73
+ - The header is truncated to 79 bytes so it cannot run into the triangle count that follows.
74
+ - Float32 throughout, as the format requires. A model with features far from the origin loses
75
+ precision.
76
+
77
+ ---
78
+
79
+ ## Usage
80
+
81
+ ### Exporting one part
82
+
83
+ ```js
84
+ import { render, toBinarySTL } from 'forma-dsl';
85
+ import { writeFile } from 'node:fs/promises';
86
+
87
+ const result = await render(source);
88
+ await writeFile('part.stl', toBinarySTL(result.parts[0].concrete));
89
+ result.context.dispose();
90
+ ```
91
+
92
+ ### Exporting every part separately
93
+
94
+ Colours do not survive STL, so one file per part is how an assembly stays an assembly:
95
+
96
+ ```js
97
+ const result = await render(source);
98
+
99
+ for (const part of result.parts) {
100
+ await writeFile(`${part.name}.stl`, toBinarySTL(part.concrete, `${result.name}/${part.name}`));
101
+ }
102
+
103
+ result.context.dispose();
104
+ ```
105
+
106
+ The header is a reasonable place to record where the file came from.
107
+
108
+ ### Downloading from a browser
109
+
110
+ ```js
111
+ const stl = toBinarySTL(part.concrete, part.name);
112
+ const url = URL.createObjectURL(new Blob([stl], { type: 'model/stl' }));
113
+
114
+ const a = document.createElement('a');
115
+ a.href = url;
116
+ a.download = `${part.name}.stl`;
117
+ a.click();
118
+
119
+ URL.revokeObjectURL(url);
120
+ ```
121
+
122
+ ### Uploading to a renderer
123
+
124
+ `render` has already produced the mesh, so three.js needs no conversion:
125
+
126
+ ```js
127
+ import { BufferGeometry, BufferAttribute, Mesh, MeshStandardMaterial } from 'three';
128
+
129
+ function toThree(part) {
130
+ const geometry = new BufferGeometry();
131
+ geometry.setAttribute('position', new BufferAttribute(part.mesh.positions, 3));
132
+ geometry.setAttribute('normal', new BufferAttribute(part.mesh.normals, 3));
133
+
134
+ return new Mesh(geometry, new MeshStandardMaterial({
135
+ color: part.color,
136
+ transparent: part.opacity < 1,
137
+ opacity: part.opacity,
138
+ }));
139
+ }
140
+ ```
141
+
142
+ ### Keeping geometry past the context
143
+
144
+ Typed arrays are plain memory, so they survive disposal — unlike `part.concrete`:
145
+
146
+ ```js
147
+ const result = await render(source);
148
+ const saved = result.parts.map((part) => ({
149
+ name: part.name,
150
+ color: part.color,
151
+ stl: toBinarySTL(part.concrete), // copied out…
152
+ }));
153
+ result.context.dispose(); // …so this is safe
154
+ ```
155
+
156
+ ---
157
+
158
+ ## Troubleshooting
159
+
160
+ ### `EvaluationContext has been disposed`
161
+
162
+ The solid was freed before it was encoded. Export first, dispose after — or copy the arrays
163
+ out, which is what the example above does.
164
+
165
+ ### The STL is empty, or has zero triangles
166
+
167
+ The part solved to empty geometry. Usual causes: a `difference` that removed everything, an
168
+ `extrude` whose height reached `0`, a `revolve` whose angle reached `0`. All of these are
169
+ silent by design, so a parameter at the end of its range does not break a model.
170
+
171
+ ### My part came in at the wrong scale
172
+
173
+ STL carries no units. The slicer is assuming millimetres; if the model was written in
174
+ centimetres, scale it in the document.
175
+
176
+ ### Colours are missing from the exported file
177
+
178
+ STL has none. Export parts separately and colour them in the slicer, or use the render mesh
179
+ directly if you control the viewer.
180
+
181
+ ### Hard edges look smooth in my renderer
182
+
183
+ The mesh is already expanded with per-face normals, so this is the renderer recomputing
184
+ them. Upload `mesh.normals` as given and do not call anything like
185
+ `computeVertexNormals()`.
@@ -0,0 +1,215 @@
1
+ # `GeometryNode`
2
+
3
+ The immutable, content-addressed intermediate representation. The evaluator produces these;
4
+ the kernel consumes them.
5
+
6
+ ```js
7
+ import { GeometryNode } from 'forma-dsl';
8
+
9
+ const box = GeometryNode.shape(3, 'box', { size: [10, 10, 10], center: true });
10
+ box.digest; // '3f2a…' — 32 hex characters
11
+ box.subtreeSize; // 1
12
+ ```
13
+
14
+ ---
15
+
16
+ ## Why it exists
17
+
18
+ The evaluator never calls the kernel. It produces a tree of nodes, each carrying a 128-bit
19
+ digest of its own content and its children's digests, computed once in the constructor and
20
+ never from a live walk of the subtree.
21
+
22
+ **Identity is digest identity.** Two structurally identical subtrees *are* the same node, so
23
+ a component used twenty times is evaluated once. That is the whole basis of the cache in
24
+ [`EvaluationContext`](evaluation-context.md), and the reason typing a digit into a dimension
25
+ re-solves one wall rather than the document.
26
+
27
+ The digest is computed from the IEEE bits of every number rather than from a hash seeded per
28
+ process, so it is identical in every browser and every run.
29
+
30
+ ---
31
+
32
+ ## Reference
33
+
34
+ ### Properties
35
+
36
+ | Property | Type | Description |
37
+ | --- | --- | --- |
38
+ | `dim` | `2 \| 3` | Whether this node is a cross-section or a solid |
39
+ | `kind` | `string` | What the node does — `shape3d`, `boolean`, `transform`, `extrude`, … |
40
+ | `props` | `Record<string, unknown>` | The node's own parameters |
41
+ | `children` | `readonly GeometryNode[]` | The operands |
42
+ | `digest` | `string` | 128-bit content digest, 32 lowercase hex characters |
43
+ | `subtreeSize` | `number` | How many nodes this subtree holds, counting itself |
44
+ | `isEmpty` | `boolean` | Whether it stands for no geometry at all |
45
+
46
+ Nodes are frozen. Every factory returns a new node rather than mutating one.
47
+
48
+ ### Factories
49
+
50
+ Prefer these to the constructor — they apply the algebraic simplifications that keep the tree
51
+ small.
52
+
53
+ | Factory | Description |
54
+ | --- | --- |
55
+ | `GeometryNode.empty(dim)` | A node standing for no geometry |
56
+ | `GeometryNode.shape(dim, shape, params)` | A leaf primitive — `'box'`, `'circle'`, `'cylinder'`, … |
57
+ | `GeometryNode.union(children)` | Everything added together |
58
+ | `GeometryNode.difference(children)` | The first child with the rest removed |
59
+ | `GeometryNode.intersection(children)` | Only what every child shares |
60
+ | `GeometryNode.boolean(op, children)` | The three above, by name |
61
+ | `GeometryNode.transformed(child, transform)` | A [`Transform`](values.md#transform) applied |
62
+ | `GeometryNode.unary(kind, child, props?, dim?)` | Any single-child operation |
63
+
64
+ ### Simplifications
65
+
66
+ The factories are not thin wrappers. Each collapses cases that would otherwise cost a kernel
67
+ call and a cache entry:
68
+
69
+ | Written | Becomes |
70
+ | --- | --- |
71
+ | A boolean with one surviving child | That child |
72
+ | A boolean with empty children | The empties dropped |
73
+ | A boolean with nothing left | `empty` |
74
+ | A union of several children | The children **sorted by subtree size**, so order does not change the digest |
75
+ | A transform of a transform | One node with the matrices multiplied |
76
+ | Any unary operation on `empty` | `empty` |
77
+
78
+ `difference` is never reordered: its first child is the body and the rest are cutters.
79
+
80
+ ### Caveats
81
+
82
+ - **`dim` must be 2 or 3.** Anything else throws `bad dimensionality`.
83
+ - **Booleans cannot mix dimensionality**: `cannot combine 2D and 3D geometry in a union`.
84
+ - `GeometryNode.unary` defaults its result dimensionality to the child's, which is wrong for
85
+ the operations that change it — pass `dim` explicitly for anything extrude-like.
86
+ - Two nodes with the same digest are interchangeable. Nothing stops you building the same
87
+ node twice; the cache will treat both as one.
88
+
89
+ ---
90
+
91
+ ## Usage
92
+
93
+ ### Walking a tree
94
+
95
+ ```js
96
+ function* walk(node, seen = new Set()) {
97
+ if (seen.has(node.digest)) return; // a shared subtree is visited once
98
+ seen.add(node.digest);
99
+ yield node;
100
+ for (const child of node.children) yield* walk(child, seen);
101
+ }
102
+
103
+ const result = await render(source);
104
+ const kinds = new Map();
105
+
106
+ for (const node of walk(result.parts[0].node)) {
107
+ kinds.set(node.kind, (kinds.get(node.kind) ?? 0) + 1);
108
+ }
109
+ ```
110
+
111
+ Deduplicating on `digest` is what makes the walk reflect what the kernel will actually do —
112
+ the tree is a DAG, not a tree, once sharing is taken into account.
113
+
114
+ ### Measuring how much a model shares
115
+
116
+ ```js
117
+ const { parts } = await render(source);
118
+ const root = parts[0].node;
119
+
120
+ const unique = new Set();
121
+ for (const node of walk(root)) unique.add(node.digest);
122
+
123
+ console.log(`${root.subtreeSize} nodes written, ${unique.size} distinct`);
124
+ ```
125
+
126
+ A large gap is the digest cache doing its job — eight spheres in a loop are one sphere node
127
+ and eight transforms.
128
+
129
+ ### Building geometry without the language
130
+
131
+ The node API is public, so a program can emit geometry directly:
132
+
133
+ ```js
134
+ import { GeometryNode, Transform, EvaluationContext, toBinarySTL } from 'forma-dsl';
135
+
136
+ const context = await EvaluationContext.create();
137
+
138
+ const pin = GeometryNode.shape(3, 'cylinder', {
139
+ height: 20, bottomRadius: 3, topRadius: 3, segments: 32, center: false,
140
+ });
141
+
142
+ const ring = GeometryNode.union(
143
+ Array.from({ length: 6 }, (_, i) => GeometryNode.transformed(
144
+ pin,
145
+ Transform.rotation3D([0, 0, i * 60]).concat(Transform.translation([30, 0, 0], 3)),
146
+ )),
147
+ );
148
+
149
+ const solid = await context.evaluate(ring);
150
+ await writeFile('ring.stl', toBinarySTL(solid));
151
+ context.dispose();
152
+ ```
153
+
154
+ Note that `pin` is one node used six times — the kernel builds one cylinder.
155
+
156
+ ### Checking whether an edit changed anything
157
+
158
+ ```js
159
+ const before = (await render(source, { context })).parts[0].node.digest;
160
+ const after = (await render(edited, { context })).parts[0].node.digest;
161
+
162
+ if (before === after) console.log('the edit did not change the geometry');
163
+ ```
164
+
165
+ Reformatting, renaming a local, or reordering the members of a union all leave the digest
166
+ untouched.
167
+
168
+ ---
169
+
170
+ ## Node kinds
171
+
172
+ What the evaluator emits, and what [`EvaluationContext`](evaluation-context.md) knows how to
173
+ solve:
174
+
175
+ | Kind | Children | Notes |
176
+ | --- | --- | --- |
177
+ | `empty` | 0 | No geometry |
178
+ | `shape2d` | 0 | `props.shape` is `rect`, `circle` or `polygon` |
179
+ | `shape3d` | 0 | `props.shape` is `box`, `sphere`, `cylinder`, `hull` or `mesh` |
180
+ | `boolean` | 2+ | `props.op` is `union`, `difference` or `intersection` |
181
+ | `transform` | 1 | `props.transform` is a [`Transform`](values.md#transform) |
182
+ | `extrude`, `revolve` | 1 | 2D → 3D |
183
+ | `projection` | 1 | 3D → 2D; `props.type` is `full` or `slice` |
184
+ | `offset`, `hull`, `refine`, `simplify`, `smooth`, `trim` | 1 | Refinement |
185
+
186
+ Higher-level blocks do not have their own kinds. `rounded_rect` is a `rect` inside an
187
+ `offset`; `torus` is a `circle`, a `transform` and a `revolve`; `cone` is a `cylinder`.
188
+
189
+ ---
190
+
191
+ ## Troubleshooting
192
+
193
+ ### `bad dimensionality 4`
194
+
195
+ The constructor was called with something other than `2` or `3`.
196
+
197
+ ### `cannot combine 2D and 3D geometry in a union`
198
+
199
+ A boolean was handed a mix. In a document this surfaces earlier and more helpfully as
200
+ `"union" cannot mix 2D and 3D shapes`.
201
+
202
+ ### My hand-built extrude produced a 2D result
203
+
204
+ `GeometryNode.unary` defaults `dim` to the child's, and an extrude's child is 2D. Pass it:
205
+
206
+ ```js
207
+ GeometryNode.unary('extrude', profile, { height: 10, /* … */ }, 3);
208
+ ```
209
+
210
+ ### Two nodes I expected to be equal have different digests
211
+
212
+ Digests cover **all** props. A `segments` of `0` and a `segments` of `32` are different
213
+ shapes even when they look alike, and a float that differs in the last bit is a different
214
+ number. Digest equality is exact, by design — the alternative is a cache that occasionally
215
+ returns the wrong solid.