@es3d/es3d 1.0.1 → 1.1.0

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,1295 @@
1
+ // OpenSCAD import. The language is the OpenSCAD User Manual (the 2021.01
2
+ // manual; the same primitives in current builds). Positional parameter order
3
+ // is the order Parameters::parse lists them in the OpenSCAD sources
4
+ // (src/core/primitives.cc, LinearExtrudeNode.cc, RotateExtrudeNode.cc,
5
+ // TransformNode.cc, ColorNode.cc, ImportNode.cc, control.cc), checked against
6
+ // the master branch. cylinder's positional parameters are h, r1, r2, center;
7
+ // r and d are named. An omitted r2 stays 1, it is not copied from r1. A named
8
+ // r sets both radii. linear_extrude's positional parameters are height, v,
9
+ // scale, center, twist, slices, segments, and the default height is 100.
10
+ //
11
+ // The file is read, not sent to the OpenSCAD binary: a browser build cannot
12
+ // shell out, and the value of the format is the solids it names. cube, sphere,
13
+ // cylinder, polygon, polyhedron, linear_extrude, rotate_extrude, modules,
14
+ // functions, for, if and the affine transforms are evaluated. union is a group.
15
+ // difference, intersection, hull, minkowski and offset have no ES3D equivalent;
16
+ // their children are kept and the notes say the operation was not applied.
17
+ // A flat difference (the usual hole in an extrusion) is written as holes in
18
+ // the first outline.
19
+ //
20
+ // OpenSCAD is Z-up. Every solid is then mapped with a −90° rotation about X,
21
+ // (x, y, z) → (x, z, −y), which is the inverse of the root rotate([90, 0, 0])
22
+ // the exporter writes. A cylinder along Z becomes a cylinder along Y.
23
+ // Trigonometry in expressions is in degrees, as OpenSCAD specifies.
24
+ // $fn sets segments. A curve with $fn 0 stays an exact ES3D solid.
25
+
26
+ import * as mat4 from '../math/mat4.js';
27
+ import { matrixToTransformString } from '../transform/transform.js';
28
+ import { colorToShortHex, parseColor } from '../format/colors.js';
29
+ import { n } from './text-encode.js';
30
+ import { parseSTL, weldTriangles } from './stl.js';
31
+
32
+ const UNDEF = Symbol('undef');
33
+ const RX90 = (() => { const m = mat4.createF64(); mat4.fromEulerDeg(m, 90, 0, 0); return m; })();
34
+ const RXM90 = (() => { const m = mat4.createF64(); mat4.fromEulerDeg(m, -90, 0, 0); return m; })();
35
+
36
+ /**
37
+ * @param {string} text
38
+ * @param {{files?: {name: string, bytes: Uint8Array}[]}} [opts]
39
+ * @returns {{text: string, notes: string[]}}
40
+ */
41
+ export function importOpenSCAD(text, { files = [] } = {}) {
42
+ const tally = new Map();
43
+ const add = (key, k = 1) => tally.set(key, (tally.get(key) || 0) + k);
44
+ const missing = new Set();
45
+ const unknown = new Set();
46
+ const lib = new Map();
47
+ for (const f of files) lib.set(baseName(f.name), f);
48
+
49
+ const prog = parseProgram(text);
50
+ const root = newScope(null);
51
+ root.vars.set('$fn', 0);
52
+ root.vars.set('$fa', 12);
53
+ root.vars.set('$fs', 2);
54
+ root.vars.set('PI', Math.PI);
55
+ const leaves = [];
56
+ const env = { add, missing, unknown, lib, stack: [], only: [], onlySeen: false };
57
+ execute(prog.body, root, leaves, env);
58
+ const produced = env.onlySeen ? env.only : leaves;
59
+ const flats = produced.filter((leaf) => leaf.dim === 2).length;
60
+ if (flats) add('flat', flats);
61
+
62
+ const notes = [];
63
+ const say = (key, one, many) => {
64
+ const k = tally.get(key);
65
+ if (k) notes.push(`${k.toLocaleString('en-US')} ${k === 1 ? one : many}`);
66
+ };
67
+ say('difference', 'difference was not subtracted; its solids are all in the scene.', 'differences were not subtracted; their solids are all in the scene.');
68
+ say('intersection', 'intersection was not clipped; its solids are all in the scene.', 'intersections were not clipped; their solids are all in the scene.');
69
+ say('hull', 'hull was not computed; the children are in the scene instead.', 'hulls were not computed; the children are in the scene instead.');
70
+ say('minkowski', 'minkowski sum was not computed; the children are in the scene instead.', 'minkowski sums were not computed; the children are in the scene instead.');
71
+ say('offset', 'offset was not applied.', 'offsets were not applied.');
72
+ say('resize', 'resize was not applied.', 'resizes were not applied.');
73
+ say('projection', 'projection was left as the original solid.', 'projections were left as the original solids.');
74
+ say('flat-diff', 'flat difference was written as holes in the first outline, without clipping overlaps.', 'flat differences were written as holes in the first outline, without clipping overlaps.');
75
+ say('twist', 'extrusion dropped a twist or taper.', 'extrusions dropped a twist or taper.');
76
+ say('direction', 'extrusion did not run along +Z; it was given that length along Z.', 'extrusions did not run along +Z; they were given that length along Z.');
77
+ say('sweep', 'rotate_extrude with a negative angle was swept the other way.', 'rotate_extrude calls with a negative angle were swept the other way.');
78
+ say('flat', 'flat shape was imported as a surface.', 'flat shapes were imported as surfaces.');
79
+ say('disabled', 'object was disabled with * or %.', 'objects were disabled with * or %.');
80
+ say('only', 'show-only subtree was kept and the rest of the file was left out.', 'show-only subtrees were kept and the rest of the file was left out.');
81
+ say('text', 'text() was omitted (there is no font).', 'text() calls were omitted (there is no font).');
82
+ say('import-skip', 'import() was omitted (only STL companions are read).', 'import() calls were omitted (only STL companions are read).');
83
+ if (missing.size) notes.push(`Missing included file${missing.size === 1 ? '' : 's'}: ${[...missing].join(', ')}.`);
84
+ if (unknown.size) notes.push(`Unknown module${unknown.size === 1 ? '' : 's'}: ${[...unknown].join(', ')}.`);
85
+
86
+ const markup = emitDocument(produced.filter((leaf) => leaf.dim === 3 || leaf.dim === 2));
87
+ return { text: markup, notes };
88
+ }
89
+
90
+ function baseName(name) {
91
+ return String(name).replace(/\\/g, '/').split('/').pop();
92
+ }
93
+
94
+ function emitDocument(leaves) {
95
+ const solids = [];
96
+ for (const leaf of leaves) {
97
+ const solid = leaf.dim === 2 ? promote2D(leaf) : leaf;
98
+ if (!solid) continue;
99
+ const matrix = toEs(solid);
100
+ solids.push({ solid, matrix, color: leaf.color });
101
+ }
102
+ const counts = new Map();
103
+ for (const s of solids) {
104
+ const key = JSON.stringify(s.solid.shape);
105
+ counts.set(key, (counts.get(key) || 0) + 1);
106
+ }
107
+ const defs = new Map();
108
+ let seq = 0;
109
+ const lines = [];
110
+ const defLines = [];
111
+ for (const s of solids) {
112
+ const key = JSON.stringify(s.solid.shape);
113
+ const attrs = {};
114
+ if (s.color) attrs.color = s.color;
115
+ const transform = matrixToTransformString(s.matrix, (v) => n(v));
116
+ if (transform) attrs.transform = transform;
117
+ if (counts.get(key) > 1) {
118
+ if (!defs.has(key)) {
119
+ const id = `s${seq++}`;
120
+ defs.set(key, id);
121
+ defLines.push(shapeTag(s.solid.shape, { id }));
122
+ }
123
+ attrs.ref = defs.get(key);
124
+ lines.push(shapeTag(null, attrs, 'use'));
125
+ } else lines.push(shapeTag(s.solid.shape, attrs));
126
+ }
127
+ const body = [];
128
+ if (defLines.length) body.push(`<defs>\n${defLines.map((l) => ' ' + l).join('\n')}\n</defs>`);
129
+ body.push(...lines);
130
+ return `<es3d version="1">\n${body.map((l) => ' ' + l).join('\n')}\n</es3d>\n`;
131
+ }
132
+
133
+ function promote2D(leaf) {
134
+ if (leaf.kind === 'square') {
135
+ return { dim: 3, matrix: leaf.matrix, shape: { kind: 'plane', width: leaf.w, depth: leaf.h }, color: leaf.color, axis: 'y' };
136
+ }
137
+ const baked = bakeProfile([leaf], leaf.matrix);
138
+ if (!baked) return null;
139
+ return { dim: 3, matrix: leaf.matrix, shape: { kind: 'path', d: baked.d, fillRule: baked.evenodd ? 'evenodd' : '' }, color: leaf.color, axis: 'z' };
140
+ }
141
+
142
+ /** OpenSCAD Z-up matrix to an ES3D matrix. Cylinders and planes also swap their local axis. */
143
+ function toEs(solid) {
144
+ const local = solid.axis === 'y' ? RX90 : mat4.createF64();
145
+ return mul(mul(RXM90, solid.matrix), local);
146
+ }
147
+
148
+ function shapeTag(shape, attrs, name) {
149
+ const a = { ...attrs };
150
+ if (shape) Object.assign(a, shapeAttrs(shape));
151
+ const body = Object.entries(a).filter(([, v]) => v !== '' && v != null).map(([k, v]) => `${k}="${esc(v)}"`).join(' ');
152
+ return `<${name || shape.kind}${body ? ' ' + body : ''}/>`;
153
+ }
154
+
155
+ function shapeAttrs(shape) {
156
+ const seg = (v) => (v ? String(v) : '');
157
+ if (shape.kind === 'box') return { size: shape.size.map((v) => n(v)).join(' ') };
158
+ if (shape.kind === 'sphere') return { radius: n(shape.radius), segments: seg(shape.segments) };
159
+ if (shape.kind === 'cylinder') {
160
+ const a = { radius: n(shape.radius), height: n(shape.height), segments: seg(shape.segments) };
161
+ if (Math.abs(shape.radiusTop - shape.radius) > 1e-9) a.radiusTop = n(shape.radiusTop);
162
+ return a;
163
+ }
164
+ if (shape.kind === 'cone') return { radius: n(shape.radius), height: n(shape.height), segments: seg(shape.segments) };
165
+ if (shape.kind === 'plane') return { size: `${n(shape.width)} ${n(shape.depth)}` };
166
+ if (shape.kind === 'torus') return { radius: n(shape.radius), tube: n(shape.tube), arc: shape.arc < 360 - 1e-6 ? n(shape.arc) : '', segments: seg(shape.segments), tubeSegments: seg(shape.tubeSegments) };
167
+ if (shape.kind === 'extrude') return { d: shape.d, depth: n(shape.depth), center: shape.center ? 'true' : '', fillRule: shape.fillRule || '' };
168
+ if (shape.kind === 'revolve') return { d: shape.d, angle: shape.angle < 360 - 1e-6 ? n(shape.angle) : '', segments: seg(shape.segments), fillRule: shape.fillRule || '' };
169
+ if (shape.kind === 'path') return { d: shape.d, fillRule: shape.fillRule || '' };
170
+ if (shape.kind === 'polygon') return { points: shape.points.map((v) => n(v)).join(' ') };
171
+ if (shape.kind === 'mesh') return { positions: shape.positions.map((v) => n(v)).join(' '), indices: shape.indices.join(' ') };
172
+ return {};
173
+ }
174
+
175
+ function esc(v) {
176
+ return String(v).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
177
+ }
178
+
179
+ // --- execution ----------------------------------------------------------------
180
+
181
+ function execute(stmts, scope, out, env) {
182
+ for (const stmt of stmts) {
183
+ if (stmt.t === 'module') scope.modules.set(stmt.name, stmt);
184
+ else if (stmt.t === 'function') scope.functions.set(stmt.name, stmt);
185
+ }
186
+ for (const stmt of stmts) {
187
+ if (stmt.t === 'module' || stmt.t === 'function') continue;
188
+ evalStmt(stmt, scope, out, env);
189
+ }
190
+ }
191
+
192
+ function evalStmt(stmt, scope, out, env) {
193
+ if (!stmt) return;
194
+ if (stmt.t === 'block') { execute(stmt.body, scope, out, env); return; }
195
+ if (stmt.t === 'assign') { scope.vars.set(stmt.name, evalExpr(stmt.expr, scope, env)); return; }
196
+ if (stmt.t === 'module') { scope.modules.set(stmt.name, stmt); return; }
197
+ if (stmt.t === 'function') { scope.functions.set(stmt.name, stmt); return; }
198
+ if (stmt.t === 'include') { includeFile(stmt, scope, out, env, stmt.kind === 'use'); return; }
199
+ if (stmt.t === 'for') { evalFor(stmt, scope, out, env); return; }
200
+ if (stmt.t === 'let') { evalLet(stmt, scope, out, env); return; }
201
+ if (stmt.t === 'if') {
202
+ if (truthy(evalExpr(stmt.cond, scope, env))) evalStmt(stmt.then, scope, out, env);
203
+ else if (stmt.else) evalStmt(stmt.else, scope, out, env);
204
+ return;
205
+ }
206
+ if (stmt.t === 'call') evalCall(stmt, scope, out, env);
207
+ }
208
+
209
+ function evalFor(stmt, scope, out, env) {
210
+ const lists = stmt.bindings.map((b) => iterable(evalExpr(b.expr, scope, env)));
211
+ const total = lists.reduce((n, list) => n * list.length, 1);
212
+ if (total > 100000) throw new Error('An OpenSCAD for-loop expands past 100000 copies.');
213
+ const rec = (i) => {
214
+ if (i === lists.length) { evalStmt(stmt.body, scope, out, env); return; }
215
+ const name = stmt.bindings[i].name;
216
+ for (const value of lists[i]) {
217
+ scope.vars.set(name, value);
218
+ rec(i + 1);
219
+ }
220
+ };
221
+ rec(0);
222
+ }
223
+
224
+ function evalLet(stmt, scope, out, env) {
225
+ const inner = childScope(scope);
226
+ for (const b of stmt.bindings) inner.vars.set(b.name, evalExpr(b.expr, inner, env));
227
+ evalStmt(stmt.body, inner, out, env);
228
+ }
229
+
230
+ function evalCall(stmt, scope, out, env) {
231
+ if (stmt.mod === '*' || stmt.mod === '%') { env.add('disabled'); return; }
232
+ const values = bindArgs(stmt.args, scope, env);
233
+ const args = mapParams(stmt.name, values, scope);
234
+ const inner = childScope(scope);
235
+ for (const [name, value] of Object.entries(args)) if (name.startsWith('$')) inner.vars.set(name, value);
236
+ inner.children = childList(stmt.child);
237
+ const bucket = stmt.mod === '!' ? [] : out;
238
+ builtin(stmt.name, args, stmt.child, inner, bucket, env);
239
+ if (stmt.mod === '!') {
240
+ env.onlySeen = true;
241
+ env.only.push(...bucket);
242
+ env.add('only');
243
+ }
244
+ }
245
+
246
+ function builtin(name, args, child, scope, out, env) {
247
+ const at = (m) => { const s = childScope(scope); s.matrix = m; return s; };
248
+ if (name === 'union' || name === 'group' || name === 'render') { evalStmt(child, scope, out, env); return; }
249
+ if (name === 'difference' || name === 'intersection') {
250
+ const parts = [];
251
+ evalStmt(child, scope, parts, env);
252
+ if (name === 'difference' && parts.length && parts.every((p) => p.dim === 2)) {
253
+ env.add('flat-diff');
254
+ const baked = bakeProfile(parts, scope.matrix);
255
+ if (baked) out.push({ dim: 2, kind: 'poly', matrix: scope.matrix, loops: baked.loops || [], d: baked.d, evenodd: true, color: scope.color });
256
+ return;
257
+ }
258
+ env.add(name);
259
+ out.push(...parts);
260
+ return;
261
+ }
262
+ if (name === 'hull' || name === 'minkowski' || name === 'offset' || name === 'resize' || name === 'projection') {
263
+ env.add(name);
264
+ evalStmt(child, scope, out, env);
265
+ return;
266
+ }
267
+ if (name === 'translate') { evalStmt(child, at(mul(scope.matrix, translation(...vec3(arg(args, 'v', [0, 0, 0]))))), out, env); return; }
268
+ if (name === 'rotate') { evalStmt(child, at(mul(scope.matrix, rotationOf(args))), out, env); return; }
269
+ if (name === 'scale') { evalStmt(child, at(mul(scope.matrix, scaleOf(arg(args, 'v', 1)))), out, env); return; }
270
+ if (name === 'mirror') { evalStmt(child, at(mul(scope.matrix, mirrorOf(vec3(arg(args, 'v', [0, 0, 1]))))), out, env); return; }
271
+ if (name === 'multmatrix') { evalStmt(child, at(mul(scope.matrix, matrixOf(arg(args, 'm', null)))), out, env); return; }
272
+ if (name === 'color') { const s = childScope(scope); s.color = colorOf(args) || scope.color; evalStmt(child, s, out, env); return; }
273
+ if (name === 'linear_extrude') { extrude(args, child, scope, out, env); return; }
274
+ if (name === 'rotate_extrude') { revolve(args, child, scope, out, env); return; }
275
+ if (name === 'cube') { cube(args, scope, out); return; }
276
+ if (name === 'sphere') { sphere(args, scope, out); return; }
277
+ if (name === 'cylinder') { cylinder(args, scope, out); return; }
278
+ if (name === 'square') { square(args, scope, out); return; }
279
+ if (name === 'circle') { circle(args, scope, out); return; }
280
+ if (name === 'polygon') { polygon(args, scope, out); return; }
281
+ if (name === 'polyhedron') { polyhedron(args, scope, out); return; }
282
+ if (name === 'text') { env.add('text'); return; }
283
+ if (name === 'import') { importMesh(args, scope, out, env); return; }
284
+ if (name === 'echo' || name === 'assert') { if (child) evalStmt(child, scope, out, env); return; }
285
+ if (name === 'children') { childrenOf(args, scope, out, env); return; }
286
+ const mod = lookupMod(scope, name);
287
+ if (!mod) { if (!BUILTIN_SKIP.has(name)) env.unknown.add(name); return; }
288
+ if (env.stack.length > 64) throw new Error(`OpenSCAD module ${name} recurses more than 64 deep.`);
289
+ env.stack.push(name);
290
+ const inner = childScope(scope);
291
+ for (const p of mod.params) {
292
+ const value = args[p.name] !== undefined ? args[p.name] : (p.def ? evalExpr(p.def, scope, env) : UNDEF);
293
+ inner.vars.set(p.name, value);
294
+ }
295
+ inner.children = childList(child);
296
+ evalStmt(mod.body, inner, out, env);
297
+ env.stack.pop();
298
+ }
299
+
300
+ const BUILTIN_SKIP = new Set(['cube', 'sphere', 'cylinder', 'square', 'circle', 'polygon', 'polyhedron', 'union', 'difference', 'intersection', 'translate', 'rotate', 'scale', 'mirror', 'multmatrix', 'color', 'linear_extrude', 'rotate_extrude', 'children', 'group', 'render', 'hull', 'minkowski', 'offset', 'resize', 'projection', 'text', 'import', 'echo', 'assert']);
301
+
302
+ function cube(args, scope, out) {
303
+ let size = arg(args, 'size', [1, 1, 1]);
304
+ if (typeof size === 'number') size = [size, size, size];
305
+ size = vec3(size);
306
+ if (size.some((v) => !(v > 0))) return;
307
+ const center = truthy(args.center);
308
+ const m = center ? scope.matrix : mul(scope.matrix, translation(size[0] / 2, size[1] / 2, size[2] / 2));
309
+ out.push({ dim: 3, matrix: m, shape: { kind: 'box', size }, color: scope.color, axis: 'z' });
310
+ }
311
+
312
+ function sphere(args, scope, out) {
313
+ const found = lookupRadius(args, 'd', 'r');
314
+ const r = found === undefined ? 1 : found;
315
+ if (!(r > 0)) return;
316
+ const fn = fragments(r, scope);
317
+ out.push({ dim: 3, matrix: scope.matrix, shape: { kind: 'sphere', radius: r, segments: explicitFn(scope) ? fn : 0 }, color: scope.color, axis: 'z' });
318
+ }
319
+
320
+ function cylinder(args, scope, out) {
321
+ const h = typeof args.h === 'number' && Number.isFinite(args.h) ? args.h : 1;
322
+ const r = lookupRadius(args, 'd', 'r');
323
+ const bottomNamed = lookupRadius(args, 'd1', 'r1');
324
+ const topNamed = lookupRadius(args, 'd2', 'r2');
325
+ let r1 = 1;
326
+ let r2 = 1;
327
+ if (r !== undefined) { r1 = r; r2 = r; }
328
+ if (bottomNamed !== undefined) r1 = bottomNamed;
329
+ if (topNamed !== undefined) r2 = topNamed;
330
+ if (!(h > 0) || r1 < 0 || r2 < 0 || (r1 === 0 && r2 === 0)) return;
331
+ const center = truthy(args.center);
332
+ const m = center ? scope.matrix : mul(scope.matrix, translation(0, 0, h / 2));
333
+ const fn = explicitFn(scope) ? fragments(Math.max(r1, r2), scope) : 0;
334
+ if (r1 === 0) {
335
+ out.push({ dim: 3, matrix: mul(m, rotationXYZ(180, 0, 0)), shape: { kind: 'cone', radius: r2, height: h, segments: fn }, color: scope.color, axis: 'y' });
336
+ return;
337
+ }
338
+ if (r2 === 0) {
339
+ out.push({ dim: 3, matrix: m, shape: { kind: 'cone', radius: r1, height: h, segments: fn }, color: scope.color, axis: 'y' });
340
+ return;
341
+ }
342
+ out.push({ dim: 3, matrix: m, shape: { kind: 'cylinder', radius: r1, radiusTop: r2, height: h, segments: fn }, color: scope.color, axis: 'y' });
343
+ }
344
+
345
+ function square(args, scope, out) {
346
+ let size = arg(args, 'size', [1, 1]);
347
+ if (typeof size === 'number') size = [size, size];
348
+ const w = num(size[0], 1), h = num(size[1], w);
349
+ if (!(w > 0) || !(h > 0)) return;
350
+ const center = truthy(args.center);
351
+ const m = center ? scope.matrix : mul(scope.matrix, translation(w / 2, h / 2, 0));
352
+ out.push({ dim: 2, kind: 'square', w, h, matrix: m, color: scope.color });
353
+ }
354
+
355
+ function circle(args, scope, out) {
356
+ const found = lookupRadius(args, 'd', 'r');
357
+ const r = found === undefined ? 1 : found;
358
+ if (!(r > 0)) return;
359
+ out.push({ dim: 2, kind: 'circle', r, fn: fragments(r, scope), matrix: scope.matrix, color: scope.color });
360
+ }
361
+
362
+ function polygon(args, scope, out) {
363
+ const points = asList(arg(args, 'points', []));
364
+ const paths = args.paths !== undefined ? asList(args.paths).map(asList) : null;
365
+ const loops = [];
366
+ const use = paths || [points.map((_, i) => i)];
367
+ for (const path of use) {
368
+ const loop = [];
369
+ for (const index of path) {
370
+ const p = points[index | 0];
371
+ if (Array.isArray(p)) loop.push(num(p[0], 0), num(p[1], 0));
372
+ }
373
+ if (loop.length >= 6) loops.push(loop);
374
+ }
375
+ if (!loops.length) return;
376
+ out.push({ dim: 2, kind: 'poly', loops, matrix: scope.matrix, color: scope.color, evenodd: loops.length > 1 });
377
+ }
378
+
379
+ function polyhedron(args, scope, out) {
380
+ const pts = asList(arg(args, 'points', []));
381
+ const faces = asList(args.faces !== undefined ? args.faces : args.triangles || []);
382
+ const positions = [];
383
+ for (const p of pts) positions.push(num(p[0], 0), num(p[1], 0), num(p[2], 0));
384
+ const indices = [];
385
+ for (const face of faces) {
386
+ const idx = asList(face).map((v) => v | 0);
387
+ if (idx.length < 3) continue;
388
+ const rev = idx.slice().reverse();
389
+ for (let i = 1; i < rev.length - 1; i++) indices.push(rev[0], rev[i], rev[i + 1]);
390
+ }
391
+ if (indices.length < 3) return;
392
+ out.push({ dim: 3, matrix: scope.matrix, shape: { kind: 'mesh', positions, indices }, color: scope.color, axis: 'z' });
393
+ }
394
+
395
+ function extrude(args, child, scope, out, env) {
396
+ const height = extrusionLength(args, env);
397
+ const center = truthy(args.center);
398
+ const twist = num(args.twist, 0);
399
+ const scale = args.scale === undefined || args.scale === UNDEF ? 1 : args.scale;
400
+ const scaleFlat = typeof scale === 'number' ? Math.abs(scale - 1) < 1e-9 : Array.isArray(scale) && scale.every((v) => Math.abs(num(v, 1) - 1) < 1e-9);
401
+ if (Math.abs(twist) > 1e-9 || !scaleFlat) env.add('twist');
402
+ const parts = [];
403
+ evalStmt(child, scope, parts, env);
404
+ const flat = parts.filter((p) => p.dim === 2);
405
+ if (!flat.length || !(height > 0)) return;
406
+ if (flat.length === 1 && flat[0].kind === 'circle') {
407
+ const c = circleFrame(flat[0], scope.matrix);
408
+ if (c) {
409
+ const m = mul(scope.matrix, translation(c.x, c.y, center ? 0 : height / 2));
410
+ out.push({ dim: 3, matrix: m, shape: { kind: 'cylinder', radius: c.r, radiusTop: c.r, height, segments: explicitFn(scope) ? flat[0].fn : 0 }, color: scope.color, axis: 'y' });
411
+ return;
412
+ }
413
+ }
414
+ if (flat.length === 1 && flat[0].kind === 'square') {
415
+ const s = flat[0];
416
+ const rel = relative(scope.matrix, s.matrix);
417
+ if (planar(rel)) {
418
+ const o = apply(rel, 0, 0, 0);
419
+ const m = mul(scope.matrix, translation(o[0], o[1], center ? 0 : height / 2));
420
+ out.push({ dim: 3, matrix: m, shape: { kind: 'box', size: [s.w, s.h, height] }, color: scope.color, axis: 'z' });
421
+ return;
422
+ }
423
+ }
424
+ const baked = bakeProfile(flat, scope.matrix);
425
+ if (!baked) return;
426
+ out.push({ dim: 3, matrix: scope.matrix, shape: { kind: 'extrude', d: baked.d, depth: height, center, fillRule: baked.evenodd ? 'evenodd' : '' }, color: scope.color, axis: 'z' });
427
+ }
428
+
429
+ /** OpenSCAD's linear_extrude height vector: v sets the direction, height its length, and a negative Z is dropped. */
430
+ function extrusionLength(args, env) {
431
+ const hasV = Array.isArray(args.v);
432
+ const hasH = typeof args.height === 'number' && Number.isFinite(args.height);
433
+ let dir = hasV ? [num(args.v[0], 0), num(args.v[1], 0), num(args.v[2], 0)] : [0, 0, 1];
434
+ let scalar = hasV ? 1 : 100;
435
+ if (hasH) {
436
+ scalar = args.height;
437
+ const l = Math.hypot(dir[0], dir[1], dir[2]);
438
+ if (l > 1e-12) dir = dir.map((c) => c / l);
439
+ }
440
+ const vec = dir.map((c) => c * scalar);
441
+ if (vec[2] < 0) vec[2] = 0;
442
+ if (Math.abs(vec[0]) > 1e-8 || Math.abs(vec[1]) > 1e-8) env.add('direction');
443
+ return Math.hypot(vec[0], vec[1], vec[2]);
444
+ }
445
+
446
+ function revolve(args, child, scope, out, env) {
447
+ let angle = 360;
448
+ let start = 180;
449
+ if (typeof args.angle === 'number' && Number.isFinite(args.angle)) {
450
+ angle = args.angle;
451
+ start = 0;
452
+ if (angle <= -360 || angle > 360) angle = 360;
453
+ }
454
+ if (typeof args.start === 'number' && Number.isFinite(args.start)) start = args.start;
455
+ if (angle < 0 && angle > -360) env.add('sweep');
456
+ angle = Math.min(360, Math.abs(angle));
457
+ if (angle < 1e-9) return;
458
+ const turned = angle < 360 - 1e-6 && Math.abs(start) > 1e-9;
459
+ const frame = turned ? mul(scope.matrix, rotationXYZ(0, 0, start)) : scope.matrix;
460
+ const parts = [];
461
+ evalStmt(child, scope, parts, env);
462
+ const flat = parts.filter((p) => p.dim === 2);
463
+ if (flat.length === 1 && flat[0].kind === 'circle' && angle >= 360 - 1e-6) {
464
+ const c = circleFrame(flat[0], scope.matrix);
465
+ if (c && Math.abs(c.y) < 1e-6 && c.x > c.r) {
466
+ out.push({ dim: 3, matrix: frame, shape: { kind: 'torus', radius: c.x, tube: c.r, arc: 360, segments: explicitFn(scope) ? fragments(c.x, scope) : 0, tubeSegments: explicitFn(scope) ? flat[0].fn : 0 }, color: scope.color, axis: 'y' });
467
+ return;
468
+ }
469
+ }
470
+ const baked = bakeProfile(flat, scope.matrix);
471
+ if (!baked) return;
472
+ out.push({ dim: 3, matrix: frame, shape: { kind: 'revolve', d: baked.d, angle, segments: explicitFn(scope) ? fragments(1, scope) : 0, fillRule: baked.evenodd ? 'evenodd' : '' }, color: scope.color, axis: 'y' });
473
+ }
474
+
475
+ function importMesh(args, scope, out, env) {
476
+ const file = String(arg(args, 'file', ''));
477
+ const hit = env.lib.get(baseName(file));
478
+ if (!hit || !/\.stl$/i.test(hit.name)) { env.add('import-skip'); return; }
479
+ const parsed = parseSTL(hit.bytes);
480
+ const welded = weldTriangles(parsed.positions);
481
+ out.push({ dim: 3, matrix: scope.matrix, shape: { kind: 'mesh', positions: Array.from(welded.positions), indices: Array.from(welded.indices) }, color: scope.color, axis: 'z' });
482
+ }
483
+
484
+ function childrenOf(args, scope, out, env) {
485
+ const list = scope.children || [];
486
+ const index = args.index !== undefined ? args.index : args[0];
487
+ const pick = index === undefined ? list.map((_, i) => i) : iterable(index);
488
+ for (const i of pick) if (list[i]) evalStmt(list[i], scope, out, env);
489
+ }
490
+
491
+ function includeFile(stmt, scope, out, env, onlyDefs) {
492
+ const hit = env.lib.get(baseName(stmt.file));
493
+ if (!hit) { env.missing.add(stmt.file); return; }
494
+ if (env.stack.includes(stmt.file)) return;
495
+ env.stack.push(stmt.file);
496
+ const text = new TextDecoder('utf-8').decode(hit.bytes);
497
+ const prog = parseProgram(text);
498
+ const body = onlyDefs ? prog.body.filter((s) => s.t === 'module' || s.t === 'function') : prog.body;
499
+ execute(body, scope, out, env);
500
+ env.stack.pop();
501
+ }
502
+
503
+ function childList(stmt) {
504
+ if (!stmt) return [];
505
+ if (stmt.t === 'block') return stmt.body;
506
+ return [stmt];
507
+ }
508
+
509
+ function lookupMod(scope, name) {
510
+ let s = scope;
511
+ while (s) {
512
+ if (s.modules.has(name)) return s.modules.get(name);
513
+ s = s.parent;
514
+ }
515
+ return null;
516
+ }
517
+
518
+ // --- 2D profiles --------------------------------------------------------------
519
+
520
+ function bakeProfile(parts, frame) {
521
+ const subs = [];
522
+ let evenodd = false;
523
+ for (const part of parts) {
524
+ if (part.evenodd) evenodd = true;
525
+ if (part.kind === 'circle') {
526
+ const c = circleFrame(part, frame);
527
+ if (!c) continue;
528
+ subs.push(circlePath(c.x, c.y, c.r));
529
+ } else if (part.kind === 'square') {
530
+ const rel = relative(frame, part.matrix);
531
+ const w = part.w / 2, h = part.h / 2;
532
+ const corners = [[-w, -h], [w, -h], [w, h], [-w, h]].map(([x, y]) => apply(rel, x, y, 0));
533
+ subs.push(loopPath(corners.map((p) => [p[0], p[1]])));
534
+ } else if (part.kind === 'poly') {
535
+ if (part.d && sameMatrix(part.matrix, frame)) subs.push(part.d);
536
+ else {
537
+ const rel = relative(frame, part.matrix);
538
+ for (const loop of part.loops || []) {
539
+ const pts = [];
540
+ for (let i = 0; i < loop.length; i += 2) {
541
+ const p = apply(rel, loop[i], loop[i + 1], 0);
542
+ pts.push([p[0], p[1]]);
543
+ }
544
+ subs.push(loopPath(pts));
545
+ }
546
+ if (part.evenodd) evenodd = true;
547
+ }
548
+ }
549
+ }
550
+ if (!subs.length) return null;
551
+ return { d: subs.join(' '), evenodd: evenodd || subs.length > 1 && parts.length > 1, loops: null };
552
+ }
553
+
554
+ function circleFrame(node, frame) {
555
+ const rel = relative(frame, node.matrix);
556
+ if (!planar(rel)) return null;
557
+ const c = apply(rel, 0, 0, 0);
558
+ const px = apply(rel, node.r, 0, 0);
559
+ const py = apply(rel, 0, node.r, 0);
560
+ const rx = Math.hypot(px[0] - c[0], px[1] - c[1]);
561
+ const ry = Math.hypot(py[0] - c[0], py[1] - c[1]);
562
+ if (!(rx > 1e-9) || Math.abs(rx - ry) > 1e-4 * rx) return null;
563
+ return { x: c[0], y: c[1], r: rx };
564
+ }
565
+
566
+ function circlePath(x, y, r) {
567
+ return `M ${n(x + r)} ${n(y)} A ${n(r)} ${n(r)} 0 1 1 ${n(x - r)} ${n(y)} A ${n(r)} ${n(r)} 0 1 1 ${n(x + r)} ${n(y)} Z`;
568
+ }
569
+
570
+ function loopPath(pts) {
571
+ if (pts.length < 3) return '';
572
+ let d = `M ${n(pts[0][0])} ${n(pts[0][1])}`;
573
+ for (let i = 1; i < pts.length; i++) d += ` L ${n(pts[i][0])} ${n(pts[i][1])}`;
574
+ return d + ' Z';
575
+ }
576
+
577
+ function planar(m) {
578
+ const z = apply(m, 0, 0, 1);
579
+ const o = apply(m, 0, 0, 0);
580
+ return Math.abs(z[0] - o[0]) < 1e-6 && Math.abs(z[1] - o[1]) < 1e-6 && Math.abs(o[2]) < 1e-6 && Math.abs(z[2] - o[2] - 1) < 1e-4 || Math.abs(z[2] - o[2]) > 0.5 && Math.abs(o[2]) < 1e-6 && Math.abs(z[0] - o[0]) < 1e-6 && Math.abs(z[1] - o[1]) < 1e-6;
581
+ }
582
+
583
+ function sameMatrix(a, b) {
584
+ for (let i = 0; i < 16; i++) if (Math.abs(a[i] - b[i]) > 1e-8) return false;
585
+ return true;
586
+ }
587
+
588
+ // --- values -------------------------------------------------------------------
589
+
590
+ function newScope(parent) {
591
+ return { parent, vars: new Map(), modules: new Map(), functions: new Map(), matrix: mat4.createF64(), color: null, children: [] };
592
+ }
593
+
594
+ function childScope(parent) {
595
+ const s = newScope(parent);
596
+ s.matrix = parent.matrix;
597
+ s.color = parent.color;
598
+ s.children = parent.children;
599
+ return s;
600
+ }
601
+
602
+ function bindArgs(args, scope, env) {
603
+ const named = {};
604
+ const positional = [];
605
+ for (const a of args) {
606
+ const value = evalExpr(a.expr, scope, env);
607
+ if (a.name) named[a.name] = value;
608
+ else positional.push(value);
609
+ }
610
+ named['#n'] = positional.length;
611
+ for (let i = 0; i < positional.length; i++) named['#' + i] = positional[i];
612
+ return named;
613
+ }
614
+
615
+ function mapParams(name, values, scope) {
616
+ const out = {};
617
+ for (const [k, v] of Object.entries(values)) if (!k.startsWith('#')) out[k] = v;
618
+ const order = PARAMS[name] || (lookupMod(scope, name) ? lookupMod(scope, name).params.map((p) => p.name) : null);
619
+ const count = values['#n'] || 0;
620
+ if (order) {
621
+ let p = 0;
622
+ for (let i = 0; i < count; i++) {
623
+ while (p < order.length && out[order[p]] !== undefined) p++;
624
+ if (p < order.length) out[order[p++]] = values['#' + i];
625
+ }
626
+ }
627
+ if (name === 'linear_extrude' && out.height === undefined && out.h !== undefined) out.height = out.h;
628
+ if (name === 'rotate_extrude' && out.angle === undefined && out.a !== undefined) out.angle = out.a;
629
+ return out;
630
+ }
631
+
632
+ function arg(args, name, fallback) {
633
+ if (args[name] !== undefined && args[name] !== UNDEF) return args[name];
634
+ return fallback;
635
+ }
636
+
637
+ function lookupRadius(args, dName, rName) {
638
+ if (typeof args[dName] === 'number' && Number.isFinite(args[dName])) return args[dName] / 2;
639
+ if (typeof args[rName] === 'number' && Number.isFinite(args[rName])) return args[rName];
640
+ return undefined;
641
+ }
642
+
643
+ function explicitFn(scope) {
644
+ const fn = lookup(scope, '$fn');
645
+ return typeof fn === 'number' && fn > 0;
646
+ }
647
+
648
+ function fragments(r, scope) {
649
+ const fn = num(lookup(scope, '$fn'), 0);
650
+ if (fn > 0) return Math.max(3, Math.min(512, Math.round(fn)));
651
+ const fa = num(lookup(scope, '$fa'), 12) || 12;
652
+ const fs = num(lookup(scope, '$fs'), 2) || 2;
653
+ return Math.max(5, Math.min(512, Math.ceil(Math.max(Math.min(360 / fa, (Math.abs(r) * 2 * Math.PI) / fs), 5))));
654
+ }
655
+
656
+ function colorOf(args) {
657
+ const c = args.c !== undefined ? args.c : args['#0'];
658
+ const alpha = args.alpha !== undefined ? num(args.alpha, 1) : null;
659
+ let rgba = null;
660
+ if (typeof c === 'string') rgba = parseColor(c);
661
+ else if (Array.isArray(c)) {
662
+ rgba = [num(c[0], 0), num(c[1], 0), num(c[2], 0), c.length > 3 ? num(c[3], 1) : 1];
663
+ }
664
+ if (!rgba) return null;
665
+ if (alpha != null) rgba[3] = alpha;
666
+ return colorToShortHex(rgba);
667
+ }
668
+
669
+ function rotationOf(args) {
670
+ const a = args.a !== undefined ? args.a : args['#0'];
671
+ const v = args.v;
672
+ if (Array.isArray(a)) return rotationXYZ(num(a[0], 0), num(a[1], 0), num(a[2], 0));
673
+ const deg = num(a, 0);
674
+ const axis = v ? vec3(v) : [0, 0, 1];
675
+ const m = mat4.createF64();
676
+ const rad = deg * Math.PI / 180;
677
+ const l = Math.hypot(axis[0], axis[1], axis[2]) || 1;
678
+ const x = axis[0] / l, y = axis[1] / l, z = axis[2] / l;
679
+ const s = Math.sin(rad), c = Math.cos(rad), t = 1 - c;
680
+ m[0] = t * x * x + c; m[4] = t * x * y - s * z; m[8] = t * x * z + s * y;
681
+ m[1] = t * x * y + s * z; m[5] = t * y * y + c; m[9] = t * y * z - s * x;
682
+ m[2] = t * x * z - s * y; m[6] = t * y * z + s * x; m[10] = t * z * z + c;
683
+ return m;
684
+ }
685
+
686
+ function rotationXYZ(x, y, z) {
687
+ const m = mat4.createF64();
688
+ mat4.fromEulerDeg(m, x, y, z);
689
+ return m;
690
+ }
691
+
692
+ function scaleOf(v) {
693
+ const s = typeof v === 'number' ? [v, v, v] : vec3(v, 1);
694
+ const m = mat4.createF64();
695
+ m[0] = s[0]; m[5] = s[1]; m[10] = s[2];
696
+ return m;
697
+ }
698
+
699
+ function mirrorOf(n) {
700
+ const l = Math.hypot(n[0], n[1], n[2]) || 1;
701
+ const x = n[0] / l, y = n[1] / l, z = n[2] / l;
702
+ const m = mat4.createF64();
703
+ m[0] = 1 - 2 * x * x; m[4] = -2 * x * y; m[8] = -2 * x * z;
704
+ m[1] = -2 * y * x; m[5] = 1 - 2 * y * y; m[9] = -2 * y * z;
705
+ m[2] = -2 * z * x; m[6] = -2 * z * y; m[10] = 1 - 2 * z * z;
706
+ return m;
707
+ }
708
+
709
+ function matrixOf(v) {
710
+ const rows = asList(v);
711
+ const m = mat4.createF64();
712
+ for (let r = 0; r < 4; r++) {
713
+ const row = asList(rows[r] || [0, 0, 0, r === 3 ? 1 : 0]);
714
+ for (let c = 0; c < 4; c++) m[c * 4 + r] = num(row[c], r === c ? 1 : 0);
715
+ }
716
+ return m;
717
+ }
718
+
719
+ function translation(x, y, z) {
720
+ const m = mat4.createF64();
721
+ m[12] = x; m[13] = y; m[14] = z;
722
+ return m;
723
+ }
724
+
725
+ function mul(a, b) {
726
+ const o = mat4.createF64();
727
+ mat4.multiply(o, a, b);
728
+ return o;
729
+ }
730
+
731
+ function relative(frame, child) {
732
+ const inv = mat4.createF64();
733
+ mat4.invert(inv, frame);
734
+ return mul(inv, child);
735
+ }
736
+
737
+ function apply(m, x, y, z) {
738
+ return [
739
+ m[0] * x + m[4] * y + m[8] * z + m[12],
740
+ m[1] * x + m[5] * y + m[9] * z + m[13],
741
+ m[2] * x + m[6] * y + m[10] * z + m[14],
742
+ ];
743
+ }
744
+
745
+ function vec3(v, fallback = 0) {
746
+ if (typeof v === 'number') return [v, v, v];
747
+ if (!Array.isArray(v)) return [fallback, fallback, fallback];
748
+ return [num(v[0], fallback), num(v[1], fallback), num(v[2], fallback)];
749
+ }
750
+
751
+ function num(v, fallback) {
752
+ return typeof v === 'number' && Number.isFinite(v) ? v : fallback;
753
+ }
754
+
755
+ function truthy(v) {
756
+ if (v === UNDEF || v == null || v === false) return false;
757
+ if (typeof v === 'number') return v !== 0;
758
+ if (Array.isArray(v)) return v.length > 0;
759
+ if (typeof v === 'string') return v.length > 0;
760
+ return !!v;
761
+ }
762
+
763
+ function iterable(v) {
764
+ if (v && v.range) return rangeValues(v.start, v.step, v.end);
765
+ if (Array.isArray(v)) return v;
766
+ if (typeof v === 'number') return [v];
767
+ return [];
768
+ }
769
+
770
+ function rangeValues(start, step, end) {
771
+ if (!Number.isFinite(start) || !Number.isFinite(step) || !Number.isFinite(end) || step === 0) return [];
772
+ const n = Math.floor((end - start) / step + 1e-9);
773
+ if (n < 0 || n > 100000) return n > 100000 ? (() => { throw new Error('An OpenSCAD range is longer than 100000.'); })() : [];
774
+ const out = [];
775
+ for (let i = 0; i <= n; i++) out.push(start + i * step);
776
+ return out;
777
+ }
778
+
779
+ function asList(v) {
780
+ if (v && v.range) return rangeValues(v.start, v.step, v.end);
781
+ return Array.isArray(v) ? v : [];
782
+ }
783
+
784
+ function lookup(scope, name) {
785
+ let s = scope;
786
+ while (s) {
787
+ if (s.vars.has(name)) return s.vars.get(name);
788
+ s = s.parent;
789
+ }
790
+ return UNDEF;
791
+ }
792
+
793
+ function lookupFn(scope, name) {
794
+ let s = scope;
795
+ while (s) {
796
+ if (s.functions.has(name)) return s.functions.get(name);
797
+ s = s.parent;
798
+ }
799
+ return null;
800
+ }
801
+
802
+ const PREC = { '||': 1, '&&': 2, '==': 3, '!=': 3, '<': 4, '>': 4, '<=': 4, '>=': 4, '+': 5, '-': 5, '*': 6, '/': 6, '%': 6 };
803
+
804
+ function evalExpr(expr, scope, env) {
805
+ if (!expr) return UNDEF;
806
+ if (expr.t === 'num') return expr.v;
807
+ if (expr.t === 'str') return expr.v;
808
+ if (expr.t === 'undef') return UNDEF;
809
+ if (expr.t === 'bool') return expr.v;
810
+ if (expr.t === 'id') return lookup(scope, expr.v);
811
+ if (expr.t === 'list') return expr.items.map((e) => evalExpr(e, scope, env));
812
+ if (expr.t === 'range') return { range: true, start: num(evalExpr(expr.start, scope, env), 0), step: expr.step ? num(evalExpr(expr.step, scope, env), 1) : 1, end: num(evalExpr(expr.end, scope, env), 0) };
813
+ if (expr.t === 'unary') {
814
+ const v = evalExpr(expr.v, scope, env);
815
+ if (expr.op === '!') return !truthy(v);
816
+ if (expr.op === '-') return typeof v === 'number' ? -v : Array.isArray(v) ? v.map((x) => -num(x, 0)) : UNDEF;
817
+ return v;
818
+ }
819
+ if (expr.t === 'ternary') return truthy(evalExpr(expr.cond, scope, env)) ? evalExpr(expr.then, scope, env) : evalExpr(expr.else, scope, env);
820
+ if (expr.t === 'index') {
821
+ const v = evalExpr(expr.v, scope, env);
822
+ const i = evalExpr(expr.i, scope, env);
823
+ if (Array.isArray(v) && typeof i === 'number') return v[i] !== undefined ? v[i] : UNDEF;
824
+ return UNDEF;
825
+ }
826
+ if (expr.t === 'dot') {
827
+ const v = evalExpr(expr.v, scope, env);
828
+ const map = { x: 0, y: 1, z: 2 };
829
+ if (Array.isArray(v) && map[expr.name] !== undefined) return v[map[expr.name]] !== undefined ? v[map[expr.name]] : UNDEF;
830
+ return UNDEF;
831
+ }
832
+ if (expr.t === 'call') return callFn(expr.name, expr.args.map((a) => evalExpr(a, scope, env)), scope, env);
833
+ if (expr.t === 'bin') return binOp(expr.op, evalExpr(expr.l, scope, env), evalExpr(expr.r, scope, env));
834
+ if (expr.t === 'comp') return evalComp(expr, scope, env);
835
+ return UNDEF;
836
+ }
837
+
838
+ function binOp(op, l, r) {
839
+ if ((op === '*' || op === '/' || op === '%' || op === '+' || op === '-') && (Array.isArray(l) || Array.isArray(r))) return vecOp(op, l, r);
840
+ if (l === UNDEF || r === UNDEF) return op === '==' || op === '!=' ? false : UNDEF;
841
+ if (op === '+') return num(l, 0) + num(r, 0);
842
+ if (op === '-') return num(l, 0) - num(r, 0);
843
+ if (op === '*') return num(l, 0) * num(r, 0);
844
+ if (op === '/') return num(r, 0) === 0 ? UNDEF : num(l, 0) / num(r, 0);
845
+ if (op === '%') return num(r, 0) === 0 ? UNDEF : num(l, 0) % num(r, 0);
846
+ if (op === '==') return sameVal(l, r);
847
+ if (op === '!=') return !sameVal(l, r);
848
+ if (op === '<') return num(l, 0) < num(r, 0);
849
+ if (op === '>') return num(l, 0) > num(r, 0);
850
+ if (op === '<=') return num(l, 0) <= num(r, 0);
851
+ if (op === '>=') return num(l, 0) >= num(r, 0);
852
+ if (op === '&&') return truthy(l) && truthy(r);
853
+ if (op === '||') return truthy(l) || truthy(r);
854
+ return UNDEF;
855
+ }
856
+
857
+ function vecOp(op, l, r) {
858
+ const a = Array.isArray(l) ? l : null;
859
+ const b = Array.isArray(r) ? r : null;
860
+ if (op === '*' && a && typeof r === 'number') return a.map((v) => num(v, 0) * r);
861
+ if (op === '*' && b && typeof l === 'number') return b.map((v) => num(v, 0) * l);
862
+ if (op === '/' && a && typeof r === 'number' && r !== 0) return a.map((v) => num(v, 0) / r);
863
+ if ((op === '+' || op === '-' || op === '*') && a && b) {
864
+ const n = Math.max(a.length, b.length);
865
+ const out = [];
866
+ for (let i = 0; i < n; i++) {
867
+ const x = num(a[i], 0), y = num(b[i], 0);
868
+ out.push(op === '+' ? x + y : op === '-' ? x - y : x * y);
869
+ }
870
+ return out;
871
+ }
872
+ return UNDEF;
873
+ }
874
+
875
+ function sameVal(a, b) {
876
+ if (a === UNDEF || b === UNDEF) return false;
877
+ if (Array.isArray(a) && Array.isArray(b)) return a.length === b.length && a.every((v, i) => sameVal(v, b[i]));
878
+ return a === b;
879
+ }
880
+
881
+ function callFn(name, args, scope, env) {
882
+ const rad = (v) => v * 180 / Math.PI;
883
+ if (name === 'sin') return Math.sin(num(args[0], 0) * Math.PI / 180);
884
+ if (name === 'cos') return Math.cos(num(args[0], 0) * Math.PI / 180);
885
+ if (name === 'tan') return Math.tan(num(args[0], 0) * Math.PI / 180);
886
+ if (name === 'asin') return rad(Math.asin(clamp1(args[0])));
887
+ if (name === 'acos') return rad(Math.acos(clamp1(args[0])));
888
+ if (name === 'atan') return rad(Math.atan(num(args[0], 0)));
889
+ if (name === 'atan2') return rad(Math.atan2(num(args[0], 0), num(args[1], 0)));
890
+ if (name === 'abs') return Math.abs(num(args[0], 0));
891
+ if (name === 'ceil') return Math.ceil(num(args[0], 0));
892
+ if (name === 'floor') return Math.floor(num(args[0], 0));
893
+ if (name === 'round') return Math.round(num(args[0], 0));
894
+ if (name === 'sqrt') return Math.sqrt(Math.max(0, num(args[0], 0)));
895
+ if (name === 'pow') return Math.pow(num(args[0], 0), num(args[1], 0));
896
+ if (name === 'ln') return Math.log(num(args[0], 1));
897
+ if (name === 'log') return Math.log(num(args[0], 1)) / Math.LN10;
898
+ if (name === 'exp') return Math.exp(num(args[0], 0));
899
+ if (name === 'sign') return Math.sign(num(args[0], 0));
900
+ if (name === 'min') return Math.min(...flatNums(args));
901
+ if (name === 'max') return Math.max(...flatNums(args));
902
+ if (name === 'len') return Array.isArray(args[0]) ? args[0].length : args[0] && args[0].range ? rangeValues(args[0].start, args[0].step, args[0].end).length : 0;
903
+ if (name === 'norm') return Math.hypot(...(Array.isArray(args[0]) ? args[0].map((v) => num(v, 0)) : [0]));
904
+ if (name === 'cross') {
905
+ const a = vec3(args[0]), b = vec3(args[1]);
906
+ return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
907
+ }
908
+ if (name === 'concat') return args.flatMap((v) => Array.isArray(v) ? v : [v]);
909
+ if (name === 'str') return args.map((v) => v === UNDEF ? 'undef' : String(v)).join('');
910
+ const fn = lookupFn(scope, name);
911
+ if (!fn) return UNDEF;
912
+ const inner = childScope(scope);
913
+ fn.params.forEach((p, i) => inner.vars.set(p.name, args[i] !== undefined ? args[i] : (p.def ? evalExpr(p.def, scope, env) : UNDEF)));
914
+ return evalExpr(fn.expr, inner, env);
915
+ }
916
+
917
+ function clamp1(v) {
918
+ return Math.max(-1, Math.min(1, num(v, 0)));
919
+ }
920
+
921
+ function flatNums(args) {
922
+ const out = [];
923
+ for (const a of args) {
924
+ if (Array.isArray(a)) out.push(...a.map((v) => num(v, 0)));
925
+ else out.push(num(a, 0));
926
+ }
927
+ return out.length ? out : [0];
928
+ }
929
+
930
+ function evalComp(expr, scope, env) {
931
+ const out = [];
932
+ const walk = (i) => {
933
+ if (i === expr.gens.length) { out.push(evalExpr(expr.expr, scope, env)); return; }
934
+ const g = expr.gens[i];
935
+ if (g.t === 'for') {
936
+ const lists = g.bindings.map((b) => iterable(evalExpr(b.expr, scope, env)));
937
+ const rec = (k) => {
938
+ if (k === lists.length) { walk(i + 1); return; }
939
+ for (const value of lists[k]) { scope.vars.set(g.bindings[k].name, value); rec(k + 1); }
940
+ };
941
+ rec(0);
942
+ return;
943
+ }
944
+ if (g.t === 'if') { if (truthy(evalExpr(g.cond, scope, env))) walk(i + 1); return; }
945
+ if (g.t === 'let') {
946
+ for (const b of g.bindings) scope.vars.set(b.name, evalExpr(b.expr, scope, env));
947
+ walk(i + 1);
948
+ }
949
+ };
950
+ walk(0);
951
+ return out;
952
+ }
953
+
954
+ // --- parser -------------------------------------------------------------------
955
+
956
+ function parseProgram(text) {
957
+ const p = new Parser(lex(text));
958
+ const body = [];
959
+ while (!p.end()) body.push(p.stmt());
960
+ return { body };
961
+ }
962
+
963
+ class Parser {
964
+ constructor(tokens) { this.toks = tokens; this.i = 0; }
965
+ cur() { return this.toks[this.i]; }
966
+ end() { return this.cur().t === 'eof'; }
967
+ eat(t, v) {
968
+ const c = this.cur();
969
+ if (c.t !== t || (v !== undefined && c.v !== v)) this.fail(`expected ${v || t}`);
970
+ this.i++;
971
+ return c;
972
+ }
973
+ is(t, v) { const c = this.cur(); return c.t === t && (v === undefined || c.v === v); }
974
+ fail(msg) { throw new Error(`OpenSCAD line ${this.cur().line}: ${msg}`); }
975
+
976
+ stmt() {
977
+ if (this.is('punct', '{')) return this.block();
978
+ let mod = null;
979
+ if (this.is('punct', '*') || this.is('punct', '!') || this.is('punct', '#') || this.is('punct', '%')) mod = this.eat('punct').v;
980
+ if (this.is('id', 'include') || this.is('id', 'use')) return this.include();
981
+ if (this.is('id', 'module')) return this.module();
982
+ if (this.is('id', 'function')) return this.fn();
983
+ if (this.is('id', 'for') || this.is('id', 'intersection_for')) return this.forStmt();
984
+ if (this.is('id', 'if')) return this.ifStmt();
985
+ if (this.is('id', 'let') || this.is('id', 'assign')) return this.letStmt();
986
+ if (this.is('id')) {
987
+ const name = this.eat('id').v;
988
+ if (this.is('punct', '=')) {
989
+ this.eat('punct', '=');
990
+ const expr = this.expr();
991
+ if (this.is('punct', ';')) this.eat('punct', ';');
992
+ return { t: 'assign', name, expr };
993
+ }
994
+ const args = this.is('punct', '(') ? this.args() : [];
995
+ let child = null;
996
+ if (this.startsStmt()) child = this.stmt();
997
+ else if (this.is('punct', ';')) this.eat('punct', ';');
998
+ const node = { t: 'call', name, args, child };
999
+ if (mod) node.mod = mod;
1000
+ return node;
1001
+ }
1002
+ this.fail(`unexpected ${this.cur().v || this.cur().t}`);
1003
+ }
1004
+
1005
+ startsStmt() {
1006
+ if (this.is('punct', '{') || this.is('punct', '*') || this.is('punct', '!') || this.is('punct', '#') || this.is('punct', '%')) return true;
1007
+ return this.is('id') && !this.is('id', 'else');
1008
+ }
1009
+
1010
+ block() {
1011
+ this.eat('punct', '{');
1012
+ const body = [];
1013
+ while (!this.is('punct', '}') && !this.end()) body.push(this.stmt());
1014
+ this.eat('punct', '}');
1015
+ return { t: 'block', body };
1016
+ }
1017
+
1018
+ include() {
1019
+ const kind = this.eat('id').v;
1020
+ this.eat('punct', '<');
1021
+ let file = '';
1022
+ while (!this.is('punct', '>') && !this.end()) {
1023
+ const tok = this.cur();
1024
+ file += tok.t === 'str' ? tok.v : String(tok.v);
1025
+ this.i++;
1026
+ }
1027
+ this.eat('punct', '>');
1028
+ if (this.is('punct', ';')) this.eat('punct', ';');
1029
+ return { t: 'include', kind, file };
1030
+ }
1031
+
1032
+ module() {
1033
+ this.eat('id', 'module');
1034
+ const name = this.eat('id').v;
1035
+ const params = this.is('punct', '(') ? this.params() : [];
1036
+ const body = this.stmt();
1037
+ return { t: 'module', name, params, body };
1038
+ }
1039
+
1040
+ fn() {
1041
+ this.eat('id', 'function');
1042
+ const name = this.eat('id').v;
1043
+ const params = this.params();
1044
+ this.eat('punct', '=');
1045
+ const expr = this.expr();
1046
+ if (this.is('punct', ';')) this.eat('punct', ';');
1047
+ return { t: 'function', name, params, expr };
1048
+ }
1049
+
1050
+ forStmt() {
1051
+ const name = this.eat('id').v;
1052
+ this.eat('punct', '(');
1053
+ const bindings = this.bindings();
1054
+ this.eat('punct', ')');
1055
+ const body = this.stmt();
1056
+ return { t: 'for', name, bindings, body };
1057
+ }
1058
+
1059
+ ifStmt() {
1060
+ this.eat('id', 'if');
1061
+ this.eat('punct', '(');
1062
+ const cond = this.expr();
1063
+ this.eat('punct', ')');
1064
+ const then = this.stmt();
1065
+ let els = null;
1066
+ if (this.is('id', 'else')) { this.eat('id', 'else'); els = this.stmt(); }
1067
+ return { t: 'if', cond, then, else: els };
1068
+ }
1069
+
1070
+ letStmt() {
1071
+ this.eat('id');
1072
+ this.eat('punct', '(');
1073
+ const bindings = this.bindings();
1074
+ this.eat('punct', ')');
1075
+ return { t: 'let', bindings, body: this.stmt() };
1076
+ }
1077
+
1078
+ bindings() {
1079
+ const list = [];
1080
+ if (this.is('punct', ')')) return list;
1081
+ do {
1082
+ const name = this.eat('id').v;
1083
+ this.eat('punct', '=');
1084
+ list.push({ name, expr: this.expr() });
1085
+ } while (this.is('punct', ',') && this.eat('punct', ','));
1086
+ return list;
1087
+ }
1088
+
1089
+ params() {
1090
+ this.eat('punct', '(');
1091
+ const list = [];
1092
+ if (!this.is('punct', ')')) {
1093
+ do {
1094
+ const name = this.eat('id').v;
1095
+ let def = null;
1096
+ if (this.is('punct', '=')) { this.eat('punct', '='); def = this.expr(); }
1097
+ list.push({ name, def });
1098
+ } while (this.is('punct', ',') && this.eat('punct', ','));
1099
+ }
1100
+ this.eat('punct', ')');
1101
+ return list;
1102
+ }
1103
+
1104
+ args() {
1105
+ this.eat('punct', '(');
1106
+ const list = [];
1107
+ if (!this.is('punct', ')')) {
1108
+ do {
1109
+ if (this.is('punct', ')')) break;
1110
+ if (this.is('id') && this.toks[this.i + 1] && this.toks[this.i + 1].t === 'punct' && this.toks[this.i + 1].v === '=') {
1111
+ const name = this.eat('id').v;
1112
+ this.eat('punct', '=');
1113
+ list.push({ name, expr: this.expr() });
1114
+ } else list.push({ name: null, expr: this.expr() });
1115
+ } while (this.is('punct', ',') && this.eat('punct', ','));
1116
+ }
1117
+ this.eat('punct', ')');
1118
+ return list;
1119
+ }
1120
+
1121
+ expr(min = 0) {
1122
+ let left = this.unary();
1123
+ while (this.is('punct', '?') && min <= 1) {
1124
+ this.eat('punct', '?');
1125
+ const then = this.expr(0);
1126
+ this.eat('punct', ':');
1127
+ const els = this.expr(1);
1128
+ left = { t: 'ternary', cond: left, then, else: els };
1129
+ }
1130
+ while (this.is('punct') && PREC[this.cur().v] >= min && this.cur().v !== '?') {
1131
+ const op = this.eat('punct').v;
1132
+ const right = this.expr(PREC[op] + 1);
1133
+ left = { t: 'bin', op, l: left, r: right };
1134
+ }
1135
+ return left;
1136
+ }
1137
+
1138
+ unary() {
1139
+ if (this.is('punct', '!') || this.is('punct', '+') || this.is('punct', '-')) {
1140
+ const op = this.eat('punct').v;
1141
+ return { t: 'unary', op, v: this.unary() };
1142
+ }
1143
+ return this.postfix(this.primary());
1144
+ }
1145
+
1146
+ postfix(v) {
1147
+ while (this.is('punct', '[') || this.is('punct', '.')) {
1148
+ if (this.is('punct', '.')) {
1149
+ this.eat('punct', '.');
1150
+ v = { t: 'dot', v, name: this.eat('id').v };
1151
+ } else {
1152
+ this.eat('punct', '[');
1153
+ const i = this.expr();
1154
+ this.eat('punct', ']');
1155
+ v = { t: 'index', v, i };
1156
+ }
1157
+ }
1158
+ return v;
1159
+ }
1160
+
1161
+ primary() {
1162
+ if (this.is('num')) return { t: 'num', v: this.eat('num').v };
1163
+ if (this.is('str')) return { t: 'str', v: this.eat('str').v };
1164
+ if (this.is('id', 'undef')) { this.eat('id'); return { t: 'undef' }; }
1165
+ if (this.is('id', 'true')) { this.eat('id'); return { t: 'bool', v: true }; }
1166
+ if (this.is('id', 'false')) { this.eat('id'); return { t: 'bool', v: false }; }
1167
+ if (this.is('id')) {
1168
+ const name = this.eat('id').v;
1169
+ if (this.is('punct', '(')) {
1170
+ const args = this.args().map((a) => a.expr);
1171
+ return { t: 'call', name, args };
1172
+ }
1173
+ return { t: 'id', v: name };
1174
+ }
1175
+ if (this.is('punct', '(')) { this.eat('punct', '('); const e = this.expr(); this.eat('punct', ')'); return e; }
1176
+ if (this.is('punct', '[')) return this.list();
1177
+ this.fail(`unexpected ${this.cur().v || this.cur().t}`);
1178
+ }
1179
+
1180
+ list() {
1181
+ this.eat('punct', '[');
1182
+ if (this.is('id', 'for') || this.is('id', 'if') || this.is('id', 'let')) {
1183
+ const gens = [];
1184
+ while (this.is('id', 'for') || this.is('id', 'if') || this.is('id', 'let')) {
1185
+ const k = this.eat('id').v;
1186
+ this.eat('punct', '(');
1187
+ if (k === 'if') gens.push({ t: 'if', cond: this.expr() });
1188
+ else gens.push({ t: k, bindings: this.bindings() });
1189
+ this.eat('punct', ')');
1190
+ }
1191
+ const expr = this.expr();
1192
+ this.eat('punct', ']');
1193
+ return { t: 'comp', gens, expr };
1194
+ }
1195
+ if (this.is('punct', ']')) { this.eat('punct', ']'); return { t: 'list', items: [] }; }
1196
+ const first = this.expr();
1197
+ if (this.is('punct', ':')) {
1198
+ this.eat('punct', ':');
1199
+ const second = this.expr();
1200
+ if (this.is('punct', ':')) {
1201
+ this.eat('punct', ':');
1202
+ const third = this.expr();
1203
+ this.eat('punct', ']');
1204
+ return { t: 'range', start: first, step: second, end: third };
1205
+ }
1206
+ this.eat('punct', ']');
1207
+ return { t: 'range', start: first, step: null, end: second };
1208
+ }
1209
+ const items = [first];
1210
+ while (this.is('punct', ',') && this.eat('punct', ',')) {
1211
+ if (this.is('punct', ']')) break;
1212
+ items.push(this.expr());
1213
+ }
1214
+ this.eat('punct', ']');
1215
+ return { t: 'list', items };
1216
+ }
1217
+ }
1218
+
1219
+ function lex(text) {
1220
+ const toks = [];
1221
+ let i = 0;
1222
+ let line = 1;
1223
+ const push = (t, v) => toks.push({ t, v, line });
1224
+ while (i < text.length) {
1225
+ const c = text[i];
1226
+ if (c === '\n') { line++; i++; continue; }
1227
+ if (c === ' ' || c === '\t' || c === '\r') { i++; continue; }
1228
+ if (c === '/' && text[i + 1] === '/') { while (i < text.length && text[i] !== '\n') i++; continue; }
1229
+ if (c === '/' && text[i + 1] === '*') { i += 2; while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) { if (text[i] === '\n') line++; i++; } i += 2; continue; }
1230
+ if (c === '"' ) {
1231
+ let s = '';
1232
+ i++;
1233
+ while (i < text.length && text[i] !== '"') {
1234
+ if (text[i] === '\\') { const e = text[++i]; s += e === 'n' ? '\n' : e === 't' ? '\t' : e; i++; }
1235
+ else { if (text[i] === '\n') line++; s += text[i++]; }
1236
+ }
1237
+ i++;
1238
+ push('str', s);
1239
+ continue;
1240
+ }
1241
+ if (c === '.' && text[i + 1] >= '0' && text[i + 1] <= '9' || (c >= '0' && c <= '9')) {
1242
+ let j = i;
1243
+ if (text[j] === '.') j++;
1244
+ while (j < text.length && text[j] >= '0' && text[j] <= '9') j++;
1245
+ if (text[j] === '.' && text[i] !== '.') { j++; while (j < text.length && text[j] >= '0' && text[j] <= '9') j++; }
1246
+ if (text[j] === 'e' || text[j] === 'E') {
1247
+ j++;
1248
+ if (text[j] === '+' || text[j] === '-') j++;
1249
+ while (j < text.length && text[j] >= '0' && text[j] <= '9') j++;
1250
+ }
1251
+ push('num', Number(text.slice(i, j)));
1252
+ i = j;
1253
+ continue;
1254
+ }
1255
+ if (c === '$' || c === '_' || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')) {
1256
+ let j = i + 1;
1257
+ while (j < text.length && /[A-Za-z0-9_$]/.test(text[j])) j++;
1258
+ push('id', text.slice(i, j));
1259
+ i = j;
1260
+ continue;
1261
+ }
1262
+ const two = text.slice(i, i + 2);
1263
+ if (['==', '!=', '<=', '>=', '&&', '||'].includes(two)) { push('punct', two); i += 2; continue; }
1264
+ push('punct', c);
1265
+ i++;
1266
+ }
1267
+ push('eof', '');
1268
+ return toks;
1269
+ }
1270
+
1271
+ // Positional names, required then optional, in the order Parameters::parse lists them.
1272
+ const PARAMS = {
1273
+ cube: ['size', 'center'],
1274
+ sphere: ['r', 'd'],
1275
+ cylinder: ['h', 'r1', 'r2', 'center', 'r', 'd', 'd1', 'd2'],
1276
+ square: ['size', 'center'],
1277
+ circle: ['r', 'd'],
1278
+ polygon: ['points', 'paths', 'convexity'],
1279
+ polyhedron: ['points', 'faces', 'convexity'],
1280
+ translate: ['v'],
1281
+ rotate: ['a', 'v'],
1282
+ scale: ['v'],
1283
+ mirror: ['v'],
1284
+ multmatrix: ['m'],
1285
+ color: ['c', 'alpha'],
1286
+ linear_extrude: ['height', 'v', 'scale', 'center', 'twist', 'slices', 'segments', 'convexity', 'h'],
1287
+ rotate_extrude: ['angle', 'start', 'convexity', 'a'],
1288
+ import: ['file', 'layer', 'convexity', 'origin', 'scale'],
1289
+ children: ['index'],
1290
+ text: ['text', 'size', 'font'],
1291
+ offset: ['r'],
1292
+ projection: ['cut'],
1293
+ resize: ['newsize', 'auto', 'convexity'],
1294
+ surface: ['file', 'center', 'convexity'],
1295
+ };