forma-dsl 0.1.1
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 +127 -0
- package/package.json +56 -0
- package/src/core/context.js +328 -0
- package/src/core/digest.js +232 -0
- package/src/core/kernel.js +67 -0
- package/src/core/node.js +187 -0
- package/src/export/mesh.js +83 -0
- package/src/export/stl.js +49 -0
- package/src/index.d.ts +406 -0
- package/src/index.js +102 -0
- package/src/lang/ast.js +226 -0
- package/src/lang/builtins.js +562 -0
- package/src/lang/evaluate.js +937 -0
- package/src/lang/lexer.js +359 -0
- package/src/lang/parser.js +522 -0
- package/src/values/angle.js +94 -0
- package/src/values/transform.js +159 -0
- package/src/values/vector.js +169 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 kalpak44
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# forma-dsl
|
|
2
|
+
|
|
3
|
+
[](https://github.com/kalpak44/forma-dsl/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/forma-dsl)
|
|
5
|
+
|
|
6
|
+
A declarative DSL for 3D modeling and scene composition, with reusable components and live
|
|
7
|
+
previews.
|
|
8
|
+
|
|
9
|
+
Models are written as text — blocks, attributes and expressions in a Terraform-flavored
|
|
10
|
+
syntax — parsed and solved by the [Manifold](https://github.com/elalish/manifold) CSG kernel
|
|
11
|
+
compiled to WebAssembly. It runs unchanged in Node and in the browser.
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install forma-dsl
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
```js
|
|
18
|
+
import { render, toBinarySTL, EvaluationContext } from 'forma-dsl';
|
|
19
|
+
import { writeFile } from 'node:fs/promises';
|
|
20
|
+
|
|
21
|
+
const source = `
|
|
22
|
+
param height { type = number default = 20 min = 8 max = 40 }
|
|
23
|
+
model "riser" {
|
|
24
|
+
extrude {
|
|
25
|
+
height = var.height
|
|
26
|
+
rounded_rect { size = [40, 20] radius = 4 center = true }
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
`;
|
|
30
|
+
|
|
31
|
+
// One context, reused: the digest cache is what makes the second render of a changed
|
|
32
|
+
// document cheap, and a fresh context starts empty every time.
|
|
33
|
+
const context = await EvaluationContext.create();
|
|
34
|
+
|
|
35
|
+
for (const height of [10, 20, 30]) {
|
|
36
|
+
const result = await render(source, { params: { height }, context });
|
|
37
|
+
await writeFile(`part-${height}.stl`, toBinarySTL(result.parts[0].concrete));
|
|
38
|
+
// Free what this tree can no longer reach, so a long run stays bounded.
|
|
39
|
+
context.collect(result.parts.map((part) => part.node));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
context.dispose();
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## The language in one screen
|
|
46
|
+
|
|
47
|
+
```hcl
|
|
48
|
+
param height { type = number default = 20 min = 8 max = 40 }
|
|
49
|
+
|
|
50
|
+
local sizes = [1.5, 2, 2.5, 3, 4, 5, 6]
|
|
51
|
+
|
|
52
|
+
model "hex_key_holder" {
|
|
53
|
+
part "body" {
|
|
54
|
+
color = "#6f7d8c"
|
|
55
|
+
|
|
56
|
+
difference {
|
|
57
|
+
align {
|
|
58
|
+
x = "center" y = "center"
|
|
59
|
+
extrude {
|
|
60
|
+
height = var.height
|
|
61
|
+
rounded_rect { size = [62, 13] radius = 6.5 center = true }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
for i, size in sizes {
|
|
66
|
+
translate {
|
|
67
|
+
offset = [(i - (len(sizes) - 1) / 2) * 8, 0, 2.5]
|
|
68
|
+
extrude {
|
|
69
|
+
height = var.height
|
|
70
|
+
regular_polygon { sides = 6 width_across_flats = size + 0.3 }
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Geometry is composed by **nesting blocks**: a block's children are its operands, and several
|
|
80
|
+
children in a row are implicitly union. Angles are in degrees, everywhere.
|
|
81
|
+
|
|
82
|
+
## API
|
|
83
|
+
|
|
84
|
+
| Export | What it does |
|
|
85
|
+
| --- | --- |
|
|
86
|
+
| `render(source, options?)` | Compile a document and evaluate one model into renderable parts |
|
|
87
|
+
| `describeParameters(source)` | Read a document's declared inputs, without rendering |
|
|
88
|
+
| `EvaluationContext` | The digest cache, and the owner of every WASM object |
|
|
89
|
+
| `GeometryNode` | The immutable, content-addressed geometry IR |
|
|
90
|
+
| `toRenderMesh` · `toBinarySTL` | Buffers for a renderer, or a file for a slicer |
|
|
91
|
+
| `Vector` · `Angle` · `Transform` | The value types the evaluator works in |
|
|
92
|
+
| `loadKernel` · `setQuality` | Loading and tuning the Manifold module |
|
|
93
|
+
| `Program` · `Evaluator` · `parse` · `tokenize` | The compiler stages, for tools |
|
|
94
|
+
| `BLOCKS` · `FUNCTIONS` · `CONSTANTS` | The registries, so an editor need not restate them |
|
|
95
|
+
|
|
96
|
+
Hand-written TypeScript declarations ship with it, so the API is typed from either language.
|
|
97
|
+
|
|
98
|
+
## Documentation
|
|
99
|
+
|
|
100
|
+
**[The full reference](https://kalpak44.github.io/forma-dsl/docs/)** — every declaration,
|
|
101
|
+
block, function and error, each with usage and troubleshooting. The
|
|
102
|
+
**[editor](https://kalpak44.github.io/forma-dsl/)** runs the whole thing in the browser:
|
|
103
|
+
type, and the solid updates.
|
|
104
|
+
|
|
105
|
+
## How it works
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
source ──▶ lexer ──▶ parser ──▶ evaluator ──▶ geometry nodes ──▶ Manifold (WASM) ──▶ mesh
|
|
109
|
+
│ │
|
|
110
|
+
params digest cache
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The evaluator does not call the kernel. It produces a tree of immutable geometry nodes, each
|
|
114
|
+
carrying a 128-bit digest of its own content and its children's digests. Identity is digest
|
|
115
|
+
identity, so a component used twenty times is one node, evaluated once. Nodes are the only
|
|
116
|
+
thing that reaches the kernel, and every result is memoized by digest — which is what makes
|
|
117
|
+
an edit cheap, since only the subtrees whose content actually changed are re-evaluated.
|
|
118
|
+
|
|
119
|
+
WebAssembly objects are not garbage-collected, so an `EvaluationContext` owns everything it
|
|
120
|
+
produced. `collect(roots)` frees what the current tree can no longer reach; `dispose()` frees
|
|
121
|
+
the lot.
|
|
122
|
+
|
|
123
|
+
One runtime dependency, `manifold-3d`.
|
|
124
|
+
|
|
125
|
+
## License
|
|
126
|
+
|
|
127
|
+
MIT — see [LICENSE](LICENSE).
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "forma-dsl",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "A declarative DSL for 3D modeling and scene composition, reusable components, and live previews.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"cad",
|
|
7
|
+
"csg",
|
|
8
|
+
"3d",
|
|
9
|
+
"dsl",
|
|
10
|
+
"parametric",
|
|
11
|
+
"manifold",
|
|
12
|
+
"stl",
|
|
13
|
+
"geometry",
|
|
14
|
+
"webassembly"
|
|
15
|
+
],
|
|
16
|
+
"homepage": "https://github.com/kalpak44/forma-dsl#readme",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/kalpak44/forma-dsl.git",
|
|
20
|
+
"directory": "packages/forma-dsl"
|
|
21
|
+
},
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/kalpak44/forma-dsl/issues"
|
|
24
|
+
},
|
|
25
|
+
"author": "Pavel Usanli",
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"type": "module",
|
|
28
|
+
"main": "./src/index.js",
|
|
29
|
+
"types": "./src/index.d.ts",
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"types": "./src/index.d.ts",
|
|
33
|
+
"default": "./src/index.js"
|
|
34
|
+
},
|
|
35
|
+
"./package.json": "./package.json"
|
|
36
|
+
},
|
|
37
|
+
"sideEffects": false,
|
|
38
|
+
"files": [
|
|
39
|
+
"src"
|
|
40
|
+
],
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">=20.19.0"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"test": "node --test",
|
|
46
|
+
"test:coverage": "node --test --experimental-test-coverage",
|
|
47
|
+
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.src.json",
|
|
48
|
+
"check:package": "node scripts/check-package.mjs"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"manifold-3d": "^3.5.4"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"typescript": "^7.0.2"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/** @import { CollectOptions, ContextStats, Kernel, Shape, Solid } from '../index.js' */
|
|
2
|
+
/** @import { GeometryNode } from './node.js' */
|
|
3
|
+
|
|
4
|
+
import { loadKernel } from './kernel.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* How many unreachable entries `collect` keeps.
|
|
8
|
+
*
|
|
9
|
+
* Editing a model churns the tree — a keystroke changes one subtree and leaves its siblings
|
|
10
|
+
* untouched — so the entries that just fell out of the tree are the ones most likely to come
|
|
11
|
+
* straight back, on an undo or on the next character of a number being typed.
|
|
12
|
+
*/
|
|
13
|
+
const DEFAULT_RETAINED = 512;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* How each single-child node is solved, once its child has been evaluated.
|
|
17
|
+
*
|
|
18
|
+
* A table rather than eight near-identical switch arms, so adding an operation is one line
|
|
19
|
+
* and the shape of the dispatch cannot drift between them.
|
|
20
|
+
*
|
|
21
|
+
* @type {Record<string, (child: any, props: any, wasm: Kernel) => Solid | Shape>}
|
|
22
|
+
*/
|
|
23
|
+
const UNARY = {
|
|
24
|
+
transform: (child, p) => child.transform(p.transform.toArray()),
|
|
25
|
+
hull: (child) => child.hull(),
|
|
26
|
+
refine: (child, p) => child.refineToLength(p.edgeLength),
|
|
27
|
+
simplify: (child, p) => child.simplify(p.tolerance),
|
|
28
|
+
smooth: (child, p) => child.smoothOut(p.minSharpAngle, p.minSmoothness),
|
|
29
|
+
trim: (child, p) => child.trimByPlane(p.normal, p.offset),
|
|
30
|
+
offset: (child, p) => child.offset(p.amount, p.joinType, p.miterLimit, p.segments),
|
|
31
|
+
projection: (child, p) => (p.type === 'slice' ? child.slice(p.z) : child.project()),
|
|
32
|
+
|
|
33
|
+
// A degenerate extrusion or revolution is an empty solid rather than an error: it falls
|
|
34
|
+
// out of a parameter reaching the end of its range, which a model should survive.
|
|
35
|
+
extrude: (child, p, { Manifold }) => (p.height <= 0
|
|
36
|
+
? Manifold.cube([0, 0, 0])
|
|
37
|
+
: child.extrude(p.height, p.divisions, p.twist, p.scaleTop, p.center)),
|
|
38
|
+
revolve: (child, p, { Manifold }) => (p.degrees <= 0
|
|
39
|
+
? Manifold.cube([0, 0, 0])
|
|
40
|
+
: child.revolve(p.segments, p.degrees)),
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Evaluates a geometry tree against the Manifold kernel, memoized by node digest.
|
|
45
|
+
*
|
|
46
|
+
* The cache owns every WASM object it holds. Nothing is freed as evaluation proceeds,
|
|
47
|
+
* because an intermediate result is also a cache entry and a later node may still need it.
|
|
48
|
+
* WASM objects are not garbage-collected, so the two ways out are {@link EvaluationContext#collect}, which
|
|
49
|
+
* frees everything the current tree can no longer reach, and {@link EvaluationContext#dispose}, which frees
|
|
50
|
+
* the lot.
|
|
51
|
+
*
|
|
52
|
+
* A context is meant to outlive a single render. Reusing one across renders is what makes
|
|
53
|
+
* the digest cache worth having: a re-render after an edit re-evaluates only the subtrees
|
|
54
|
+
* whose content actually changed.
|
|
55
|
+
*/
|
|
56
|
+
export class EvaluationContext {
|
|
57
|
+
/** @type {Map<string, { value: Solid | Shape, used: number }>} Digest to cached object. */
|
|
58
|
+
#cache = new Map();
|
|
59
|
+
|
|
60
|
+
/** @type {Map<string, Promise<Solid | Shape>>} Evaluations started but not yet settled. */
|
|
61
|
+
#inFlight = new Map();
|
|
62
|
+
|
|
63
|
+
/** @type {Kernel | null} The loaded module; null until `create` resolves. */
|
|
64
|
+
#wasm = null;
|
|
65
|
+
|
|
66
|
+
/** @type {number} Monotonic counter standing in for time, to order cache entries by use. */
|
|
67
|
+
#clock = 0;
|
|
68
|
+
|
|
69
|
+
/** @type {boolean} Whether `dispose` has run. */
|
|
70
|
+
#disposed = false;
|
|
71
|
+
|
|
72
|
+
/** @type {ContextStats} Running totals, for the editor's status line and for tests. */
|
|
73
|
+
stats = { evaluated: 0, hits: 0, freed: 0 };
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The only way to build a context, because the kernel loads asynchronously.
|
|
77
|
+
*
|
|
78
|
+
* @returns {Promise<EvaluationContext>} A context with the kernel ready.
|
|
79
|
+
*/
|
|
80
|
+
static async create() {
|
|
81
|
+
const context = new EvaluationContext();
|
|
82
|
+
context.#wasm = await loadKernel();
|
|
83
|
+
return context;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @returns {Kernel} The loaded module.
|
|
88
|
+
* @throws {Error} If the context was built without waiting for {@link EvaluationContext.create}.
|
|
89
|
+
*/
|
|
90
|
+
get wasm() {
|
|
91
|
+
if (!this.#wasm) throw new Error('EvaluationContext used before create() resolved');
|
|
92
|
+
return this.#wasm;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** @returns {number} How many objects the cache currently holds. */
|
|
96
|
+
get size() { return this.#cache.size; }
|
|
97
|
+
|
|
98
|
+
/** @returns {boolean} Whether this context has been disposed. */
|
|
99
|
+
get disposed() { return this.#disposed; }
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Resolves to the kernel object for a node.
|
|
103
|
+
*
|
|
104
|
+
* Concurrent requests for the same node share one evaluation — without the in-flight map,
|
|
105
|
+
* a shape used twice in a tree that is walked concurrently would be built twice and one
|
|
106
|
+
* copy leaked.
|
|
107
|
+
*
|
|
108
|
+
* @param {GeometryNode} node The node to solve.
|
|
109
|
+
* @returns {Promise<Solid | Shape>} The kernel object, owned by this context.
|
|
110
|
+
* @throws {Error} If the context has been disposed.
|
|
111
|
+
*/
|
|
112
|
+
async evaluate(node) {
|
|
113
|
+
if (this.#disposed) throw new Error('EvaluationContext has been disposed');
|
|
114
|
+
|
|
115
|
+
const cached = this.#cache.get(node.digest);
|
|
116
|
+
if (cached) {
|
|
117
|
+
this.stats.hits++;
|
|
118
|
+
cached.used = ++this.#clock;
|
|
119
|
+
return cached.value;
|
|
120
|
+
}
|
|
121
|
+
const pending = this.#inFlight.get(node.digest);
|
|
122
|
+
if (pending) {
|
|
123
|
+
this.stats.hits++;
|
|
124
|
+
return pending;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const promise = this.#evaluateUncached(node).then((result) => {
|
|
128
|
+
this.#inFlight.delete(node.digest);
|
|
129
|
+
// Disposal can land while this was in flight. The result is a live WASM object that
|
|
130
|
+
// nothing will ever look at again, so free it here rather than putting it into a
|
|
131
|
+
// cache that has already been emptied.
|
|
132
|
+
if (this.#disposed) {
|
|
133
|
+
free(result);
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
136
|
+
this.#cache.set(node.digest, { value: result, used: ++this.#clock });
|
|
137
|
+
this.stats.evaluated++;
|
|
138
|
+
return result;
|
|
139
|
+
}, (error) => {
|
|
140
|
+
this.#inFlight.delete(node.digest);
|
|
141
|
+
throw error;
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
this.#inFlight.set(node.digest, promise);
|
|
145
|
+
return promise;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Frees every cached object the given roots can no longer reach, keeping the most
|
|
150
|
+
* recently used `keep` of them as a tail.
|
|
151
|
+
*
|
|
152
|
+
* Called after a render rather than during one: an unreachable entry is only safe to free
|
|
153
|
+
* once nothing holds the object, and mid-render the caller still might.
|
|
154
|
+
*
|
|
155
|
+
* @param {ReadonlyArray<GeometryNode>} roots The trees still in use.
|
|
156
|
+
* @param {CollectOptions} [options] How much of the unreachable tail to keep.
|
|
157
|
+
* @returns {number} How many objects were freed.
|
|
158
|
+
*/
|
|
159
|
+
collect(roots, options = {}) {
|
|
160
|
+
const { keep = DEFAULT_RETAINED } = options;
|
|
161
|
+
if (this.#disposed) return 0;
|
|
162
|
+
|
|
163
|
+
const reachable = this.#reachableDigests(roots);
|
|
164
|
+
|
|
165
|
+
/** @type {Array<[string, number]>} */
|
|
166
|
+
const evictable = [];
|
|
167
|
+
for (const [digest, entry] of this.#cache) {
|
|
168
|
+
// An in-flight digest has no cache entry yet, so nothing here can race one.
|
|
169
|
+
if (!reachable.has(digest)) evictable.push([digest, entry.used]);
|
|
170
|
+
}
|
|
171
|
+
if (evictable.length <= keep) return 0;
|
|
172
|
+
|
|
173
|
+
// Most recently used first, so the tail that survives is the tail worth surviving.
|
|
174
|
+
evictable.sort((a, b) => b[1] - a[1]);
|
|
175
|
+
let freed = 0;
|
|
176
|
+
for (const [digest] of evictable.slice(keep)) {
|
|
177
|
+
free(this.#cache.get(digest).value);
|
|
178
|
+
this.#cache.delete(digest);
|
|
179
|
+
freed++;
|
|
180
|
+
}
|
|
181
|
+
this.stats.freed += freed;
|
|
182
|
+
return freed;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Walks the given trees iteratively, so a deeply nested model cannot overflow the stack,
|
|
187
|
+
* and stops at digests already seen, so a shared subtree is visited once.
|
|
188
|
+
*
|
|
189
|
+
* @param {ReadonlyArray<GeometryNode>} roots The trees to walk.
|
|
190
|
+
* @returns {Set<string>} Every digest reachable from the roots.
|
|
191
|
+
*/
|
|
192
|
+
#reachableDigests(roots) {
|
|
193
|
+
const reachable = new Set();
|
|
194
|
+
const stack = [...roots];
|
|
195
|
+
while (stack.length) {
|
|
196
|
+
const node = stack.pop();
|
|
197
|
+
if (!node || reachable.has(node.digest)) continue;
|
|
198
|
+
reachable.add(node.digest);
|
|
199
|
+
for (const child of node.children) stack.push(child);
|
|
200
|
+
}
|
|
201
|
+
return reachable;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* @param {GeometryNode} node The node whose children to solve.
|
|
206
|
+
* @returns {Promise<Array<Solid | Shape>>} The children, in order, solved concurrently.
|
|
207
|
+
*/
|
|
208
|
+
async #children(node) {
|
|
209
|
+
return Promise.all(node.children.map((child) => this.evaluate(child)));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Solves one node, assuming its result is not already cached.
|
|
214
|
+
*
|
|
215
|
+
* @param {GeometryNode} node The node to solve.
|
|
216
|
+
* @returns {Promise<Solid | Shape>} The kernel object.
|
|
217
|
+
* @throws {Error} If the node's kind or boolean operation is not one this can solve.
|
|
218
|
+
*/
|
|
219
|
+
async #evaluateUncached(node) {
|
|
220
|
+
const { Manifold, CrossSection } = this.wasm;
|
|
221
|
+
const props = node.props;
|
|
222
|
+
|
|
223
|
+
switch (node.kind) {
|
|
224
|
+
case 'empty':
|
|
225
|
+
return node.dim === 2 ? CrossSection.square([0, 0]) : Manifold.cube([0, 0, 0]);
|
|
226
|
+
|
|
227
|
+
case 'boolean': {
|
|
228
|
+
const parts = await this.#children(node);
|
|
229
|
+
// Both classes carry the same three statics; the union of two constructors is not
|
|
230
|
+
// callable as one, so the choice is made here rather than duplicating the switch.
|
|
231
|
+
const Concrete = /** @type {any} */ (node.dim === 2 ? CrossSection : Manifold);
|
|
232
|
+
switch (props.op) {
|
|
233
|
+
case 'union': return Concrete.union(parts);
|
|
234
|
+
case 'difference': return Concrete.difference(parts);
|
|
235
|
+
case 'intersection': return Concrete.intersection(parts);
|
|
236
|
+
default: throw new Error(`unknown boolean operation ${props.op}`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
case 'shape2d':
|
|
241
|
+
return this.#shape2D(props);
|
|
242
|
+
|
|
243
|
+
case 'shape3d':
|
|
244
|
+
return this.#shape3D(props);
|
|
245
|
+
|
|
246
|
+
default: {
|
|
247
|
+
const solve = UNARY[node.kind];
|
|
248
|
+
if (!solve) throw new Error(`cannot evaluate node kind ${node.kind}`);
|
|
249
|
+
const [child] = await this.#children(node);
|
|
250
|
+
return solve(child, props, this.wasm);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* @param {Record<string, any>} props The shape's parameters, including its `shape` name.
|
|
257
|
+
* @returns {Shape} The cross-section.
|
|
258
|
+
* @throws {Error} If the shape name is not one the kernel builds directly.
|
|
259
|
+
*/
|
|
260
|
+
#shape2D(props) {
|
|
261
|
+
const { CrossSection } = this.wasm;
|
|
262
|
+
switch (props.shape) {
|
|
263
|
+
case 'rect':
|
|
264
|
+
return CrossSection.square(props.size, props.center);
|
|
265
|
+
case 'circle':
|
|
266
|
+
return CrossSection.circle(props.radius, props.segments);
|
|
267
|
+
case 'polygon':
|
|
268
|
+
return CrossSection.ofPolygons(props.contours, props.fillRule);
|
|
269
|
+
default:
|
|
270
|
+
throw new Error(`unknown 2D shape ${props.shape}`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* @param {Record<string, any>} props The shape's parameters, including its `shape` name.
|
|
276
|
+
* @returns {Solid} The solid.
|
|
277
|
+
* @throws {Error} If the shape name is not one the kernel builds directly.
|
|
278
|
+
*/
|
|
279
|
+
#shape3D(props) {
|
|
280
|
+
const { Manifold } = this.wasm;
|
|
281
|
+
switch (props.shape) {
|
|
282
|
+
case 'box':
|
|
283
|
+
return Manifold.cube(props.size, props.center);
|
|
284
|
+
case 'sphere':
|
|
285
|
+
return Manifold.sphere(props.radius, props.segments);
|
|
286
|
+
case 'cylinder':
|
|
287
|
+
return Manifold.cylinder(
|
|
288
|
+
props.height, props.bottomRadius, props.topRadius, props.segments, props.center,
|
|
289
|
+
);
|
|
290
|
+
case 'hull':
|
|
291
|
+
return Manifold.hull(props.points);
|
|
292
|
+
case 'mesh':
|
|
293
|
+
return Manifold.ofMesh(new this.wasm.Mesh(props.mesh));
|
|
294
|
+
default:
|
|
295
|
+
throw new Error(`unknown 3D shape ${props.shape}`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Frees every WASM object this context has produced.
|
|
301
|
+
*
|
|
302
|
+
* The context cannot be used again; evaluations still in flight free their own results as
|
|
303
|
+
* they land. Disposing twice is harmless.
|
|
304
|
+
*
|
|
305
|
+
* @returns {void}
|
|
306
|
+
*/
|
|
307
|
+
dispose() {
|
|
308
|
+
if (this.#disposed) return;
|
|
309
|
+
this.#disposed = true;
|
|
310
|
+
for (const entry of this.#cache.values()) free(entry.value);
|
|
311
|
+
this.#cache.clear();
|
|
312
|
+
this.#inFlight.clear();
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Releases a kernel object, tolerating one the kernel has already taken back.
|
|
318
|
+
*
|
|
319
|
+
* @param {{ delete?: () => void } | null | undefined} value The object to free.
|
|
320
|
+
* @returns {void}
|
|
321
|
+
*/
|
|
322
|
+
function free(value) {
|
|
323
|
+
try {
|
|
324
|
+
value?.delete();
|
|
325
|
+
} catch {
|
|
326
|
+
// Already freed by the kernel, or never owned one — either way there is nothing to do.
|
|
327
|
+
}
|
|
328
|
+
}
|