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.
@@ -0,0 +1,232 @@
1
+ // A 128-bit content digest, computed once per node and never recomputed.
2
+ //
3
+ // It must be stable across runs, machines and browsers: two structurally identical trees
4
+ // have to produce the same digest every time, or the evaluation cache silently misses and
5
+ // a rebuilt model recomputes everything. That rules out anything seeded per process, and
6
+ // it rules out Web Crypto, which is async and cannot be called from a constructor.
7
+
8
+ const C1 = 0x239b961b, C2 = 0xab0e9789, C3 = 0x38b34ae5, C4 = 0xa1e38b93;
9
+
10
+ /**
11
+ * Rotates a 32-bit word left.
12
+ *
13
+ * @param {number} x The word.
14
+ * @param {number} r How many bits to rotate by.
15
+ * @returns {number} The rotated word.
16
+ */
17
+ function rotl(x, r) {
18
+ return (x << r) | (x >>> (32 - r));
19
+ }
20
+
21
+ /**
22
+ * Multiplies two 32-bit words, keeping the low bits.
23
+ *
24
+ * `Math.imul`, not `*`: a 32-bit product above 2^53 loses its low bits to the double, and
25
+ * those low bits are most of the mixing.
26
+ *
27
+ * @param {number} a The left operand.
28
+ * @param {number} b The right operand.
29
+ * @returns {number} The low 32 bits of the product.
30
+ */
31
+ function mul(a, b) {
32
+ return Math.imul(a, b) | 0;
33
+ }
34
+
35
+ /**
36
+ * The MurmurHash3 finalisation mix, which avalanches the remaining bits.
37
+ *
38
+ * @param {number} h A hash word.
39
+ * @returns {number} The mixed word, unsigned.
40
+ */
41
+ function fmix(h) {
42
+ h ^= h >>> 16;
43
+ h = mul(h, 0x85ebca6b);
44
+ h ^= h >>> 13;
45
+ h = mul(h, 0xc2b2ae35);
46
+ h ^= h >>> 16;
47
+ return h >>> 0;
48
+ }
49
+
50
+ /**
51
+ * Packs the bytes after the last whole block into four words, little-endian.
52
+ *
53
+ * Byte `i` of the tail belongs to word `i >> 2` at shift `(i & 3) * 8` — which is what
54
+ * MurmurHash3's fallthrough spells out one case at a time. The arithmetic is identical;
55
+ * only the shape differs, and the digests it produces are asserted to be unchanged.
56
+ *
57
+ * @param {Uint8Array} bytes The whole input.
58
+ * @param {number} tail Offset of the first trailing byte.
59
+ * @param {number} rem How many trailing bytes there are, 0 to 15.
60
+ * @returns {number[]} The four words.
61
+ */
62
+ function foldTail(bytes, tail, rem) {
63
+ const k = [0, 0, 0, 0];
64
+ for (let i = 0; i < rem; i++) {
65
+ k[i >> 2] ^= bytes[tail + i] << ((i & 3) * 8);
66
+ }
67
+ return k;
68
+ }
69
+
70
+ /**
71
+ * MurmurHash3 x86_128 over a byte array.
72
+ *
73
+ * Chosen because it is 32-bit throughout, so it runs identically everywhere without BigInt.
74
+ *
75
+ * @param {Uint8Array} bytes The bytes to digest.
76
+ * @returns {string} The digest, as 32 lowercase hex characters.
77
+ */
78
+ export function digestBytes(bytes) {
79
+ const len = bytes.length;
80
+ const blocks = len >> 4;
81
+ let h1 = 0, h2 = 0, h3 = 0, h4 = 0;
82
+
83
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
84
+
85
+ for (let i = 0; i < blocks; i++) {
86
+ const o = i << 4;
87
+ let k1 = view.getUint32(o, true);
88
+ let k2 = view.getUint32(o + 4, true);
89
+ let k3 = view.getUint32(o + 8, true);
90
+ let k4 = view.getUint32(o + 12, true);
91
+
92
+ k1 = mul(k1, C1); k1 = rotl(k1, 15); k1 = mul(k1, C2); h1 ^= k1;
93
+ h1 = rotl(h1, 19); h1 = (h1 + h2) | 0; h1 = (mul(h1, 5) + 0x561ccd1b) | 0;
94
+
95
+ k2 = mul(k2, C2); k2 = rotl(k2, 16); k2 = mul(k2, C3); h2 ^= k2;
96
+ h2 = rotl(h2, 17); h2 = (h2 + h3) | 0; h2 = (mul(h2, 5) + 0x0bcaa747) | 0;
97
+
98
+ k3 = mul(k3, C3); k3 = rotl(k3, 17); k3 = mul(k3, C4); h3 ^= k3;
99
+ h3 = rotl(h3, 15); h3 = (h3 + h4) | 0; h3 = (mul(h3, 5) + 0x96cd1c35) | 0;
100
+
101
+ k4 = mul(k4, C4); k4 = rotl(k4, 18); k4 = mul(k4, C1); h4 ^= k4;
102
+ h4 = rotl(h4, 13); h4 = (h4 + h1) | 0; h4 = (mul(h4, 5) + 0x32ac3b17) | 0;
103
+ }
104
+
105
+ // The trailing bytes that did not fill a 16-byte block, folded in a word at a time.
106
+ const tail = blocks << 4;
107
+ const rem = len & 15;
108
+ const k = foldTail(bytes, tail, rem);
109
+
110
+ // Each word is mixed only if the tail reached it: k1 from one byte, k2 from five, and so
111
+ // on. That is MurmurHash3's own fallthrough, written as the condition it encodes.
112
+ if (rem >= 1) { let x = mul(k[0], C1); x = rotl(x, 15); h1 ^= mul(x, C2); }
113
+ if (rem >= 5) { let x = mul(k[1], C2); x = rotl(x, 16); h2 ^= mul(x, C3); }
114
+ if (rem >= 9) { let x = mul(k[2], C3); x = rotl(x, 17); h3 ^= mul(x, C4); }
115
+ if (rem >= 13) { let x = mul(k[3], C4); x = rotl(x, 18); h4 ^= mul(x, C1); }
116
+
117
+ h1 ^= len; h2 ^= len; h3 ^= len; h4 ^= len;
118
+ h1 = (h1 + h2) | 0; h1 = (h1 + h3) | 0; h1 = (h1 + h4) | 0;
119
+ h2 = (h2 + h1) | 0; h3 = (h3 + h1) | 0; h4 = (h4 + h1) | 0;
120
+ h1 = fmix(h1); h2 = fmix(h2); h3 = fmix(h3); h4 = fmix(h4);
121
+ h1 = (h1 + h2) | 0; h1 = (h1 + h3) | 0; h1 = (h1 + h4) | 0;
122
+ h2 = (h2 + h1) | 0; h3 = (h3 + h1) | 0; h4 = (h4 + h1) | 0;
123
+
124
+ return [h1 >>> 0, h2 >>> 0, h3 >>> 0, h4 >>> 0]
125
+ .map((w) => w.toString(16).padStart(8, '0'))
126
+ .join('');
127
+ }
128
+
129
+ /**
130
+ * Accumulates the canonical byte encoding of a node's payload.
131
+ *
132
+ * Every method returns the writer, so an encoding reads as one chain.
133
+ *
134
+ * Numbers go in as their IEEE-754 bits rather than as text. `toString` would collapse
135
+ * values that are genuinely different (-0 and 0) and round others, so two distinct models
136
+ * could share a digest and one would be served the other's geometry from the cache.
137
+ */
138
+ export class DigestWriter {
139
+ /** @type {Uint8Array[]} The pieces written so far, joined only at the end. */
140
+ #parts = [];
141
+
142
+ /** @type {number} Total length of `#parts`, tracked to size the buffer once. */
143
+ #length = 0;
144
+
145
+ /**
146
+ * @param {Uint8Array} bytes The piece to append.
147
+ * @returns {void}
148
+ */
149
+ #push(bytes) {
150
+ this.#parts.push(bytes);
151
+ this.#length += bytes.length;
152
+ }
153
+
154
+ /**
155
+ * Writes a length-prefixed UTF-8 string, so `"ab" + "c"` cannot encode as `"a" + "bc"`.
156
+ *
157
+ * @param {string} value The string.
158
+ * @returns {this} This writer.
159
+ */
160
+ string(value) {
161
+ const bytes = new TextEncoder().encode(value);
162
+ this.int(bytes.length);
163
+ this.#push(bytes);
164
+ return this;
165
+ }
166
+
167
+ /**
168
+ * @param {number} value Coerced to a 32-bit integer.
169
+ * @returns {this} This writer.
170
+ */
171
+ int(value) {
172
+ const bytes = new Uint8Array(4);
173
+ new DataView(bytes.buffer).setInt32(0, value | 0, true);
174
+ this.#push(bytes);
175
+ return this;
176
+ }
177
+
178
+ /**
179
+ * Writes a double as its IEEE-754 bits, normalising -0 to 0 so the two agree.
180
+ *
181
+ * @param {number} value The number.
182
+ * @returns {this} This writer.
183
+ */
184
+ number(value) {
185
+ const bytes = new Uint8Array(8);
186
+ new DataView(bytes.buffer).setFloat64(0, value === 0 ? 0 : value, true);
187
+ this.#push(bytes);
188
+ return this;
189
+ }
190
+
191
+ /**
192
+ * @param {ReadonlyArray<number>} values The numbers, written length-prefixed.
193
+ * @returns {this} This writer.
194
+ */
195
+ numbers(values) {
196
+ this.int(values.length);
197
+ for (const value of values) this.number(value);
198
+ return this;
199
+ }
200
+
201
+ /**
202
+ * @param {boolean} value The flag.
203
+ * @returns {this} This writer.
204
+ */
205
+ bool(value) {
206
+ return this.int(value ? 1 : 0);
207
+ }
208
+
209
+ /**
210
+ * Folds in a child's digest instead of walking its subtree, which is what keeps digest
211
+ * computation O(1) per node rather than O(nodes) per node.
212
+ *
213
+ * @param {string} hex The child's digest.
214
+ * @returns {this} This writer.
215
+ */
216
+ digest(hex) {
217
+ return this.string(hex);
218
+ }
219
+
220
+ /**
221
+ * @returns {string} The digest of everything written, as 32 lowercase hex characters.
222
+ */
223
+ finish() {
224
+ const out = new Uint8Array(this.#length);
225
+ let offset = 0;
226
+ for (const part of this.#parts) {
227
+ out.set(part, offset);
228
+ offset += part.length;
229
+ }
230
+ return digestBytes(out);
231
+ }
232
+ }
@@ -0,0 +1,67 @@
1
+ /** @import { Kernel, QualityOptions } from '../index.js' */
2
+
3
+ import ManifoldModule from 'manifold-3d';
4
+
5
+ /**
6
+ * The single in-flight or settled load of the WASM module.
7
+ *
8
+ * @type {Promise<Kernel> | null}
9
+ */
10
+ let modulePromise = null;
11
+
12
+ /**
13
+ * Loads and sets up the Manifold WASM module, once per page or process.
14
+ *
15
+ * Every geometry call needs it, so this is memoized on the promise rather than the result:
16
+ * two models evaluating concurrently at startup must not each start an instantiation.
17
+ *
18
+ * Only the first call's options take effect, since later calls get the memoized promise.
19
+ *
20
+ * @param {{ locateFile: () => string }} [options] Passed to the module factory. A bundler
21
+ * that fingerprints the `.wasm` file needs `locateFile` to point at the built URL,
22
+ * because the module cannot find it by its own relative path.
23
+ * @returns {Promise<Kernel>} The set-up module.
24
+ */
25
+ export function loadKernel(options) {
26
+ if (!modulePromise) {
27
+ modulePromise = ManifoldModule(options).then((wasm) => {
28
+ wasm.setup();
29
+ return wasm;
30
+ }, (error) => {
31
+ // A rejected promise would be memoized along with everything else, so a transient
32
+ // failure — a WASM fetch that lost the network — would be permanent for the page.
33
+ modulePromise = null;
34
+ throw error;
35
+ });
36
+ }
37
+ return modulePromise;
38
+ }
39
+
40
+ /**
41
+ * Sets the resolution used for curved surfaces.
42
+ *
43
+ * These are global to the kernel rather than per-model, so a viewer that renders a draft
44
+ * preview and then a final pass has to set them around each evaluation, not once at load.
45
+ *
46
+ * Each option is left alone when omitted.
47
+ *
48
+ * @param {QualityOptions} [options] The resolution controls.
49
+ * @returns {Promise<void>} Resolves once the kernel has been loaded and configured.
50
+ */
51
+ export async function setQuality(options = {}) {
52
+ const { minAngle, minEdgeLength, segments } = options;
53
+ const wasm = await loadKernel();
54
+ if (minAngle !== undefined) wasm.setMinCircularAngle(minAngle);
55
+ if (minEdgeLength !== undefined) wasm.setMinCircularEdgeLength(minEdgeLength);
56
+ if (segments !== undefined) wasm.setCircularSegments(segments);
57
+ }
58
+
59
+ /**
60
+ * Restores the kernel's own curve resolution defaults.
61
+ *
62
+ * @returns {Promise<void>} Resolves once the kernel has been loaded and reset.
63
+ */
64
+ export async function resetQuality() {
65
+ const wasm = await loadKernel();
66
+ wasm.resetToCircularDefaults();
67
+ }
@@ -0,0 +1,187 @@
1
+ /** @import { BooleanOp, Dimensionality } from '../index.js' */
2
+
3
+ import { DigestWriter } from './digest.js';
4
+ import { Transform } from '../values/transform.js';
5
+
6
+ /**
7
+ * Canonically encodes an attribute value into the digest.
8
+ *
9
+ * Object keys are sorted, so two nodes built from the same attributes in a different source
10
+ * order share a digest and therefore a cache entry. Each branch writes a distinct tag byte
11
+ * first, so a string `"1"` and a number `1` cannot encode alike.
12
+ *
13
+ * @param {DigestWriter} writer The writer to append to.
14
+ * @param {unknown} value The value to encode.
15
+ * @returns {void}
16
+ */
17
+ function writeValue(writer, value) {
18
+ if (value === null || value === undefined) {
19
+ writer.string('nil');
20
+ } else if (typeof value === 'number') {
21
+ writer.string('n').number(value);
22
+ } else if (typeof value === 'boolean') {
23
+ writer.string('b').bool(value);
24
+ } else if (typeof value === 'string') {
25
+ writer.string('s').string(value);
26
+ } else if (Array.isArray(value)) {
27
+ writer.string('a').int(value.length);
28
+ for (const item of value) writeValue(writer, item);
29
+ } else if (value instanceof Transform) {
30
+ writer.string('t').numbers(value.toArray());
31
+ } else {
32
+ const keys = Object.keys(value).sort();
33
+ writer.string('o').int(keys.length);
34
+ for (const key of keys) {
35
+ writer.string(key);
36
+ writeValue(writer, value[key]);
37
+ }
38
+ }
39
+ }
40
+
41
+ /**
42
+ * An immutable node in the geometry tree, identified by its content.
43
+ *
44
+ * Identity is digest identity: two structurally identical subtrees are the same node, so a
45
+ * model that uses one component twenty times evaluates it once. The digest is computed in
46
+ * the constructor from the children's digests, never from a live walk of the subtree.
47
+ */
48
+ export class GeometryNode {
49
+ /**
50
+ * Prefer the factories below, which apply the algebraic simplifications; this is the raw
51
+ * constructor they all end at.
52
+ *
53
+ * @param {Dimensionality} dim Whether this node is 2D or 3D.
54
+ * @param {string} kind What the node does, such as `boolean` or `extrude`.
55
+ * @param {Record<string, unknown>} [props] The node's own parameters.
56
+ * @param {ReadonlyArray<GeometryNode>} [children] The operands.
57
+ * @throws {TypeError} If `dim` is neither 2 nor 3.
58
+ */
59
+ constructor(dim, kind, props = {}, children = []) {
60
+ if (dim !== 2 && dim !== 3) throw new TypeError(`bad dimensionality ${dim}`);
61
+
62
+ /** @type {Dimensionality} Whether this node is 2D or 3D. */
63
+ this.dim = dim;
64
+ /** @type {string} What the node does. */
65
+ this.kind = kind;
66
+ /** @type {Record<string, unknown>} The node's own parameters. */
67
+ this.props = props;
68
+ /** @type {ReadonlyArray<GeometryNode>} The operands. */
69
+ this.children = children;
70
+
71
+ const writer = new DigestWriter().int(dim).string(kind);
72
+ writeValue(writer, props);
73
+ writer.int(children.length);
74
+ for (const child of children) writer.digest(child.digest).int(child.dim);
75
+
76
+ /** @type {string} The 128-bit content digest, as 32 lowercase hex characters. */
77
+ this.digest = writer.finish();
78
+
79
+ /** @type {number} How many nodes this subtree contains, counting itself. */
80
+ this.subtreeSize = 1 + children.reduce((n, c) => n + c.subtreeSize, 0);
81
+
82
+ Object.freeze(this);
83
+ }
84
+
85
+ /** @returns {boolean} Whether this node stands for no geometry at all. */
86
+ get isEmpty() { return this.kind === 'empty'; }
87
+
88
+ /**
89
+ * @param {Dimensionality} dim Whether the empty node is 2D or 3D.
90
+ * @returns {GeometryNode} A node standing for no geometry.
91
+ */
92
+ static empty(dim) { return new GeometryNode(dim, 'empty'); }
93
+
94
+ /**
95
+ * Combines children with a boolean operation, dropping empties and collapsing the
96
+ * one-child case to that child.
97
+ *
98
+ * Union members are ordered by subtree size so that two unions written in a different
99
+ * order still share a digest. Difference is not reordered: its first child is the one
100
+ * everything else is cut from.
101
+ *
102
+ * @param {BooleanOp} op Which operation to apply.
103
+ * @param {ReadonlyArray<GeometryNode>} children The operands.
104
+ * @returns {GeometryNode} The combined node.
105
+ * @throws {TypeError} If the surviving children are not all the same dimensionality.
106
+ */
107
+ static boolean(op, children) {
108
+ const live = children.filter((c) => !c.isEmpty);
109
+ if (live.length === 0) return GeometryNode.empty(children[0]?.dim ?? 3);
110
+ if (live.length === 1) return live[0];
111
+ const dim = live[0].dim;
112
+ if (live.some((c) => c.dim !== dim)) {
113
+ throw new TypeError(`cannot combine 2D and 3D geometry in a ${op}`);
114
+ }
115
+ const ordered = op === 'union'
116
+ ? [...live].sort((a, b) => a.subtreeSize - b.subtreeSize || a.digest.localeCompare(b.digest))
117
+ : live;
118
+ return new GeometryNode(dim, 'boolean', { op }, ordered);
119
+ }
120
+
121
+ /**
122
+ * @param {ReadonlyArray<GeometryNode>} children The operands.
123
+ * @returns {GeometryNode} Everything added together.
124
+ */
125
+ static union(children) { return GeometryNode.boolean('union', children); }
126
+
127
+ /**
128
+ * @param {ReadonlyArray<GeometryNode>} children The first operand, then what to cut from it.
129
+ * @returns {GeometryNode} The first child with the rest removed.
130
+ */
131
+ static difference(children) { return GeometryNode.boolean('difference', children); }
132
+
133
+ /**
134
+ * @param {ReadonlyArray<GeometryNode>} children The operands.
135
+ * @returns {GeometryNode} Only what every child shares.
136
+ */
137
+ static intersection(children) { return GeometryNode.boolean('intersection', children); }
138
+
139
+ /**
140
+ * Applies a transform, collapsing a transform applied to a transform into a single matrix.
141
+ *
142
+ * A deep chain of translate blocks would otherwise be one kernel call and one cache entry
143
+ * per level.
144
+ *
145
+ * @param {GeometryNode} child What to transform.
146
+ * @param {Transform} transform The transform to apply.
147
+ * @returns {GeometryNode} The transformed node.
148
+ */
149
+ static transformed(child, transform) {
150
+ if (child.isEmpty) return child;
151
+ if (child.kind === 'transform') {
152
+ return new GeometryNode(child.dim, 'transform',
153
+ { transform: transform.concat(/** @type {Transform} */ (child.props.transform)) }, child.children);
154
+ }
155
+ return new GeometryNode(child.dim, 'transform', { transform }, [child]);
156
+ }
157
+
158
+ /**
159
+ * Wraps a single child, short-circuiting on empty geometry.
160
+ *
161
+ * @param {string} kind What the node does.
162
+ * @param {GeometryNode} child The operand.
163
+ * @param {Record<string, unknown>} [props] The operation's parameters.
164
+ * @param {Dimensionality} [dim] The result's dimensionality; defaults to the child's,
165
+ * which is wrong only for the operations that change it, such as `extrude`.
166
+ * @returns {GeometryNode} The wrapped node.
167
+ */
168
+ static unary(kind, child, props = {}, dim = child.dim) {
169
+ if (child.isEmpty) return GeometryNode.empty(dim);
170
+ return new GeometryNode(dim, kind, props, [child]);
171
+ }
172
+
173
+ /**
174
+ * @param {Dimensionality} dim Whether the shape is 2D or 3D.
175
+ * @param {string} shape Which primitive, such as `box` or `circle`.
176
+ * @param {Record<string, unknown>} params The primitive's parameters.
177
+ * @returns {GeometryNode} A leaf node.
178
+ */
179
+ static shape(dim, shape, params) {
180
+ return new GeometryNode(dim, dim === 2 ? 'shape2d' : 'shape3d', { shape, ...params });
181
+ }
182
+
183
+ /** @returns {string} A short identification, for diagnostics. */
184
+ toString() {
185
+ return `${this.kind}<${this.dim}D>#${this.digest.slice(0, 8)}`;
186
+ }
187
+ }
@@ -0,0 +1,83 @@
1
+ /** @import { IndexedMesh, RenderMesh, Solid } from '../index.js' */
2
+
3
+ /**
4
+ * A point in space, as three numbers.
5
+ *
6
+ * @typedef {[number, number, number]} Triple
7
+ */
8
+
9
+ /**
10
+ * Reads one vertex position out of the kernel's flat property array.
11
+ *
12
+ * `vertProperties` interleaves whatever properties the mesh carries, so the position is the
13
+ * first three of every `stride`, not every third entry.
14
+ *
15
+ * @param {IndexedMesh} mesh The kernel mesh.
16
+ * @param {number} index Which vertex.
17
+ * @param {number} stride How many properties each vertex carries.
18
+ * @returns {Triple} The position.
19
+ */
20
+ function vertexAt(mesh, index, stride) {
21
+ const o = index * stride;
22
+ return [mesh.vertProperties[o], mesh.vertProperties[o + 1], mesh.vertProperties[o + 2]];
23
+ }
24
+
25
+ /**
26
+ * The unit normal of a triangle, wound counter-clockwise.
27
+ *
28
+ * A degenerate triangle has no direction to point in, so it keeps the zero vector rather
29
+ * than dividing by zero and poisoning the buffer with NaN.
30
+ *
31
+ * @param {Triple} a First corner.
32
+ * @param {Triple} b Second corner.
33
+ * @param {Triple} c Third corner.
34
+ * @returns {Triple} The normal.
35
+ */
36
+ function faceNormal(a, b, c) {
37
+ const u = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
38
+ const v = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
39
+ const n = /** @type {Triple} */ ([
40
+ u[1] * v[2] - u[2] * v[1],
41
+ u[2] * v[0] - u[0] * v[2],
42
+ u[0] * v[1] - u[1] * v[0],
43
+ ]);
44
+ const length = Math.hypot(n[0], n[1], n[2]);
45
+ return length > 0 ? [n[0] / length, n[1] / length, n[2] / length] : n;
46
+ }
47
+
48
+ /**
49
+ * Converts an evaluated Manifold into flat typed arrays for a renderer.
50
+ *
51
+ * `getMesh()` returns indexed triangles sharing vertices, which is what a viewer wants for
52
+ * upload size, but shared vertices across a hard edge would be smooth-shaded. Normals are
53
+ * therefore computed per face and the vertices expanded, so a cube reads as a cube.
54
+ *
55
+ * @param {Solid} manifold The solid to convert.
56
+ * @returns {RenderMesh} Expanded positions and normals, three vertices per triangle.
57
+ */
58
+ export function toRenderMesh(manifold) {
59
+ const mesh = manifold.getMesh();
60
+ const stride = mesh.numProp;
61
+ const triangles = mesh.triVerts.length / 3;
62
+
63
+ const positions = new Float32Array(triangles * 9);
64
+ const normals = new Float32Array(triangles * 9);
65
+
66
+ for (let t = 0; t < triangles; t++) {
67
+ const corners = [
68
+ vertexAt(mesh, mesh.triVerts[t * 3], stride),
69
+ vertexAt(mesh, mesh.triVerts[t * 3 + 1], stride),
70
+ vertexAt(mesh, mesh.triVerts[t * 3 + 2], stride),
71
+ ];
72
+ const normal = faceNormal(corners[0], corners[1], corners[2]);
73
+
74
+ // All three corners carry the same normal; that repetition is what keeps the edge hard.
75
+ for (const [i, corner] of corners.entries()) {
76
+ const o = t * 9 + i * 3;
77
+ positions[o] = corner[0]; positions[o + 1] = corner[1]; positions[o + 2] = corner[2];
78
+ normals[o] = normal[0]; normals[o + 1] = normal[1]; normals[o + 2] = normal[2];
79
+ }
80
+ }
81
+
82
+ return { positions, normals, triangleCount: triangles, vertexCount: mesh.numVert };
83
+ }
@@ -0,0 +1,49 @@
1
+ /** @import { Solid } from '../index.js' */
2
+
3
+ import { toRenderMesh } from './mesh.js';
4
+
5
+ // The binary STL layout, named rather than spelled as arithmetic at every offset.
6
+ const HEADER_BYTES = 80;
7
+ const COUNT_BYTES = 4;
8
+ const BODY_START = HEADER_BYTES + COUNT_BYTES;
9
+ /** A normal, three vertices, and a trailing attribute count: 12 floats and a uint16. */
10
+ const TRIANGLE_BYTES = 50;
11
+
12
+ /**
13
+ * Encodes a solid as binary STL.
14
+ *
15
+ * Binary rather than ASCII because a model of any size is several times smaller and every
16
+ * slicer reads it.
17
+ *
18
+ * @param {Solid} manifold The solid to encode.
19
+ * @param {string} [header] Free text for the 80-byte header; truncated to fit, so it cannot
20
+ * run into the triangle count that follows it.
21
+ * @returns {Uint8Array} The STL file.
22
+ */
23
+ export function toBinarySTL(manifold, header = 'forma-dsl') {
24
+ const { positions, normals, triangleCount } = toRenderMesh(manifold);
25
+ const buffer = new ArrayBuffer(BODY_START + triangleCount * TRIANGLE_BYTES);
26
+ const view = new DataView(buffer);
27
+ const bytes = new Uint8Array(buffer);
28
+
29
+ bytes.set(new TextEncoder().encode(header.slice(0, HEADER_BYTES - 1)), 0);
30
+ view.setUint32(HEADER_BYTES, triangleCount, true);
31
+
32
+ for (let t = 0; t < triangleCount; t++) {
33
+ const o = BODY_START + t * TRIANGLE_BYTES;
34
+
35
+ for (let axis = 0; axis < 3; axis++) {
36
+ view.setFloat32(o + axis * 4, normals[t * 9 + axis], true);
37
+ }
38
+ for (let v = 0; v < 3; v++) {
39
+ for (let axis = 0; axis < 3; axis++) {
40
+ view.setFloat32(o + 12 + v * 12 + axis * 4, positions[t * 9 + v * 3 + axis], true);
41
+ }
42
+ }
43
+
44
+ // The attribute byte count. Nothing reads it, and slicers dislike it being anything else.
45
+ view.setUint16(o + 48, 0, true);
46
+ }
47
+
48
+ return new Uint8Array(buffer);
49
+ }