cadcode 0.0.0-stage → 0.2.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,489 @@
1
+ import Module from 'manifold-3d';
2
+ import { parseColor } from './colors.js';
3
+
4
+ /** @typedef {import('./colors.js').ColorInput} ColorInput */
5
+ /** @typedef {import('./colors.js').RGBA} RGBA */
6
+ /** @typedef {[number, number]} Vec2 */
7
+ /** @typedef {[number, number, number]} Vec3 */
8
+ /**
9
+ * Nested arrays of shapes, as produced by loops. Falsy entries are skipped, so `cond && shape` works.
10
+ * @template T
11
+ * @typedef {T | null | undefined | false | Items<T>[]} Items
12
+ */
13
+
14
+ const wasm = await Module();
15
+ wasm.setup();
16
+ const { Manifold, CrossSection, Mesh } = wasm;
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Resolution of curved primitives — the same rules as OpenSCAD's $fn/$fa/$fs.
20
+
21
+ const resolution = { fn: 0, fa: 12, fs: 2 };
22
+
23
+ /**
24
+ * Sets the default number of segments for circles, cylinders and spheres, like OpenSCAD's `$fn`, `$fa` and `$fs`.
25
+ * @param {{ fn?: number, fa?: number, fs?: number }} options
26
+ */
27
+ export function setResolution(options) {
28
+ Object.assign(resolution, options);
29
+ }
30
+
31
+ function segmentsFor(r, fn) {
32
+ const f = fn ?? resolution.fn;
33
+ if (r < 1e-9) return 3;
34
+ if (f > 0) return Math.max(Math.round(f), 3);
35
+ return Math.ceil(Math.max(Math.min(360 / resolution.fa, (r * 2 * Math.PI) / resolution.fs), 5));
36
+ }
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Colors travel with the geometry: color() stamps a fresh mesh ID on the solid,
40
+ // and manifold keeps per-triangle IDs through booleans, so a union of colored
41
+ // parts still knows which faces came from where.
42
+
43
+ /** @type {Map<number, RGBA>} */
44
+ const colorById = new Map();
45
+
46
+ /** @internal Looks up the color assigned to a manifold original ID. */
47
+ export function colorOfId(id) {
48
+ return colorById.get(id);
49
+ }
50
+
51
+ // ---------------------------------------------------------------------------
52
+
53
+ /** @template T @param {Items<T>[]} items @returns {T[]} */
54
+ function flatten(items) {
55
+ const out = [];
56
+ const walk = (item) => {
57
+ if (Array.isArray(item)) item.forEach(walk);
58
+ else if (item) out.push(item);
59
+ };
60
+ items.forEach(walk);
61
+ return out;
62
+ }
63
+
64
+ /** @param {number[]} v @returns {Vec3} */
65
+ function toVec3(v) {
66
+ return [v[0] ?? 0, v[1] ?? 0, v[2] ?? 0];
67
+ }
68
+
69
+ /** Rotation by `degrees` around an arbitrary `axis`, as a column-major 4x4 matrix. */
70
+ function axisRotation(degrees, axis) {
71
+ const [ax, ay, az] = toVec3(axis);
72
+ const len = Math.hypot(ax, ay, az);
73
+ const [x, y, z] = [ax / len, ay / len, az / len];
74
+ const a = (degrees * Math.PI) / 180;
75
+ const c = Math.cos(a);
76
+ const s = Math.sin(a);
77
+ const t = 1 - c;
78
+ return [
79
+ t * x * x + c, t * x * y + s * z, t * x * z - s * y, 0,
80
+ t * x * y - s * z, t * y * y + c, t * y * z + s * x, 0,
81
+ t * x * z + s * y, t * y * z - s * x, t * z * z + c, 0,
82
+ 0, 0, 0, 1,
83
+ ];
84
+ }
85
+
86
+ /** A 3D solid. Every method returns a new solid; solids are never modified in place. */
87
+ export class Solid {
88
+ /** @internal */
89
+ constructor(manifold) {
90
+ this.manifold = manifold;
91
+ }
92
+
93
+ /** @param {number[]} v */
94
+ translate(v) {
95
+ return new Solid(this.manifold.translate(toVec3(v)));
96
+ }
97
+
98
+ /**
99
+ * Rotates in degrees. `rotate([x, y, z])` turns around X, then Y, then Z, like OpenSCAD.
100
+ * `rotate(a)` turns around Z; `rotate(a, axis)` turns around an arbitrary axis.
101
+ * @param {number[] | number} angles
102
+ * @param {number[]} [axis]
103
+ */
104
+ rotate(angles, axis) {
105
+ if (axis) return new Solid(this.manifold.transform(axisRotation(angles, axis)));
106
+ return new Solid(this.manifold.rotate(typeof angles === 'number' ? [0, 0, angles] : toVec3(angles)));
107
+ }
108
+
109
+ /** @param {number[] | number} v */
110
+ scale(v) {
111
+ return new Solid(this.manifold.scale(typeof v === 'number' ? v : [v[0] ?? 1, v[1] ?? 1, v[2] ?? 1]));
112
+ }
113
+
114
+ /** Mirrors across the plane through the origin with the given normal. @param {number[]} normal */
115
+ mirror(normal) {
116
+ return new Solid(this.manifold.mirror(toVec3(normal)));
117
+ }
118
+
119
+ /** Applies a column-major 4x4 matrix. @param {number[]} matrix */
120
+ transform(matrix) {
121
+ return new Solid(this.manifold.transform(matrix));
122
+ }
123
+
124
+ /**
125
+ * Colors the whole solid. Accepts CSS names, hex strings or [r, g, b, a] in 0..1.
126
+ * @param {ColorInput} color
127
+ * @param {number} [alpha]
128
+ */
129
+ color(color, alpha) {
130
+ const m = this.manifold.asOriginal();
131
+ colorById.set(m.originalID(), parseColor(color, alpha));
132
+ return new Solid(m);
133
+ }
134
+
135
+ /** @param {...Items<Solid>} others */
136
+ union(...others) {
137
+ return new Solid(Manifold.union([this.manifold, ...flatten(others).map((s) => s.manifold)]));
138
+ }
139
+
140
+ /** @param {...Items<Solid>} others */
141
+ subtract(...others) {
142
+ const cutters = flatten(others);
143
+ if (cutters.length === 0) return this;
144
+ return new Solid(this.manifold.subtract(Manifold.union(cutters.map((s) => s.manifold))));
145
+ }
146
+
147
+ /** @param {...Items<Solid>} others */
148
+ intersect(...others) {
149
+ return new Solid(Manifold.intersection([this.manifold, ...flatten(others).map((s) => s.manifold)]));
150
+ }
151
+
152
+ /** Convex hull of this solid together with any others. @param {...Items<Solid>} others */
153
+ hull(...others) {
154
+ return new Solid(Manifold.hull([this.manifold, ...flatten(others).map((s) => s.manifold)]));
155
+ }
156
+
157
+ /** The cross-section at height `z`. */
158
+ slice(z = 0) {
159
+ return new Shape2(this.manifold.slice(z));
160
+ }
161
+
162
+ /** The outline of the solid projected onto the XY plane. */
163
+ project() {
164
+ return new Shape2(this.manifold.project());
165
+ }
166
+
167
+ volume() {
168
+ return this.manifold.volume();
169
+ }
170
+
171
+ surfaceArea() {
172
+ return this.manifold.surfaceArea();
173
+ }
174
+
175
+ /** @returns {{ min: Vec3, max: Vec3 }} */
176
+ bounds() {
177
+ const { min, max } = this.manifold.boundingBox();
178
+ return { min: [...min], max: [...max] };
179
+ }
180
+
181
+ isEmpty() {
182
+ return this.manifold.isEmpty();
183
+ }
184
+ }
185
+
186
+ /**
187
+ * @typedef {object} ExtrudeOptions
188
+ * @property {boolean} [center]
189
+ * @property {number} [twist] degrees the top is rotated relative to the bottom
190
+ * @property {number | Vec2} [scale] scale of the top relative to the bottom
191
+ * @property {number} [divisions] intermediate slices; useful with twist
192
+ */
193
+
194
+ /** @typedef {{ r: number, fn?: number } | { delta: number, chamfer?: boolean }} OffsetOptions */
195
+
196
+ /** A 2D shape in the XY plane. Extrude or revolve it to get a solid. */
197
+ export class Shape2 {
198
+ /** @internal */
199
+ constructor(section) {
200
+ this.section = section;
201
+ }
202
+
203
+ /** @param {number[]} v */
204
+ translate(v) {
205
+ return new Shape2(this.section.translate([v[0] ?? 0, v[1] ?? 0]));
206
+ }
207
+
208
+ /** @param {number} degrees */
209
+ rotate(degrees) {
210
+ return new Shape2(this.section.rotate(degrees));
211
+ }
212
+
213
+ /** @param {number[] | number} v */
214
+ scale(v) {
215
+ return new Shape2(this.section.scale(typeof v === 'number' ? v : [v[0] ?? 1, v[1] ?? 1]));
216
+ }
217
+
218
+ /** @param {number[]} axis */
219
+ mirror(axis) {
220
+ return new Shape2(this.section.mirror([axis[0] ?? 0, axis[1] ?? 0]));
221
+ }
222
+
223
+ /**
224
+ * Grows (positive) or shrinks (negative) the outline, like OpenSCAD's offset().
225
+ * A number means `{ r }`: rounded corners. `{ delta }` keeps corners sharp, `{ delta, chamfer: true }` cuts them.
226
+ * @param {number | OffsetOptions} options
227
+ */
228
+ offset(options) {
229
+ const o = typeof options === 'number' ? { r: options } : options;
230
+ if ('r' in o) return new Shape2(this.section.offset(o.r, 'Round', 2, segmentsFor(Math.abs(o.r), o.fn)));
231
+ return new Shape2(this.section.offset(o.delta, o.chamfer ? 'Square' : 'Miter', 1e6));
232
+ }
233
+
234
+ /** @param {...Items<Shape2>} others */
235
+ union(...others) {
236
+ return new Shape2(CrossSection.union([this.section, ...flatten(others).map((s) => s.section)]));
237
+ }
238
+
239
+ /** @param {...Items<Shape2>} others */
240
+ subtract(...others) {
241
+ const cutters = flatten(others);
242
+ if (cutters.length === 0) return this;
243
+ return new Shape2(this.section.subtract(CrossSection.union(cutters.map((s) => s.section))));
244
+ }
245
+
246
+ /** @param {...Items<Shape2>} others */
247
+ intersect(...others) {
248
+ return new Shape2(CrossSection.intersection([this.section, ...flatten(others).map((s) => s.section)]));
249
+ }
250
+
251
+ /** @param {...Items<Shape2>} others */
252
+ hull(...others) {
253
+ return new Shape2(CrossSection.hull([this.section, ...flatten(others).map((s) => s.section)]));
254
+ }
255
+
256
+ /**
257
+ * Extrudes along +Z, like OpenSCAD's linear_extrude().
258
+ * @param {number} height
259
+ * @param {ExtrudeOptions} [options]
260
+ */
261
+ extrude(height, options = {}) {
262
+ const { center = false, twist = 0, scale = 1, divisions = 0 } = options;
263
+ // manifold-3d misreads a scalar scaleTop, so always pass a vector.
264
+ const scaleTop = typeof scale === 'number' ? [scale, scale] : scale;
265
+ return new Solid(this.section.extrude(height, divisions, twist, scaleTop, center));
266
+ }
267
+
268
+ /**
269
+ * Revolves around the Y axis into a solid around Z, like OpenSCAD's rotate_extrude().
270
+ * @param {{ angle?: number, fn?: number }} [options]
271
+ */
272
+ revolve(options = {}) {
273
+ const { min, max } = this.section.bounds();
274
+ const radius = Math.max(Math.abs(min[0]), Math.abs(max[0]));
275
+ return new Solid(this.section.revolve(segmentsFor(radius, options.fn), options.angle ?? 360));
276
+ }
277
+
278
+ area() {
279
+ return this.section.area();
280
+ }
281
+
282
+ /** @returns {{ min: Vec2, max: Vec2 }} */
283
+ bounds() {
284
+ const { min, max } = this.section.bounds();
285
+ return { min: [...min], max: [...max] };
286
+ }
287
+
288
+ isEmpty() {
289
+ return this.section.isEmpty();
290
+ }
291
+
292
+ /** The outline as arrays of points. @returns {Vec2[][]} */
293
+ toPolygons() {
294
+ return this.section.toPolygons().map((poly) => poly.map((p) => [p[0], p[1]]));
295
+ }
296
+ }
297
+
298
+ // ---------------------------------------------------------------------------
299
+ // 3D primitives
300
+
301
+ /**
302
+ * A box with one corner at the origin, or centered with `{ center: true }`.
303
+ * @param {number | number[]} [size]
304
+ * @param {{ center?: boolean }} [options]
305
+ */
306
+ export function cube(size = 1, options = {}) {
307
+ const s = typeof size === 'number' ? [size, size, size] : toVec3(size);
308
+ return new Solid(Manifold.cube(s, options.center ?? false));
309
+ }
310
+
311
+ /**
312
+ * @typedef {object} CylinderOptions
313
+ * @property {number} h
314
+ * @property {number} [r]
315
+ * @property {number} [d]
316
+ * @property {number} [r1] bottom radius, for cones
317
+ * @property {number} [r2] top radius, for cones
318
+ * @property {number} [d1]
319
+ * @property {number} [d2]
320
+ * @property {boolean} [center]
321
+ * @property {number} [fn]
322
+ */
323
+
324
+ /**
325
+ * A cylinder or cone standing on the XY plane along +Z, like OpenSCAD's cylinder().
326
+ * @param {CylinderOptions} options
327
+ */
328
+ export function cylinder(options) {
329
+ const { h, r, d, r1, r2, d1, d2, center = false, fn } = options;
330
+ const base = r ?? (d !== undefined ? d / 2 : 1);
331
+ const bottom = r1 ?? (d1 !== undefined ? d1 / 2 : base);
332
+ const top = r2 ?? (d2 !== undefined ? d2 / 2 : base);
333
+ return new Solid(Manifold.cylinder(h, bottom, top, segmentsFor(Math.max(bottom, top), fn), center));
334
+ }
335
+
336
+ /**
337
+ * A sphere centered on the origin. `sphere(5)` is `sphere({ r: 5 })`.
338
+ * @param {number | { r?: number, d?: number, fn?: number }} options
339
+ */
340
+ export function sphere(options) {
341
+ const o = typeof options === 'number' ? { r: options } : options;
342
+ const r = o.r ?? (o.d !== undefined ? o.d / 2 : 1);
343
+ return new Solid(Manifold.sphere(r, segmentsFor(r, o.fn)));
344
+ }
345
+
346
+ /**
347
+ * A solid from explicit vertices and faces, like OpenSCAD's polyhedron():
348
+ * each face lists point indices clockwise when seen from outside.
349
+ * @param {number[][]} points
350
+ * @param {number[][]} faces
351
+ */
352
+ export function polyhedron(points, faces) {
353
+ const triVerts = [];
354
+ for (const face of faces) {
355
+ for (let i = 1; i + 1 < face.length; i++) triVerts.push(face[0], face[i + 1], face[i]);
356
+ }
357
+ const mesh = new Mesh({
358
+ numProp: 3,
359
+ vertProperties: new Float32Array(points.flatMap(toVec3)),
360
+ triVerts: new Uint32Array(triVerts),
361
+ });
362
+ mesh.merge();
363
+ const manifold = new Manifold(mesh);
364
+ if (manifold.status() !== 'NoError') throw new Error(`polyhedron() is not a closed solid: ${manifold.status()}`);
365
+ return new Solid(manifold);
366
+ }
367
+
368
+ // ---------------------------------------------------------------------------
369
+ // 2D primitives
370
+
371
+ /**
372
+ * @param {number | number[]} [size]
373
+ * @param {{ center?: boolean }} [options]
374
+ */
375
+ export function square(size = 1, options = {}) {
376
+ const s = typeof size === 'number' ? [size, size] : [size[0], size[1]];
377
+ return new Shape2(CrossSection.square(s, options.center ?? false));
378
+ }
379
+
380
+ /**
381
+ * A circle centered on the origin. `circle(5)` is `circle({ r: 5 })`.
382
+ * @param {number | { r?: number, d?: number, fn?: number }} options
383
+ */
384
+ export function circle(options) {
385
+ const o = typeof options === 'number' ? { r: options } : options;
386
+ const r = o.r ?? (o.d !== undefined ? o.d / 2 : 1);
387
+ return new Shape2(CrossSection.circle(r, segmentsFor(r, o.fn)));
388
+ }
389
+
390
+ /**
391
+ * A polygon from points. With `paths`, each path is a list of point indices;
392
+ * paths inside others become holes (even-odd fill), like OpenSCAD's polygon().
393
+ * @param {number[][]} points
394
+ * @param {number[][]} [paths]
395
+ */
396
+ export function polygon(points, paths) {
397
+ const pts = points.map((p) => [p[0], p[1]]);
398
+ const contours = paths ? paths.map((path) => path.map((i) => pts[i])) : [pts];
399
+ return new Shape2(new CrossSection(contours, 'EvenOdd'));
400
+ }
401
+
402
+ // ---------------------------------------------------------------------------
403
+ // Functional forms. They take any mix of shapes and nested arrays (from loops),
404
+ // so OpenSCAD's `translate(v) for (...) child();` becomes `translate(v, xs.map(...))`.
405
+
406
+ function dimension(items, op) {
407
+ if (items.length === 0) return 'empty';
408
+ const solids = items.filter((s) => s instanceof Solid).length;
409
+ if (solids === items.length) return 'solid';
410
+ if (solids === 0) return 'shape2';
411
+ throw new Error(`${op}() cannot mix 2D shapes and 3D solids`);
412
+ }
413
+
414
+ /** Applies a 3D or 2D operation to all items, depending on what they are. */
415
+ function combine(items, op, solidOp, shapeOp) {
416
+ const flat = flatten(items);
417
+ switch (dimension(flat, op)) {
418
+ case 'empty':
419
+ return new Solid(Manifold.compose([]));
420
+ case 'solid':
421
+ return new Solid(solidOp(flat.map((s) => s.manifold)));
422
+ case 'shape2':
423
+ return new Shape2(shapeOp(flat.map((s) => s.section)));
424
+ }
425
+ }
426
+
427
+ /** @param {...Items<Solid | Shape2>} items */
428
+ export function union(...items) {
429
+ return combine(items, 'union', (ms) => Manifold.union(ms), (cs) => CrossSection.union(cs));
430
+ }
431
+
432
+ /**
433
+ * Subtracts every following shape from the first one.
434
+ * @param {Solid | Shape2} base
435
+ * @param {...Items<Solid | Shape2>} cutters
436
+ */
437
+ export function difference(base, ...cutters) {
438
+ return base.subtract(...cutters);
439
+ }
440
+
441
+ /** @param {...Items<Solid | Shape2>} items */
442
+ export function intersection(...items) {
443
+ return combine(items, 'intersection', (ms) => Manifold.intersection(ms), (cs) => CrossSection.intersection(cs));
444
+ }
445
+
446
+ /** Convex hull of all given shapes, like OpenSCAD's hull(). @param {...Items<Solid | Shape2>} items */
447
+ export function hull(...items) {
448
+ return combine(items, 'hull', (ms) => Manifold.hull(ms), (cs) => CrossSection.hull(cs));
449
+ }
450
+
451
+ /** @param {number[]} v @param {...Items<Solid | Shape2>} items */
452
+ export function translate(v, ...items) {
453
+ return union(...items).translate(v);
454
+ }
455
+
456
+ /** Same arguments as {@link Solid#rotate}; 2D shapes take a single angle. @param {number[] | number} angles @param {...Items<Solid | Shape2>} items */
457
+ export function rotate(angles, ...items) {
458
+ return union(...items).rotate(angles);
459
+ }
460
+
461
+ /** @param {number[] | number} v @param {...Items<Solid | Shape2>} items */
462
+ export function scale(v, ...items) {
463
+ return union(...items).scale(v);
464
+ }
465
+
466
+ /** @param {number[]} normal @param {...Items<Solid | Shape2>} items */
467
+ export function mirror(normal, ...items) {
468
+ return union(...items).mirror(normal);
469
+ }
470
+
471
+ /**
472
+ * `color('orange', lid)` or `color('orange', 0.35, lid)`.
473
+ * @param {ColorInput} c
474
+ * @param {...(number | Items<Solid>)} rest
475
+ */
476
+ export function color(c, ...rest) {
477
+ const alpha = typeof rest[0] === 'number' ? rest.shift() : undefined;
478
+ return union(...rest).color(c, alpha);
479
+ }
480
+
481
+ /** @param {number | OffsetOptions} options @param {...Items<Shape2>} items */
482
+ export function offset(options, ...items) {
483
+ return union(...items).offset(options);
484
+ }
485
+
486
+ /** @param {number} height @param {ExtrudeOptions} options @param {...Items<Shape2>} items */
487
+ export function linearExtrude(height, options, ...items) {
488
+ return union(...items).extrude(height, options);
489
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Numbers from `start` to `end` inclusive, like OpenSCAD's `[start : step : end]`.
3
+ * @param {number} start
4
+ * @param {number} end
5
+ * @param {number} [step]
6
+ * @returns {number[]}
7
+ */
8
+ export function range(start, end, step = 1) {
9
+ if (step === 0) throw new Error('range() step must not be 0');
10
+ const out = [];
11
+ const count = Math.floor((end - start) / step + 1e-9);
12
+ for (let i = 0; i <= count; i++) out.push(start + i * step);
13
+ return out;
14
+ }
15
+
16
+ /**
17
+ * Every combination of the given lists, like OpenSCAD's `for (x = xs, y = ys)`:
18
+ * `product([1, 2], [3, 4])` is `[[1, 3], [1, 4], [2, 3], [2, 4]]`.
19
+ * @param {...any[]} lists
20
+ * @returns {any[][]}
21
+ */
22
+ export function product(...lists) {
23
+ return lists.reduce((acc, list) => acc.flatMap((combo) => list.map((v) => [...combo, v])), [[]]);
24
+ }