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.
- package/LICENSE +21 -0
- package/README.md +97 -0
- package/package.json +52 -0
- package/src/bin.js +30 -0
- package/src/catalogue.js +703 -0
- package/src/document.js +285 -0
- package/src/examples.js +212 -0
- package/src/guide.js +313 -0
- package/src/reference/README.md +153 -0
- package/src/reference/api/describe-parameters.md +167 -0
- package/src/reference/api/evaluation-context.md +221 -0
- package/src/reference/api/export.md +185 -0
- package/src/reference/api/geometry-node.md +215 -0
- package/src/reference/api/kernel.md +182 -0
- package/src/reference/api/low-level.md +239 -0
- package/src/reference/api/render.md +217 -0
- package/src/reference/api/values.md +223 -0
- package/src/reference/errors.md +253 -0
- package/src/reference/language/control-flow.md +160 -0
- package/src/reference/language/expressions.md +206 -0
- package/src/reference/language/overview.md +156 -0
- package/src/reference/language/scope.md +184 -0
- package/src/reference/language/syntax.md +210 -0
- package/src/reference/reference/align.md +189 -0
- package/src/reference/reference/booleans.md +231 -0
- package/src/reference/reference/component.md +248 -0
- package/src/reference/reference/conversions.md +269 -0
- package/src/reference/reference/functions.md +314 -0
- package/src/reference/reference/local.md +197 -0
- package/src/reference/reference/model.md +213 -0
- package/src/reference/reference/param.md +230 -0
- package/src/reference/reference/part.md +229 -0
- package/src/reference/reference/refinement.md +284 -0
- package/src/reference/reference/shapes-2d.md +309 -0
- package/src/reference/reference/shapes-3d.md +279 -0
- package/src/reference/reference/transforms.md +246 -0
- package/src/reference.js +160 -0
- package/src/server.js +219 -0
- package/src/tools.js +523 -0
- 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.
|