forgecad 0.1.3 → 0.1.5

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,4799 @@
1
+ # ForgeCAD — AI Context (Chat UI)
2
+
3
+ > **Usage:** Paste this file as context into your AI chat session (Claude.ai, ChatGPT, Gemini, etc.).
4
+ > The AI will have full ForgeCAD API knowledge and will guide you through building models.
5
+ >
6
+ > **No CLI access in this session.** The AI cannot run commands directly. Instead, it will ask
7
+ > you to run commands like `forgecad run <file>` or `forgecad notebook view <file> preview`
8
+ > in your terminal and paste back the output for verification and iteration.
9
+
10
+ ## Workflow
11
+
12
+ 1. Tell the AI what you want to build and share any existing `.forge.js` or `.forge-notebook.json` files.
13
+ 2. The AI will write or edit model files for you.
14
+ 3. To validate, run `forgecad run <file>` in your terminal and paste the output.
15
+ 4. For notebooks: `forgecad notebook view <file> preview` shows the current geometry description.
16
+ 5. Iterate until the model looks right, then optionally `forgecad render <file>` for a PNG.
17
+
18
+ ---
19
+
20
+ ## ForgeCAD API Reference
21
+
22
+ Author or modify ForgeCAD models, sketches, assemblies, notebooks, and CLI workflows.
23
+ Prefer documented primitives, import rules, and placement strategies over inventing new APIs.
24
+
25
+ ### Model files
26
+
27
+ - `.forge.js` — parametric part or assembly script; default export is a `Shape` or `Assembly`.
28
+ - `.sketch.js` — 2D sketch script; default export is a `Sketch`.
29
+ - `.forge-notebook.json` — multi-cell notebook for iterative work; preview cell is the active output.
30
+
31
+ ### Import and composition
32
+
33
+ - `importPart()` for parts, `importSketch()` for sketches/SVGs, with explicit `paramOverrides`.
34
+ - `.withReferences()` + `.placeReference()` for reusable placement.
35
+ - Plain `.js` modules for shared helpers/constants (not model imports).
36
+
37
+ ### Validation commands (ask the user to run these)
38
+
39
+ ```
40
+ forgecad run <file.forge.js> # geometry diagnostics
41
+ forgecad run <file.forge-notebook.json> # preview-cell diagnostics
42
+ forgecad notebook view <file.forge-notebook.json> preview # inspect notebook output
43
+ forgecad render <file.forge.js> # PNG render
44
+ forgecad capture gif <file.forge.js> # animated orbit GIF
45
+ ```
46
+
47
+ ---
48
+
49
+ <!-- API/model-building/reference.md -->
50
+
51
+ # ForgeCAD Model-Building Reference
52
+
53
+ This file covers the script authoring surface for geometry, sketches, topology, assemblies, imports, and reusable parts.
54
+
55
+ It intentionally excludes:
56
+ - viewport-only APIs in [../runtime/viewport.md](../runtime/viewport.md)
57
+ - report/export APIs in [../output/bom.md](../output/bom.md), [../output/dimensions.md](../output/dimensions.md), and [../output/brep-export.md](../output/brep-export.md)
58
+ - implementation notes in [../internals/manifold.md](../internals/manifold.md)
59
+
60
+ ## Core Concepts
61
+
62
+ ForgeCAD scripts are JavaScript code that returns geometry. The forge API is globally available — no imports needed.
63
+
64
+ ### Basic Structure
65
+ ```javascript
66
+ // 1. Declare parameters (creates UI sliders)
67
+ const width = param("Width", 50, { min: 20, max: 100, unit: "mm" });
68
+
69
+ // 2. Create geometry
70
+ const shape = box(width, 30, 10);
71
+
72
+ // 3. Return the final shape
73
+ return shape;
74
+ ```
75
+
76
+ ### Execution Model
77
+ - Scripts execute on every parameter change (400ms debounce)
78
+ - All operations are **immutable** — they return new shapes, never modify in place
79
+ - Must return one of:
80
+ - A `Shape` (3D solid)
81
+ - A `Sketch` (2D profile — rendered flat on XY plane)
82
+ - A `TrackedShape` (3D solid with named faces/edges — auto-unwrapped)
83
+ - A `ShapeGroup` (multiple shapes/sketches grouped for joint transforms)
84
+ - An `Array` of shapes/sketches/groups (multi-object scene)
85
+ - An `Array` of `{ name, shape?, sketch?, color? }` objects (named multi-object scene)
86
+
87
+ ### ⚠️ Important: Unions Remove Colors
88
+
89
+ When you use `union()` to combine shapes, the result becomes a single solid mesh with only one color. Individual colors assigned to the original shapes are lost:
90
+
91
+ ```javascript
92
+ // ❌ BAD: Colors are lost after union!
93
+ const red = box(30, 30, 30).color('#ff0000');
94
+ const blue = box(20, 20, 20).translate(30, 0, 0).color('#0066ff');
95
+ return union(red, blue); // Result is all one color (red)
96
+ ```
97
+
98
+ **Solution**: Return objects as a composite response instead:
99
+
100
+ ```javascript
101
+ // ✅ GOOD: Each object keeps its color
102
+ const red = box(30, 30, 30).color('#ff0000');
103
+ const blue = box(20, 20, 20).translate(30, 0, 0).color('#0066ff');
104
+
105
+ // Return as named objects to preserve individual colors and materials
106
+ return [
107
+ { "label": red }, // Each gets its own color, toggle, and controls
108
+ { "label": blue }
109
+ ];
110
+ ```
111
+
112
+ Each object in the returned array stays independently colorable and selectable, which is the main alternative to booleaning everything into one solid.
113
+
114
+ ### Coordinate System
115
+ ForgeCAD uses **Z-up** right-handed coordinates:
116
+ - **X** = left/right
117
+ - **Y** = forward/back
118
+ - **Z** = up/down
119
+
120
+ See [coordinate-system.md](coordinate-system.md) for view mapping details.
121
+
122
+ ## Parameters
123
+
124
+ ### `param(name, default, options?)`
125
+ Declares a parameter and creates a UI slider.
126
+
127
+ **Parameters:**
128
+ - `name` (string) - Display name in UI
129
+ - `default` (number) - Initial value
130
+ - `options` (object, optional):
131
+ - `min` (number) - Minimum value (default: 0)
132
+ - `max` (number) - Maximum value (default: default * 4)
133
+ - `step` (number) - Slider increment (auto-calculated if not provided)
134
+ - `unit` (string) - Display unit like "mm", "°", "%"
135
+ - `integer` (boolean) - If true, value is always rounded to whole number. Step defaults to 1. Use for counts, quantities, sides, etc.
136
+
137
+ **Returns:** Current parameter value (number)
138
+
139
+ **Examples:**
140
+ ```javascript
141
+ const width = param("Width", 50);
142
+ const angle = param("Angle", 45, { min: 0, max: 180, unit: "°" });
143
+ const thick = param("Thickness", 2, { min: 0.5, max: 10, step: 0.5, unit: "mm" });
144
+ const count = param("Count", 5, { min: 1, max: 20, integer: true });
145
+ ```
146
+
147
+ ## Colors
148
+
149
+ Both Shape and Sketch support colors via `.color()`:
150
+
151
+ ```javascript
152
+ const red = box(50, 50, 50).color('#ff0000');
153
+ const blue = circle2d(25).color('#0066ff');
154
+ ```
155
+
156
+ Colors are preserved through transforms and boolean operations (the first operand's color wins).
157
+
158
+ When returning multiple objects, colors can also be set per-object:
159
+
160
+ ```javascript
161
+ return [
162
+ { name: "Base", shape: box(100, 100, 5), color: "#888888" },
163
+ { name: "Column", shape: cylinder(50, 10).translate(50, 50, 5), color: "#4488cc" },
164
+ ];
165
+ ```
166
+
167
+ ## 3D Primitives
168
+
169
+ ### `box(x, y, z, center?)`
170
+ Creates a rectangular box with named faces and edges.
171
+
172
+ **Parameters:**
173
+ - `x, y, z` (number) - Dimensions
174
+ - `center` (boolean, optional) - If true, centers at origin. Default: false (corner at origin)
175
+
176
+ **Returns:** `TrackedShape` (with faces: top, bottom, side-left, side-right, side-top, side-bottom; edges: vert-bl, vert-br, vert-tr, vert-tl, etc.)
177
+
178
+ ```javascript
179
+ const cube = box(50, 50, 50, true); // Centered cube
180
+ const plate = box(100, 80, 5); // Corner at origin
181
+ plate.face('top'); // FaceRef { normal, center, planar, uAxis, vAxis }
182
+ plate.edge('vert-bl'); // EdgeRef { start, end }
183
+ ```
184
+
185
+ ### `cylinder(height, radius, radiusTop?, segments?, center?)`
186
+ Creates a cylinder or cone with named faces and edges.
187
+
188
+ **Parameters:**
189
+ - `height` (number) - Height along Z axis
190
+ - `radius` (number) - Bottom radius
191
+ - `radiusTop` (number, optional) - Top radius. If different from radius, creates a cone. Default: same as radius
192
+ - `segments` (number, optional) - Number of sides. Default: auto (smooth circle)
193
+ - `center` (boolean, optional) - If true, centers along Z. Default: false
194
+
195
+ **Returns:** `TrackedShape` (with faces: top, bottom, side; edges: top-rim, bottom-rim)
196
+
197
+ ```javascript
198
+ const cyl = cylinder(50, 10); // Cylinder
199
+ const cone = cylinder(50, 20, 5); // Cone (tapered)
200
+ const hex = cylinder(10, 15, 15, 6); // Hexagonal prism
201
+ cyl.face('top'); // FaceRef (planar)
202
+ cyl.face('side'); // FaceRef (curved, planar === false)
203
+ ```
204
+
205
+ ### `sphere(radius, segments?)`
206
+ Creates a sphere.
207
+
208
+ **Parameters:**
209
+ - `radius` (number) - Sphere radius
210
+ - `segments` (number, optional) - Tessellation detail. Default: auto (smooth)
211
+
212
+ **Returns:** `Shape`
213
+
214
+ ```javascript
215
+ const ball = sphere(25);
216
+ const lowPoly = sphere(25, 8); // Octahedron-like
217
+ ```
218
+
219
+ ## 3D Transforms
220
+
221
+ All transforms are **chainable** and **immutable** (return new shapes).
222
+ The core 3D transform set uses the same names/signatures on `Shape`, `TrackedShape`, and `ShapeGroup`.
223
+ For mixed groups that include `Sketch` children, `transform` / `rotateAround` / `pointAlong` are 3D-only.
224
+
225
+ ### `.clone()` / `.duplicate()`
226
+ Create an explicit copy handle of a shape (same geometry/color) so you can branch variants clearly.
227
+
228
+ ```javascript
229
+ const bracket = box(60, 20, 8);
230
+ const left = bracket.clone().translate(-40, 0, 0);
231
+ const right = bracket.duplicate().translate(40, 0, 0);
232
+ ```
233
+
234
+ ### `.translate(x, y, z)`
235
+ Moves the shape relative to its current position.
236
+
237
+ ```javascript
238
+ const moved = box(10, 10, 10).translate(50, 0, 0);
239
+ ```
240
+
241
+ ### `.moveTo(x, y, z)`
242
+ Positions the shape so its bounding box min corner is at the given global coordinate.
243
+
244
+ ```javascript
245
+ // Place a box at exactly (100, 50, 0) in world space
246
+ const placed = box(30, 30, 10).moveTo(100, 50, 0);
247
+ ```
248
+
249
+ ### `.moveToLocal(target, x, y, z)`
250
+ Positions the shape relative to another shape's local coordinate system (bounding box min corner).
251
+
252
+ **Parameters:**
253
+ - `target` (Shape | TrackedShape) — The reference shape
254
+ - `x, y, z` (number) — Offset from target's bounding box min corner
255
+
256
+ ```javascript
257
+ const base = box(100, 100, 10);
258
+ const part = box(20, 20, 30);
259
+
260
+ // Place part at (10, 10, 10) relative to base's origin corner
261
+ const placed = part.moveToLocal(base, 10, 10, 10);
262
+ ```
263
+
264
+ ### `.rotate(x, y, z)`
265
+ Rotates using Euler angles in **degrees**.
266
+
267
+ **Parameters:**
268
+ - `x, y, z` (number) - Rotation in degrees around each axis
269
+
270
+ ```javascript
271
+ const rotated = box(50, 20, 10).rotate(0, 0, 45); // 45° around Z
272
+ const tilted = cylinder(50, 10).rotate(90, 0, 0); // Lay on side
273
+ ```
274
+
275
+ ### `.scale(v)`
276
+ Scales the shape.
277
+
278
+ **Parameters:**
279
+ - `v` (number | [number, number, number]) - Uniform scale or per-axis scale
280
+
281
+ ```javascript
282
+ const bigger = sphere(10).scale(2); // 2x larger
283
+ const stretched = box(10, 10, 10).scale([2, 1, 0.5]); // Non-uniform
284
+ ```
285
+
286
+ ### `.mirror(normal)`
287
+ Mirrors across a plane defined by its normal vector.
288
+
289
+ **Parameters:**
290
+ - `normal` ([number, number, number]) - Plane normal (doesn't need to be unit length)
291
+
292
+ ```javascript
293
+ const mirrored = shape.mirror([1, 0, 0]); // Mirror across YZ plane
294
+ ```
295
+
296
+ ### `.transform(m)`
297
+ Applies a custom 4x4 transform matrix or `Transform` object.
298
+
299
+ **Parameters:**
300
+ - `m` (`number[] | Transform`) - 4x4 column-major matrix (`number[16]`) or a `Transform`
301
+
302
+ ```javascript
303
+ const T = Transform.identity()
304
+ .translate(0, 0, 1.5)
305
+ .rotateAxis([1, 0, 0], 35, [0, hingeY, 0]);
306
+
307
+ const moved = lid.transform(T);
308
+ ```
309
+
310
+ ### `.rotateAround(axis, angleDeg, pivot?)`
311
+ Rotates around an arbitrary axis through a pivot point.
312
+
313
+ **Parameters:**
314
+ - `axis` ([number, number, number]) - Rotation axis direction
315
+ - `angleDeg` (number) - Rotation angle in degrees
316
+ - `pivot` ([number, number, number], optional) - Pivot point. Default: origin
317
+
318
+ ```javascript
319
+ // Rotate a door 45° around Z axis at the hinge position
320
+ const opened = door.rotateAround([0, 0, 1], 45, [hingeX, hingeY, 0]);
321
+ ```
322
+
323
+ ### `.rotateAroundTo(axis, pivot, movingPoint, targetPoint, opts?)`
324
+ Solves the rotation around an axis that makes `movingPoint` reach the target line/plane implied by `targetPoint`.
325
+
326
+ **Parameters:**
327
+ - `axis` ([number, number, number]) - Rotation axis direction
328
+ - `pivot` ([number, number, number]) - Point on the rotation axis
329
+ - `movingPoint` (`[number, number, number] | string`) - World-space point or this shape's anchor/reference
330
+ - `targetPoint` (`[number, number, number] | string`) - World-space point or this shape's anchor/reference
331
+ - `opts` (object, optional)
332
+ - `mode` (`'plane' | 'line'`) - Default: `'plane'`
333
+
334
+ Modes:
335
+ - `'plane'` — rotate until `movingPoint` lies in the plane defined by `axis` + `targetPoint`
336
+ - `'line'` — rotate until `movingPoint` lies on the infinite line from `pivot` through `targetPoint`; throws if the geometry makes that impossible
337
+
338
+ ```javascript
339
+ const arm = box(80, 8, 8, true)
340
+ .translate(40, 0, 0)
341
+ .withReferences({ points: { tip: [80, 0, 0] } });
342
+
343
+ const aimed = arm.rotateAroundTo(
344
+ [0, 0, 1],
345
+ [0, 0, 0],
346
+ "tip",
347
+ [30, 30, 20],
348
+ );
349
+ ```
350
+
351
+ ### `.pointAlong(direction)`
352
+ Reorients a shape so its primary axis (Z) points along the given direction. Useful for laying cylinders and extrusions along X or Y without thinking about Euler angles.
353
+
354
+ **Parameters:**
355
+ - `direction` ([number, number, number]) - Target direction vector
356
+
357
+ ```javascript
358
+ // Lay a cylinder along the X axis
359
+ const axle = cylinder(100, 5).pointAlong([1, 0, 0]);
360
+
361
+ // Symmetric hinges pointing outward from center
362
+ const hingeL = cylinder(40, 5).pointAlong([-1, 0, 0]).translate(-50, 0, 0);
363
+ const hingeR = cylinder(40, 5).pointAlong([1, 0, 0]).translate(50, 0, 0);
364
+ ```
365
+
366
+ ### `Transform` primitives (for kinematic chains)
367
+ Use `Transform` when manual pivot math becomes hard to maintain.
368
+
369
+ ```javascript
370
+ const T = Transform.identity()
371
+ .translate(0, 0, 120)
372
+ .rotateAxis([0, 0, 1], 35);
373
+
374
+ const p = T.point([10, 0, 0]); // transform a point
375
+ const v = T.vector([1, 0, 0]); // transform a direction (no translation)
376
+ ```
377
+
378
+ Core methods:
379
+ - `Transform.identity()`
380
+ - `Transform.translation(x, y, z)`
381
+ - `Transform.rotationAxis(axis, angleDeg, pivot?)`
382
+ - `Transform.rotateAroundTo(axis, pivot, movingPoint, targetPoint, opts?)`
383
+ - `Transform.scale(v)`
384
+ - `T.mul(other)` (chain-composition order)
385
+ - `composeChain(a, b, c, ...)` explicit left-to-right chain composition
386
+ - `T.inverse()`
387
+ - `shape.transform(T)` / `trackedShape.transform(T)` / `group.transform(T)`
388
+
389
+ ## Joints
390
+
391
+ ### `joint(name, shape, pivot, opts?)`
392
+ Create a revolute (hinge) joint. Auto-creates a param slider and rotates the shape.
393
+
394
+ **Parameters:**
395
+ - `name` (string) - Display name for the angle parameter
396
+ - `shape` (Shape) - The shape to rotate
397
+ - `pivot` ([number, number, number]) - The pivot point
398
+ - `opts` (object, optional):
399
+ - `axis` ([number, number, number]) - Rotation axis. Default: [0, 0, 1] (Z axis)
400
+ - `min` (number) - Minimum angle. Default: 0
401
+ - `max` (number) - Maximum angle. Default: 360
402
+ - `default` (number) - Initial angle. Default: 0
403
+ - `unit` (string) - Display unit. Default: "°"
404
+
405
+ **Returns:** `Shape` (rotated by the current slider value)
406
+
407
+ ```javascript
408
+ // One line: creates a "Lid Angle" slider and rotates the lid around the hinge
409
+ const openLid = joint("Lid Angle", lid, [0, boxDepth, boxHeight], {
410
+ axis: [1, 0, 0],
411
+ max: 120,
412
+ default: 45,
413
+ });
414
+ ```
415
+
416
+ ## Assembly Graph (Mechanisms)
417
+
418
+ See also: `assembly.md`.
419
+
420
+ ### `assembly(name?)`
421
+ Creates an assembly container with named parts + joints.
422
+
423
+ ```javascript
424
+ const mech = assembly("Two-Link Arm")
425
+ .addPart("base", box(80, 80, 20, true))
426
+ .addPart("link1", box(120, 24, 24).translate(0, -12, -12))
427
+ .addPart("link2", box(100, 20, 20).translate(0, -10, -10))
428
+ .addJoint("shoulder", "revolute", "base", "link1", {
429
+ axis: [0, 1, 0],
430
+ min: -30, max: 120, default: 20,
431
+ frame: Transform.identity().translate(0, 0, 20),
432
+ })
433
+ .addJoint("elbow", "revolute", "link1", "link2", {
434
+ axis: [0, 1, 0],
435
+ min: -20, max: 140, default: 40,
436
+ frame: Transform.identity().translate(120, 0, 0),
437
+ });
438
+
439
+ const solved = mech.solve();
440
+ return solved.toScene();
441
+ ```
442
+
443
+ Linked joint example:
444
+
445
+ ```javascript
446
+ mech
447
+ .addJointCoupling("Top Gear", {
448
+ terms: [
449
+ { joint: "Steering", ratio: 1 },
450
+ { joint: "Wheel Drive", ratio: 20 / 14 },
451
+ ],
452
+ })
453
+ .addJointCoupling("Motor 1", {
454
+ terms: [{ joint: "Top Gear", ratio: -2 }],
455
+ });
456
+ ```
457
+
458
+ Gear helper example:
459
+
460
+ ```javascript
461
+ const pair = lib.gearPair({
462
+ pinion: { module: 1.25, teeth: 14, faceWidth: 8 },
463
+ gear: { module: 1.25, teeth: 42, faceWidth: 8 },
464
+ });
465
+
466
+ mech.addGearCoupling("Driven", "Pinion", { pair }); // uses pair.jointRatio
467
+ ```
468
+
469
+ Bevel/face helper example:
470
+
471
+ ```javascript
472
+ const bevel = lib.bevelGearPair({
473
+ pinion: { module: 1.5, teeth: 18, faceWidth: 10 },
474
+ gear: { module: 1.5, teeth: 36, faceWidth: 9 },
475
+ shaftAngleDeg: 90,
476
+ });
477
+
478
+ const face = lib.faceGearPair({
479
+ face: { module: 1.5, teeth: 44, faceWidth: 7, toothHeight: 1.2, side: 'top' },
480
+ vertical: { module: 1.5, teeth: 16, faceWidth: 8 },
481
+ });
482
+
483
+ mech.addGearCoupling("Bevel Driven", "Bevel Driver", { pair: bevel });
484
+ mech.addGearCoupling("Face Driven", "Face Driver", { pair: face });
485
+ ```
486
+
487
+ Key methods:
488
+ - `addPart(name, shape, { transform?, metadata? })`
489
+ - `addFrame(name, { transform? })` for virtual mechanism frames
490
+ - `addJoint(name, type, parent, child, opts)` where `type` is `'fixed' | 'revolute' | 'prismatic'`
491
+ - `addRevolute(...)`, `addPrismatic(...)`, `addFixed(...)` shorthand helpers
492
+ - `addJointCoupling(jointName, { terms, offset? })` for linked joints:
493
+ - `jointName`: driven joint
494
+ - `terms`: `[{ joint, ratio? }, ...]` where each source contributes `ratio * sourceValue` (default ratio `1`)
495
+ - `offset`: additive bias after term sum (default `0`)
496
+ - `addGearCoupling(drivenJoint, driverJoint, opts)` for revolute gear meshes:
497
+ - ratio source (exactly one): `ratio`, `pair` (`pair.jointRatio`), or `driverTeeth + drivenTeeth`
498
+ - `mesh`: `'external' | 'internal' | 'bevel' | 'face'` (teeth mode only, default `'external'`; `bevel`/`face` follow external sign)
499
+ - `offset`: additive bias after gear ratio
500
+ - `solve(state?)` with per-joint value overrides
501
+ - `sweepJoint(jointName, from, to, steps, baseState?, collisionOptions?)`
502
+
503
+ Solved assembly helpers:
504
+ - `solved.toScene()` for rendering
505
+ - `solved.collisionReport()` for interference checks
506
+ - `solved.minClearance(partA, partB, searchLength?)`
507
+ - `solved.bom()` / `solved.bomCsv()`
508
+ - `bomToCsv(rows)` (standalone helper)
509
+
510
+ ### `robotExport(options)`
511
+ Declares that the current script should also export an `assembly(...)` as a robot package for the SDF CLI.
512
+
513
+ Key fields:
514
+ - `assembly`: required source assembly graph
515
+ - `modelName`: simulator-facing model name
516
+ - `links.<part>.massKg` or `densityKgM3`: inertial hints
517
+ - `joints.<joint>.effort|velocity|damping|friction`: simulator tuning
518
+ - `plugins.diffDrive`: diff-drive plugin wiring for Gazebo
519
+ - `world.generateDemoWorld`: emit a simple obstacle-course world alongside the model
520
+
521
+ Example:
522
+
523
+ ```javascript
524
+ robotExport({
525
+ assembly: mech,
526
+ modelName: "Forge Scout",
527
+ links: {
528
+ Base: { massKg: 12 },
529
+ },
530
+ plugins: {
531
+ diffDrive: {
532
+ leftJoints: ["leftFront", "leftRear"],
533
+ rightJoints: ["rightFront", "rightRear"],
534
+ wheelSeparationMm: 320,
535
+ wheelRadiusMm: 72,
536
+ },
537
+ },
538
+ });
539
+ ```
540
+
541
+ ## 3D Boolean Operations
542
+
543
+ ### `union(...shapes)`
544
+ Combines shapes (additive).
545
+
546
+ ```javascript
547
+ const combined = union(
548
+ box(50, 50, 10),
549
+ cylinder(20, 15).translate(25, 25, 10)
550
+ );
551
+
552
+ const parts = [
553
+ box(50, 50, 10),
554
+ cylinder(20, 15).translate(25, 25, 10),
555
+ ];
556
+ const combinedFromArray = union(parts);
557
+ ```
558
+
559
+ ### `difference(...shapes)`
560
+ Subtracts shapes[1..n] from shapes[0].
561
+
562
+ ```javascript
563
+ const plate = box(100, 100, 5);
564
+ const hole1 = cylinder(6, 10).translate(25, 50, 0);
565
+ const hole2 = cylinder(6, 10).translate(75, 50, 0);
566
+ const result = difference(plate, hole1, hole2);
567
+ const resultFromArray = difference([plate, hole1, hole2]);
568
+
569
+ // Or using method syntax:
570
+ const result = plate.subtract(hole1, hole2);
571
+ const sameResult = plate.subtract([hole1, hole2]);
572
+ ```
573
+
574
+ ### `intersection(...shapes)`
575
+ Keeps only overlapping volume.
576
+
577
+ ```javascript
578
+ const overlap = intersection(
579
+ sphere(30),
580
+ box(40, 40, 40, true)
581
+ );
582
+
583
+ const overlapFromArray = intersection([
584
+ sphere(30),
585
+ box(40, 40, 40, true),
586
+ ]);
587
+ ```
588
+
589
+ ### Method Syntax
590
+ Shapes also have boolean methods:
591
+
592
+ ```javascript
593
+ shape.add(other1, other2)
594
+ shape.add([other1, other2])
595
+ shape.subtract(other1, other2)
596
+ shape.subtract([other1, other2])
597
+ shape.intersect(other1, other2)
598
+ shape.intersect([other1, other2])
599
+ ```
600
+
601
+ ## Group
602
+
603
+ ### `group(...items)`
604
+ Groups multiple shapes/sketches for joint transforms without merging them into a single mesh. Unlike `union`, colors and individual identities are preserved.
605
+
606
+ **Parameters:**
607
+ - `...items` (Shape | Sketch | TrackedShape | ShapeGroup | `{ name, shape? | sketch? | group? }`) - Items to group (nested groups allowed)
608
+
609
+ **Returns:** `ShapeGroup`
610
+
611
+ ```javascript
612
+ const base = box(100, 100, 5).color('#888888');
613
+ const column = cylinder(40, 5).translate(50, 50, 5).color('#4488cc');
614
+
615
+ // Group them — they stay separate but transform together
616
+ const assembly = group(base, column).translate(200, 0, 0);
617
+ return assembly;
618
+ ```
619
+
620
+ Named child descriptors are useful when the group will be flattened later, especially inside assemblies:
621
+
622
+ ```javascript
623
+ const shell = box(80, 60, 24).color('#6e7b88');
624
+ const lid = box(80, 60, 4).translate(0, 0, 24).color('#c9d2db');
625
+
626
+ const housing = group(
627
+ { name: 'Shell', shape: shell },
628
+ { name: 'Lid', shape: lid },
629
+ );
630
+ ```
631
+
632
+ When that group is returned directly, each named child keeps its own viewport object. When the group is used as an assembly part, Forge uses those child names to produce labels such as `Base Assembly.Lid` instead of `Base Assembly.2`.
633
+
634
+ ### ShapeGroup Methods
635
+
636
+ **Transforms** — all chainable, return a new ShapeGroup. Placement references (see below) are transformed along with the children.
637
+
638
+ ```javascript
639
+ group.translate(x, y, z)
640
+ group.moveTo(x, y, z)
641
+ group.moveToLocal(target, x, y, z)
642
+ group.attachTo(target, targetAnchor, selfAnchor?, offset?)
643
+ group.rotate(x, y, z)
644
+ group.rotateAround(axis, angleDeg, pivot?)
645
+ group.rotateAroundTo(axis, pivot, movingPoint, targetPoint, opts?)
646
+ group.pointAlong(direction)
647
+ group.transform(m)
648
+ group.scale(v)
649
+ group.mirror(normal)
650
+ group.color(hex) // applies to all children
651
+ group.clone()
652
+ group.duplicate() // alias
653
+ ```
654
+
655
+ **Placement references** — same API as `Shape`. References survive all transforms and `importGroup()` round-trips.
656
+
657
+ ```javascript
658
+ group.withReferences({ points?, edges?, surfaces?, objects? })
659
+ group.referenceNames(kind?)
660
+ group.referencePoint(ref)
661
+ group.placeReference(ref, target, offset?)
662
+ ```
663
+
664
+ **Child access** — get a named child as a live `Shape`/`TrackedShape`/`ShapeGroup`.
665
+
666
+ ```javascript
667
+ group.child(name) // throws if name not found
668
+ group.children // raw array
669
+ group.childNames // name array (parallel to children)
670
+ group.childName(index) // name at index
671
+ ```
672
+
673
+ `group.rotateAround(...)` is convenience sugar for `group.transform(Transform.rotationAxis(...))`.
674
+ `group.rotateAroundTo(...)` is convenience sugar for `group.transform(Transform.rotateAroundTo(...))`.
675
+ `group.pointAlong(...)` is convenience sugar for a group-wide axis rotation from Z to `direction`.
676
+
677
+ ```javascript
678
+ const hingeY = 40;
679
+ const lid = group(shell, logo);
680
+
681
+ const openedA = lid.rotateAround([1, 0, 0], 35, [0, hingeY, 0]); // sugar
682
+ const openedB = lid.transform(Transform.rotationAxis([1, 0, 0], 35, [0, hingeY, 0])); // equivalent
683
+
684
+ const laidDown = lid.pointAlong([1, 0, 0]); // same intent as Shape/TrackedShape.pointAlong
685
+ ```
686
+
687
+ ### ShapeGroup and placement references
688
+
689
+ `group()` fully supports `.withReferences()`, `.placeReference()`, and `.referencePoint()` — the same API available on `Shape` and `TrackedShape`. References survive every transform (`translate`, `rotate`, `scale`, `mirror`, `transform`, `rotateAround`, etc.).
690
+
691
+ ```javascript
692
+ const bracketGroup = group(
693
+ { name: 'Bracket Left', shape: leftBracket },
694
+ { name: 'Bracket Right', shape: rightBracket },
695
+ { name: 'Dowel', shape: dowel },
696
+ ).withReferences({
697
+ points: {
698
+ mountCenter: [0, 0, 0],
699
+ topCenter: [34, 30, 40],
700
+ },
701
+ });
702
+
703
+ // Place the group by a named reference point
704
+ const placed = bracketGroup.placeReference('mountCenter', [100, 0, 0]);
705
+
706
+ // Reference survives the translation:
707
+ const pt = placed.referencePoint('mountCenter'); // → [100, 0, 0]
708
+ ```
709
+
710
+ When a ShapeGroup is returned from a script, each child becomes a separate viewport object with its own visibility/color controls. Named children keep those names in the viewport/object tree.
711
+
712
+ ## 3D Anchor Positioning
713
+
714
+ ### `.attachTo(target, targetAnchor, selfAnchor?, offset?)`
715
+ Position a shape relative to another using named 3D anchor points based on bounding boxes.
716
+
717
+ Available on both `Shape` and `TrackedShape`.
718
+
719
+ **Parameters:**
720
+ - `target` (Shape | TrackedShape) — The shape to attach to
721
+ - `targetAnchor` (Anchor3D) — Point on target
722
+ - `selfAnchor` (Anchor3D, optional) — Point on this shape to align. Default: 'center'
723
+ - `offset` ([number, number, number], optional) — Additional offset after alignment
724
+
725
+ **Anchor3D values:**
726
+ - `'center'` — bounding box center
727
+ - Face centers (1 axis pinned): `'front'` (−Y), `'back'` (+Y), `'left'` (−X), `'right'` (+X), `'top'` (+Z), `'bottom'` (−Z)
728
+ - Edge midpoints (2 axes pinned): `'front-left'`, `'front-right'`, `'back-left'`, `'back-right'`, `'top-front'`, `'top-back'`, `'top-left'`, `'top-right'`, `'bottom-front'`, `'bottom-back'`, `'bottom-left'`, `'bottom-right'`
729
+ - True corners (3 axes pinned): `'top-front-left'`, `'top-front-right'`, `'top-back-left'`, `'top-back-right'`, `'bottom-front-left'`, `'bottom-front-right'`, `'bottom-back-left'`, `'bottom-back-right'`
730
+
731
+ Anchor word order is flexible for built-ins: `'front-left'` and `'left-front'` are treated the same.
732
+
733
+ **Returns:** Same type as caller (Shape or TrackedShape)
734
+
735
+ ```javascript
736
+ const base = box(100, 100, 10);
737
+ const column = cylinder(50, 8);
738
+
739
+ // Place column on top of base, centered
740
+ const placed = column.attachTo(base, 'top', 'bottom');
741
+
742
+ // Stack boxes: place b on top of a, aligned at back-left corner
743
+ const a = box(50, 50, 20);
744
+ const b = box(30, 30, 10);
745
+ const stacked = b.attachTo(a, 'top-back-left', 'bottom-back-left');
746
+
747
+ // Place with offset: center on top, then shift 10mm right
748
+ const shifted = column.attachTo(base, 'top', 'bottom', [10, 0, 0]);
749
+ ```
750
+
751
+ ### `.onFace(parent, face, opts?)`
752
+ Place a shape on a specific face of a parent shape. Think of it like sticking a label on a box surface.
753
+
754
+ **Parameters:**
755
+ - `parent` (Shape | TrackedShape) — The parent shape
756
+ - `face` ('front' | 'back' | 'left' | 'right' | 'top' | 'bottom') — Which face to place on
757
+ - `opts` (object, optional):
758
+ - `u` (number) — Horizontal offset within the face (from center). Default: 0
759
+ - `v` (number) — Vertical offset within the face (from center). Default: 0
760
+ - `protrude` (number) — How far the child sticks out from the face. Default: 0
761
+
762
+ **Face coordinate mapping (u, v):**
763
+ - front/back: u = left/right (X), v = up/down (Z)
764
+ - left/right: u = forward/back (Y), v = up/down (Z)
765
+ - top/bottom: u = left/right (X), v = forward/back (Y)
766
+
767
+ **Returns:** Same type as caller
768
+
769
+ ```javascript
770
+ const body = box(100, 40, 60, true);
771
+
772
+ // Vent on front face, centered, 15mm below center, protruding 2mm
773
+ const vent = box(80, 2, 12, true).color('#333')
774
+ .onFace(body, 'front', { v: -15, protrude: 2 });
775
+
776
+ // Display near top-right of front face
777
+ const display = box(35, 1.5, 8, true).color('#00ddee')
778
+ .onFace(body, 'front', { u: 20, v: 15, protrude: 1 });
779
+
780
+ // Fan on top, protruding 5mm
781
+ const fan = cylinder(10, 40).color('#333')
782
+ .onFace(body, 'top', { protrude: 5 });
783
+
784
+ // Side vent on left face
785
+ const sideVent = box(2, 30, 40, true).color('#666')
786
+ .onFace(body, 'left', { protrude: 1 });
787
+ ```
788
+
789
+ **When to use `onFace()` vs `attachTo()`:**
790
+ - `onFace()` — placing surface details (vents, displays, buttons, labels) on a parent body
791
+ - `attachTo()` — stacking independent parts (column on base, unit on wall)
792
+
793
+ ## Advanced 3D Operations
794
+
795
+ ### `hull3d(...args)`
796
+ Convex hull of multiple shapes and/or points.
797
+
798
+ ```javascript
799
+ const hull = hull3d(
800
+ sphere(10),
801
+ sphere(10).translate(50, 0, 0),
802
+ [25, 0, 30], // bare point
803
+ );
804
+ ```
805
+
806
+ ### `levelSet(sdf, bounds, edgeLength, level?)`
807
+ Create a shape from a signed distance function (SDF). Positive = inside.
808
+
809
+ ```javascript
810
+ const gyroid = levelSet(
811
+ ([x, y, z]) => Math.sin(x) * Math.cos(y) + Math.sin(y) * Math.cos(z) + Math.sin(z) * Math.cos(x),
812
+ { min: [-10, -10, -10], max: [10, 10, 10] },
813
+ 0.5, // edge length (resolution)
814
+ );
815
+ ```
816
+
817
+ ### Smoothing
818
+
819
+ ```javascript
820
+ // Mark edges for smoothing, then subdivide
821
+ const smooth = box(50, 50, 50, true)
822
+ .smoothOut(60) // edges sharper than 60° get smoothed
823
+ .refine(4); // subdivide 4 times
824
+
825
+ // Or refine by edge length / tolerance
826
+ shape.refineToLength(2); // max edge length 2mm
827
+ shape.refineToTolerance(0.1); // max deviation 0.1mm from smooth surface
828
+ ```
829
+
830
+ ### Cutting
831
+
832
+ ```javascript
833
+ // Split by another shape → [inside, outside]
834
+ const [inside, outside] = shape.split(cutter);
835
+
836
+ // Split by infinite plane → [positive side, opposite side]
837
+ const [above, below] = shape.splitByPlane([0, 0, 1], 10); // Z=10 plane
838
+
839
+ // Trim: keep the positive side of the plane
840
+ const trimmed = shape.trimByPlane([0, 0, 1], 10);
841
+ ```
842
+
843
+ #### `shape.shell(thickness, options?)`
844
+ Hollow out a supported solid by keeping the outside wall and subtracting an inward cavity.
845
+
846
+ Current compiler-backed `shell()` support is intentionally narrow:
847
+
848
+ - compile-covered `box()`, `cylinder()`, and straight `Sketch.extrude()` solids
849
+ - optional `openFaces: ['top' | 'bottom']`
850
+ - rigid transforms before shelling are preserved through both lowerers
851
+ - defended named-face queries on the resulting outer shell faces plus created inner faces where Forge can model them exactly
852
+
853
+ Not supported yet:
854
+
855
+ - tapered extrudes (`scaleTop`)
856
+ - scale transforms before shelling
857
+ - already-shelled results
858
+ - general boolean, `revolve()`, `loft()`, `sweep()`, `sphere()`, hull, and plane-trim bases
859
+
860
+ Forge keeps `shell` as semantic compiler intent, then lowers it into backend-supported boolean/extrude/cylinder plans for both Manifold and CadQuery/OCCT.
861
+
862
+ Supported named-face subset on the shell result:
863
+
864
+ - preserved outer faces stay queryable on defended bases
865
+ - created inner faces use names like `inner-top`, `inner-bottom`, `inner-side`, or `inner-side-right`
866
+ - downstream `onFace(result, 'inner-side-right', ...)` placement is defended on the planar members of that subset
867
+ - generic straight extrudes still shell exactly, but named created-face support is currently limited to the defended profile families Forge can model directly (`rect`, `roundedRect`, `circle`)
868
+
869
+ ```javascript
870
+ const cup = roundedRect(80, 50, 6, true)
871
+ .extrude(30)
872
+ .translate(4, -3, 2)
873
+ .shell(2.5, { openFaces: ['top'] });
874
+ ```
875
+
876
+ #### `shape.hole(face, options)`
877
+ Compiler-owned circular hole workflow anchored to a face/workplane query.
878
+
879
+ - `depth` omitted = through-hole
880
+ - `depth` provided = blind hole
881
+ - `upToFace` = stop on a queried planar face parallel to the hole direction
882
+ - `extent: { forward, reverse? }` = two-sided extent with forward/reverse termination options
883
+ - `u` / `v` place the hole in face-local coordinates
884
+ - `counterbore: { diameter, depth }` adds a wider cylindrical recess at the entry
885
+ - `countersink: { diameter, angleDeg? }` adds a conical entry (default `90°`)
886
+ - `thread: { designation?, pitch?, class?, handedness?, depth?, modeled? }` carries thread intent metadata
887
+ - `depth` and `upToFace` are mutually exclusive with `extent`
888
+ - `counterbore` and `countersink` are mutually exclusive
889
+
890
+ ```javascript
891
+ const block = roundedRect(90, 60, 8, true).extrude(24);
892
+ const exitFace = block.face('bottom');
893
+
894
+ const body = block
895
+ .hole('front', { diameter: 8, u: 0, v: 2 }) // through
896
+ .hole('top', { diameter: 6, u: -18, v: 10, depth: 10 }) // blind
897
+ .hole('top', {
898
+ diameter: 5,
899
+ u: 18,
900
+ v: 10,
901
+ upToFace: exitFace,
902
+ counterbore: { diameter: 9, depth: 4 },
903
+ });
904
+ ```
905
+
906
+ Supported subset:
907
+
908
+ - compile-covered target bodies
909
+ - canonical faces, tracked planar faces, and `FaceRef` targets
910
+ - through, blind, and planar `upToFace` circular holes
911
+ - two-sided extents with forward/reverse termination (blind or `upToFace` per side)
912
+ - counterbore and countersink entry variants on one-sided hole extents
913
+ - thread metadata for deferred thread intent (modeled threads not yet supported)
914
+ - exact lowering through both Manifold and CadQuery/OCCT
915
+ - created hole faces in the defended subset:
916
+ - `wall` on all supported hole variants
917
+ - `floor` on blind holes
918
+ - `cap` on reverse blind two-sided holes
919
+ - `counterbore-wall` and `counterbore-floor` on counterbored holes
920
+ - `countersink-wall` on countersunk holes
921
+ - preserved non-host faces stay queryable where Forge can defend them
922
+ - rewritten host/exit faces now stay queryable as defended descendant regions when Forge can keep one stable source surface/frame
923
+ - reusing the same `upToFace` stop plane through later rewrites is supported when you keep a `FaceRef` from the earlier face (`const exitFace = block.face('bottom')`)
924
+
925
+ Not supported yet:
926
+
927
+ - non-planar or non-parallel `upToFace` termination faces
928
+ - two-sided extents combined with counterbore or countersink heads
929
+ - modeled helical threads (pass thread metadata with `modeled: false` or omitted for deferred intent)
930
+ - combined counterbore+countersink heads
931
+ - drafted/tapered main holes
932
+ - segmented polygonal hole cutters
933
+ - runtime-only target bodies without compiler intent
934
+
935
+ #### `shape.cutout(sketch, options?)`
936
+ Compiler-owned cut-extrude workflow using a sketch already placed with `Sketch.onFace(...)`.
937
+
938
+ - `depth` omitted = through-cut
939
+ - `depth` provided = blind cut
940
+ - `upToFace` = stop on a queried planar face parallel to the cut direction
941
+ - `extent: { forward, reverse? }` = two-sided extent with forward/reverse termination options
942
+ - `taperScale` = draft angle for tapered cuts (uniform or `[x, y]` pair)
943
+ - the sketch must carry semantic workplane placement from `onFace(...)`
944
+
945
+ ```javascript
946
+ const block = roundedRect(90, 60, 8, true).extrude(24);
947
+ const exitFace = block.face('bottom');
948
+ const pocket = roundedRect(18, 10, 2, true)
949
+ .onFace(block, 'top', { u: 14, v: -8, selfAnchor: 'center' });
950
+
951
+ const body = block.cutout(pocket, { upToFace: exitFace });
952
+ ```
953
+
954
+ Supported subset:
955
+
956
+ - compile-covered sketch profiles that CadQuery/OCCT already supports
957
+ - sketches placed on queried faces via `onFace(...)`
958
+ - through, blind, and planar `upToFace` cut extents
959
+ - two-sided extents with forward/reverse termination
960
+ - tapered cuts via `taperScale` on circle, rect, and roundedRect profiles
961
+ - exact/runtime parity through the shared compiler node family
962
+ - created cut faces in the defended subset:
963
+ - `floor` on blind cuts
964
+ - `wall` on circular cuts
965
+ - `wall-bottom`, `wall-right`, `wall-top`, `wall-left` on rectangular and rounded-rectangle cuts
966
+ - `cap` on reverse blind two-sided cuts
967
+ - preserved non-host faces stay queryable where Forge can defend them
968
+ - rewritten host/exit faces now stay queryable as defended descendant regions when Forge can keep one stable source surface/frame
969
+ - reusing the same `upToFace` stop plane through later rewrites is supported when you keep a `FaceRef` from the earlier face
970
+
971
+ Not supported yet:
972
+
973
+ - free-floating sketches without face/workplane provenance
974
+ - non-planar or non-parallel `upToFace` termination faces
975
+ - two-sided extents combined with `taperScale`
976
+ - named created-wall support for arbitrary boolean/offset/projected cut profiles
977
+
978
+ #### `sheetMetal(options)`
979
+ Compiler-owned sheet-metal v1 builder.
980
+
981
+ `sheetMetal(...)` returns a `SheetMetalPart`, not a solid directly.
982
+
983
+ Core flow:
984
+
985
+ - define the base panel, thickness, bend radius, and explicit `kFactor`
986
+ - add `90°` edge flanges with `.flange(...)`
987
+ - add planar panel/flange cutouts with `.cutout(...)`
988
+ - materialize either `.folded()` or `.flatPattern()`
989
+
990
+ ```javascript
991
+ const cover = sheetMetal({
992
+ panel: { width: 180, height: 110 },
993
+ thickness: 1.5,
994
+ bendRadius: 2,
995
+ bendAllowance: { kFactor: 0.42 },
996
+ cornerRelief: { size: 4 },
997
+ })
998
+ .flange('top', { length: 18 })
999
+ .flange('right', { length: 18 })
1000
+ .flange('bottom', { length: 18 })
1001
+ .flange('left', { length: 18 })
1002
+ .cutout('panel', rect(72, 36, true), { selfAnchor: 'center' })
1003
+ .cutout('flange-right', roundedRect(26, 10, 5, true), { selfAnchor: 'center' });
1004
+
1005
+ const folded = cover.folded();
1006
+ const flat = cover.flatPattern();
1007
+ ```
1008
+
1009
+ Supported v1 subset:
1010
+
1011
+ - one base panel
1012
+ - up to four `90°` edge flanges
1013
+ - constant thickness
1014
+ - explicit bend radius plus explicit K-factor bend metadata
1015
+ - rectangular corner reliefs
1016
+ - planar cutouts on the panel and existing flange regions
1017
+ - defended semantic regions such as `panel`, `flange-right`, and `bend-right`
1018
+
1019
+ Not supported yet:
1020
+
1021
+ - arbitrary solid conversion into sheet metal
1022
+ - hems, jogs, lofted bends, or broader miter-corner logic
1023
+ - nonuniform thickness
1024
+ - cutouts directly on bend regions
1025
+
1026
+ Use [sheet-metal.md](sheet-metal.md) for the full sheet-metal contract and the maintained `folded-service-panel-cover` proof model.
1027
+
1028
+ ### Warping
1029
+
1030
+ ```javascript
1031
+ // Deform vertices with arbitrary function
1032
+ const warped = box(50, 50, 50, true).warp(([x, y, z]) => {
1033
+ // Twist around Z axis
1034
+ const angle = z * 0.05;
1035
+ const cos = Math.cos(angle), sin = Math.sin(angle);
1036
+ const nx = x * cos - y * sin;
1037
+ const ny = x * sin + y * cos;
1038
+ return [nx, ny, z];
1039
+ });
1040
+ ```
1041
+
1042
+ ### Plane Operations
1043
+
1044
+ ```javascript
1045
+ // Cross-section: intersect shape with a plane → Sketch
1046
+ const section = intersectWithPlane(shape, { plane: 'XY', offset: 10 });
1047
+
1048
+ // Project: flatten shape onto a plane → Sketch
1049
+ const shadow = projectToPlane(shape, { origin: [0, 0, 0], normal: [0, 0, 1] });
1050
+ ```
1051
+
1052
+ `projectToPlane()` always works as a runtime modeling utility.
1053
+
1054
+ Compiler-owned replay is narrower today:
1055
+ - supported for downstream exact export when the projected source reduces to one defended planar projection basis:
1056
+ - plain `extrude()` / `box()` / `cylinder()` sources in their native XY basis
1057
+ - `Sketch.onFace()`-placed straight extrusions on a matching parallel target plane
1058
+ - compatible `shell()` / through-`hole()` / through-`cutout()` descendants aligned to that same basis
1059
+ - boolean `union()` combinations of compatible projected operands, including mirrored/patterned descendants
1060
+ - aligned blind holes/cuts keep the same replayed silhouette, while aligned through holes/cuts subtract their projected profile from it
1061
+ - the target plane still has to stay parallel to that defended basis without introducing in-plane shear
1062
+ - boolean `difference()` / `intersection()` sources, trim/fillet/chamfer silhouette changes, and non-parallel host workplanes still return a usable runtime sketch but fall back to explicit compiler diagnostics instead of pretending the projection is exact-safe
1063
+
1064
+ ## 2D Sketches
1065
+
1066
+ This file intentionally avoids repeating full 2D API signatures that already live in dedicated docs.
1067
+
1068
+ Use these canonical files:
1069
+
1070
+ - [sketch-core.md](sketch-core.md) - `Sketch` basics, queries, anchors, color, clone/duplicate
1071
+ - [sketch-primitives.md](sketch-primitives.md) - `rect`, `circle2d`, `roundedRect`, `polygon`, `ngon`, `ellipse`, `slot`, `star`
1072
+ - [sketch-path.md](sketch-path.md) - `path()` builder and `stroke(...)`
1073
+ - [sketch-transforms.md](sketch-transforms.md) - translate/rotate/scale/mirror for sketches
1074
+ - [sketch-booleans.md](sketch-booleans.md) - `union2d`/`difference2d`/`intersection2d`/`hull2d` and method forms
1075
+ - [sketch-operations.md](sketch-operations.md) - `offset`, `filletCorners`, `simplify`, `warp`, hull
1076
+ - [sketch-on-face.md](sketch-on-face.md) - sketch placement on planar faces
1077
+ - [sketch-extrude.md](sketch-extrude.md) - `extrude` and `revolve`
1078
+ - [sketch-anchor.md](sketch-anchor.md) - 2D anchor-based positioning
1079
+ - [entities.md](entities.md) - constrained sketches, named entities, topology-aware utilities
1080
+
1081
+ Integration rule: start with the smallest relevant doc set and add more only when the task expands.
1082
+
1083
+ ### Curves & Surfacing
1084
+
1085
+ #### `spline2d(points, options?)`
1086
+ Build a smooth Catmull-Rom spline sketch from 2D control points.
1087
+
1088
+ **Options:**
1089
+ - `closed` (boolean) - Default: `true`
1090
+ - `tension` (number, 0..1) - Default: `0.5`
1091
+ - `samplesPerSegment` (number) - Default: `16`
1092
+ - `strokeWidth` (number) - Required when `closed: false` (creates a stroked solid)
1093
+ - `join` (`'Round' | 'Square'`) - Stroke corner style for open splines. Default: `'Round'`
1094
+
1095
+ ```javascript
1096
+ const closed = spline2d([[20,0],[12,10],[0,12],[-12,10],[-20,0],[0,-14]]);
1097
+ const openRail = spline2d([[0,0],[30,20],[70,10]], { closed: false, strokeWidth: 3 });
1098
+ ```
1099
+
1100
+ #### `spline3d(points, options?)` / `Curve3D`
1101
+ Create a reusable 3D spline curve object.
1102
+
1103
+ `Curve3D` methods:
1104
+ - `.sample(count?)`
1105
+ - `.sampleBySegment(samplesPerSegment?)`
1106
+ - `.pointAt(t)` where `t` is `[0..1]`
1107
+ - `.tangentAt(t)`
1108
+ - `.length(samples?)`
1109
+
1110
+ ```javascript
1111
+ const rail = spline3d(
1112
+ [[0,0,0], [20,10,30], [40,0,60]],
1113
+ { tension: 0.45 }
1114
+ );
1115
+ ```
1116
+
1117
+ #### `loft(profiles, heights, options?)`
1118
+ Loft between multiple sketches along Z stations.
1119
+
1120
+ This implementation interpolates signed-distance fields and meshes via level-set extraction, so profiles can differ in vertex count/topology. Forge now records that loft intent in the compiler graph as well, and compatible loft stacks can export through the CadQuery/OCCT exact route even though viewport/runtime geometry remains sampled.
1121
+
1122
+ Performance note: `loft()` is significantly heavier than primitive/extrude/revolve paths. Use loft only when profile interpolation is required. If your part is axis-symmetric (bottles, vases, knobs, lathe-style parts), prefer `revolve()` for much faster generation.
1123
+
1124
+ **Parameters:**
1125
+ - `profiles` (`Sketch[]`) - At least 2
1126
+ - `heights` (`number[]`) - Same length as `profiles`, strictly increasing
1127
+ - `options`:
1128
+ - `edgeLength` (number) - Mesh resolution
1129
+ - `boundsPadding` (number) - Extra level-set bounds padding
1130
+
1131
+ ```javascript
1132
+ const body = loft(
1133
+ [circle2d(20), roundedRect(30, 24, 6, true), circle2d(10)],
1134
+ [0, 40, 70],
1135
+ { edgeLength: 1.0 }
1136
+ );
1137
+ ```
1138
+
1139
+ #### `sweep(profile, path, options?)`
1140
+ Sweep a 2D profile along a 3D path (`Curve3D` or point polyline).
1141
+
1142
+ Performance note: `sweep()` also uses level-set meshing internally. Prefer direct primitives/extrude/revolve when they can express the same shape. Forge records the sampled sweep path in the compiler graph, so compatible sweeps can export through the CadQuery/OCCT exact route using that canonical path representation.
1143
+
1144
+ **Parameters:**
1145
+ - `profile` (`Sketch`) - Local cross-section in XY plane
1146
+ - `path` (`Curve3D | [x,y,z][]`)
1147
+ - `options`:
1148
+ - `samples` (number) - Sampling count for `Curve3D` paths (default `48`)
1149
+ - `edgeLength` (number) - Mesh resolution
1150
+ - `boundsPadding` (number) - Extra level-set bounds padding
1151
+ - `up` (`[x,y,z]`) - Preferred frame-up vector
1152
+
1153
+ ```javascript
1154
+ const tubePath = spline3d([[0,0,0], [20,0,20], [40,10,30]]);
1155
+ const tube = sweep(circle2d(3), tubePath, { samples: 36, edgeLength: 0.7 });
1156
+ ```
1157
+
1158
+ ## Entities, Constraints, and Patterns
1159
+
1160
+ `reference.md` delegates detailed entity/constraint coverage to [entities.md](entities.md) to avoid duplication.
1161
+
1162
+ Use [entities.md](entities.md) for:
1163
+
1164
+ - `Point2D`, `Line2D`, `Circle2D`, `Rectangle2D`
1165
+ - `TrackedShape` topology access (`face`, `edge`, `faceNames`, `edgeNames`, `toShape`)
1166
+ - `constrainedSketch()` and `Constraint.*`
1167
+ - `linearPattern`, `circularPattern`, `mirrorCopy`
1168
+ - `filletEdge`, `chamferEdge`, `arcBridgeBetweenRects`
1169
+ - Utility helpers like `degrees(...)` and `radians(...)`
1170
+
1171
+ ## Multi-File Projects
1172
+
1173
+ ForgeCAD supports multi-file projects. Files are either **sketches** (`.sketch.js`, return a `Sketch`), **parts** (`.forge.js`, return a `Shape`, `TrackedShape`, or `ShapeGroup`), or **SVG assets** (`.svg`, parsed into a `Sketch`).
1174
+
1175
+ ### File Types
1176
+ - `*.sketch.js` — 2D sketch file; when used with `importSketch()`, must return a `Sketch`
1177
+ - `*.forge.js` — 3D Forge file:
1178
+ - when used with `importPart()`, must return a `Shape` or `TrackedShape`
1179
+ - when used with `importGroup()`, must return a `ShapeGroup` (via `group(...)`)
1180
+ - `*.svg` — vector artwork file, imported as sketch geometry
1181
+
1182
+ ### Import Path Resolution
1183
+ - `./file.forge.js`, `./file.sketch.js`, `./asset.svg` (and `../...`) resolve relative to the file that calls imports
1184
+ - Bare paths like `api/bracket.forge.js` resolve from the opened project root
1185
+ - Leading `/` is treated as project-root relative
1186
+
1187
+ ### `importSketch(fileName, paramOverridesOrSvgOptions?)`
1188
+ Imports a sketch and returns `Sketch`.
1189
+
1190
+ - For `*.sketch.js`: executes the file (must return `Sketch`)
1191
+ - For `*.svg`: parses vector geometry into a `Sketch`
1192
+
1193
+ **Parameters:**
1194
+ - `fileName` (string) — Import path (e.g. `"./profile.sketch.js"` or `"api/profile.sketch.js"`)
1195
+ - `paramOverridesOrSvgOptions` (optional object)
1196
+ - For `*.sketch.js`: import-time param overrides by param name
1197
+ - For `*.svg`: SVG import options (see `importSvgSketch`)
1198
+
1199
+ **Returns:** `Sketch`
1200
+
1201
+ ```javascript
1202
+ // In a .forge.js file:
1203
+ const profile = importSketch("bracket-profile.sketch.js", {
1204
+ "Width": 42,
1205
+ "Height": 18,
1206
+ });
1207
+ return profile.extrude(50);
1208
+ ```
1209
+
1210
+ ```javascript
1211
+ // Import SVG and keep only the largest connected region
1212
+ const logo = importSketch("assets/logo.svg", {
1213
+ include: "auto",
1214
+ regionSelection: "largest",
1215
+ flattenTolerance: 0.25,
1216
+ });
1217
+ return logo.extrude(2);
1218
+ ```
1219
+
1220
+ ### `importSvgSketch(fileName, options?)`
1221
+ Parses an SVG file and returns a `Sketch`.
1222
+
1223
+ **Parameters:**
1224
+ - `fileName` (string) — Import path to an `.svg`
1225
+ - `options` (optional object):
1226
+ - `include`: `'auto' | 'fill' | 'stroke' | 'fill-and-stroke'` (default: `'auto'`)
1227
+ - `regionSelection`: `'all' | 'largest'` (default: `'all'`)
1228
+ - `maxRegions`: number (largest-first cap)
1229
+ - `minRegionArea`: number
1230
+ - `minRegionAreaRatio`: number (fraction of largest region area)
1231
+ - `flattenTolerance`: number (curve discretization tolerance)
1232
+ - `arcSegments`: number (minimum arc segment count)
1233
+ - `scale`: number (uniform scale factor)
1234
+ - `maxWidth`: number (uniformly downscale to keep final sketch width within this limit)
1235
+ - `maxHeight`: number (uniformly downscale to keep final sketch height within this limit)
1236
+ - `centerOnOrigin`: boolean (default: `false`, recenters final sketch bounds center to `(0, 0)`)
1237
+ - `simplify`: number (final simplify tolerance, default: `0`)
1238
+ - `invertY`: boolean (default: `true`, converts SVG Y-down to CAD Y-up)
1239
+
1240
+ **Returns:** `Sketch`
1241
+
1242
+ ```javascript
1243
+ const badge = importSvgSketch("assets/badge.svg", {
1244
+ include: "fill-and-stroke",
1245
+ minRegionAreaRatio: 0.001,
1246
+ maxRegions: 8,
1247
+ maxWidth: 120,
1248
+ maxHeight: 80,
1249
+ centerOnOrigin: true,
1250
+ });
1251
+ return badge;
1252
+ ```
1253
+
1254
+ ### `importPart(fileName, paramOverrides?)`
1255
+ Executes another file and returns its result as a `Shape`. The target file may return either `Shape` or `TrackedShape` (tracked results are auto-unwrapped to `Shape`).
1256
+
1257
+ **Parameters:**
1258
+ - `fileName` (string) — Import path (e.g. `"./bracket.forge.js"` or `"api/bracket.forge.js"`)
1259
+ - `paramOverrides` (optional object) — Import-time parameter overrides by param name
1260
+
1261
+ **Returns:** `Shape` (chainable)
1262
+
1263
+ ```javascript
1264
+ // Assembly: import parts and position them
1265
+ const bracket = importPart("bracket.forge.js", { "Thickness": 4 });
1266
+ const bracket2 = importPart("bracket.forge.js", { "Thickness": 8 })
1267
+ .translate(100, 0, 0)
1268
+ .rotate(0, 0, 180);
1269
+
1270
+ return union(bracket, bracket2);
1271
+ ```
1272
+
1273
+ Imported parts can also carry named placement references:
1274
+
1275
+ ```javascript
1276
+ // widget.forge.js
1277
+ return union(base, post).withReferences({
1278
+ points: {
1279
+ mount: [0, -16, -4],
1280
+ },
1281
+ objects: {
1282
+ post,
1283
+ },
1284
+ });
1285
+ ```
1286
+
1287
+ ```javascript
1288
+ // assembly.forge.js
1289
+ const widget = importPart("widget.forge.js")
1290
+ .placeReference("mount", [120, 40, 0]);
1291
+
1292
+ const cap = box(18, 18, 8, true)
1293
+ .attachTo(widget, "objects.post.top", "bottom");
1294
+
1295
+ return [widget, cap];
1296
+ ```
1297
+
1298
+ ### `importGroup(fileName, paramOverrides?)`
1299
+ Executes another file and returns its result as a `ShapeGroup`. The target file **must** return a `ShapeGroup` (i.e. use `group(...)` as the final `return`). Use this when you want to import a multipart component and either transform it as a unit or access individual named children separately.
1300
+
1301
+ **Parameters:**
1302
+ - `fileName` (string) — Import path (e.g. `"./bracket-assembly.forge.js"`)
1303
+ - `paramOverrides` (optional object) — Import-time parameter overrides by param name
1304
+
1305
+ **Returns:** `ShapeGroup` (chainable)
1306
+
1307
+ ```javascript
1308
+ // bracket-assembly.forge.js ← the source file
1309
+ const thickness = param("Thickness", 4);
1310
+ const leftBracket = box(thickness, 60, 40).color('#5b7c8d');
1311
+ const rightBracket = box(thickness, 60, 40).translate(64, 0, 0).color('#5b7c8d');
1312
+ const dowel = cylinder(60, 3).rotate(90,0,0).translate(34, 60, 20).color('#d38b4d');
1313
+
1314
+ return group(
1315
+ { name: "Bracket Left", shape: leftBracket },
1316
+ { name: "Bracket Right", shape: rightBracket },
1317
+ { name: "Dowel", shape: dowel },
1318
+ ).withReferences({
1319
+ points: { mountCenter: [0, 30, 20] },
1320
+ });
1321
+ ```
1322
+
1323
+ ```javascript
1324
+ // scene.forge.js ← the consumer
1325
+ // Import and use as a whole unit
1326
+ const bracketA = importGroup("bracket-assembly.forge.js")
1327
+ .placeReference("mountCenter", [0, 0, 0]);
1328
+
1329
+ // Import with param overrides, then work on children individually
1330
+ const bracketB = importGroup("bracket-assembly.forge.js", { "Thickness": 6 });
1331
+ const leftOnly = bracketB.child("Bracket Left").color('#ff4444');
1332
+ const rightOnly = bracketB.child("Bracket Right").translate(200, 0, 0);
1333
+
1334
+ return [bracketA, leftOnly, rightOnly];
1335
+ ```
1336
+
1337
+ ### `importAssembly(fileName, paramOverrides?)`
1338
+ Executes another file and returns its result as an `ImportedAssembly`. The target file **must** return an `Assembly` instance directly (before calling `.solve()`). Use this when the source file is authored with `assembly()` and you want to access named parts or kinematic structure without rewriting it as a `group()`.
1339
+
1340
+ **Parameters:**
1341
+ - `fileName` (string) — Import path (e.g. `"./sub-arm.forge.js"`)
1342
+ - `paramOverrides` (optional object) — Import-time parameter overrides by param name
1343
+
1344
+ **Returns:** `ImportedAssembly` with the following methods:
1345
+ - `.assembly` — the raw `Assembly` for `sweepJoint`, `describe`, etc.
1346
+ - `.solve(state?)` — delegate to `Assembly.solve()`, returns `SolvedAssembly`
1347
+ - `.part(name, state?)` — returns the named part positioned at the given joint state (defaults to each joint's `default` value)
1348
+ - `.toGroup(state?)` — converts all parts to a `ShapeGroup` with children named after the assembly part names
1349
+
1350
+ ```javascript
1351
+ // sub-arm.forge.js ← the source file — returns Assembly, not solved
1352
+ const mech = assembly("Sub Arm")
1353
+ .addPart("Base", box(60, 60, 16, true))
1354
+ .addPart("Link", box(120, 20, 20).translate(0, -10, -10))
1355
+ .addRevolute("shoulder", "Base", "Link", {
1356
+ axis: [0, 1, 0],
1357
+ min: -45,
1358
+ max: 120,
1359
+ default: 30,
1360
+ frame: Transform.identity().translate(0, 0, 16),
1361
+ });
1362
+
1363
+ return mech; // return Assembly directly
1364
+ ```
1365
+
1366
+ ```javascript
1367
+ // scene.forge.js ← the consumer
1368
+ const angle = param("Angle", 45, { unit: "°" });
1369
+
1370
+ const subArm = importAssembly("sub-arm.forge.js", { "Link Length": 100 });
1371
+
1372
+ // Access a specific part positioned at a given state
1373
+ const baseShape = subArm.part("Base");
1374
+ const linkShape = subArm.part("Link", { shoulder: angle });
1375
+
1376
+ // Convert to a named ShapeGroup — children match assembly part names
1377
+ const asGroup = subArm.toGroup({ shoulder: angle });
1378
+ const base = asGroup.child("Base");
1379
+
1380
+ // Full kinematic access via the underlying assembly
1381
+ const swept = subArm.assembly.sweepJoint("shoulder", -45, 120, 20);
1382
+
1383
+ // Place two copies at different joint states
1384
+ const copy1 = subArm.toGroup({ shoulder: 45 });
1385
+ const copy2 = subArm.toGroup({ shoulder: -20 }).translate(200, 0, 0);
1386
+ return [copy1, copy2];
1387
+ ```
1388
+
1389
+ **When to use `importGroup` vs `importAssembly`:**
1390
+
1391
+ | | `importPart` | `importGroup` | `importAssembly` |
1392
+ |---|---|---|---|
1393
+ | Source returns | `Shape` or `TrackedShape` | `ShapeGroup` via `group(...)` | `Assembly` (unsolved) |
1394
+ | Result type | `Shape` | `ShapeGroup` | `ImportedAssembly` |
1395
+ | Access children | Not possible | `.child("Name")` | `.part("Name", state?)` or `.toGroup().child("Name")` |
1396
+ | Kinematic access | None | None | `.solve()`, `.assembly.sweepJoint()` |
1397
+ | Multiple poses | No | No | Yes — call `.toGroup(state)` with different states |
1398
+
1399
+ ### Import Rules
1400
+ - Circular imports are detected and throw an error
1401
+ - Imported files can be instantiated multiple times (each call is a fresh execution)
1402
+ - `paramOverrides` only affects that import call (other imports are independent)
1403
+ - Params supplied through `paramOverrides` are treated as fixed arguments for that import call
1404
+ - Relative imports (`./` / `../`) are resolved from the current file path
1405
+ - `importPart()` accepts `Shape` or `TrackedShape` results and always returns a chainable `Shape`
1406
+ - `importGroup()` accepts only `ShapeGroup` results; use `group(...)` as the return value in the source file
1407
+ - `importAssembly()` accepts only `Assembly` results; return the `assembly(...)` instance before calling `.solve()`
1408
+ - Source files can attach placement references with `.withReferences({ points, edges, surfaces, objects })` — works on both `Shape` and `ShapeGroup`
1409
+ - Imported tracked solids keep their named faces/edges as `surfaces.<faceName>` and `edges.<edgeName>` references
1410
+ - SVG import supports deterministic region filtering (`regionSelection`, `maxRegions`, area thresholds)
1411
+ - The returned `Shape`, `Sketch`, or `ShapeGroup` is fully chainable — use `.translate()`, `.rotate()`, etc.
1412
+
1413
+ ### Plain JS Module Imports
1414
+ Alongside `importPart()` / `importSketch()`, regular JS `import` / `require(...)` is supported for utility modules.
1415
+
1416
+ - If a module uses `export` / `module.exports`, that export value is used.
1417
+ - If a module has no explicit exports and uses a top-level `return`, that return value becomes the module value (including arrays).
1418
+ - Do not mix explicit exports with top-level `return` in the same module; this throws an error.
1419
+
1420
+ ```javascript
1421
+ // scene-items.js
1422
+ import { box, cylinder } from "forgecad";
1423
+
1424
+ return [
1425
+ { name: "Plate", shape: box(20, 12, 2, true) },
1426
+ { name: "Pin", shape: cylinder(14, 3, undefined, undefined, true).translate(0, 0, 8) },
1427
+ ];
1428
+ ```
1429
+
1430
+ ```javascript
1431
+ // main.forge.js
1432
+ import items from "./scene-items.js";
1433
+
1434
+ return items.map((entry, index) => ({
1435
+ name: entry.name,
1436
+ shape: entry.shape.translate(index === 0 ? -20 : 20, 0, 0),
1437
+ }));
1438
+ ```
1439
+
1440
+ ### Placement References
1441
+
1442
+ ### `.withReferences({ points?, edges?, surfaces?, objects? })`
1443
+ Attach named placement references to a `Shape`, `TrackedShape`, or `ShapeGroup`. References survive all normal transforms and import round-trips (`importPart()`, `importGroup()`).
1444
+
1445
+ **Reference kinds:**
1446
+ - `points`: exact 3D coordinates
1447
+ - `edges`: `{ start, end }` segments; default reference point is the midpoint
1448
+ - `surfaces`: `{ center, normal }`; default reference point is the center
1449
+ - `objects`: bounding boxes derived from another shape/group or explicit `{ min, max }`
1450
+
1451
+ ```javascript
1452
+ const part = union(base, post).withReferences({
1453
+ points: {
1454
+ mount: [0, -16, -4],
1455
+ },
1456
+ edges: {
1457
+ postAxis: { start: [12, 0, 4], end: [12, 0, 30] },
1458
+ },
1459
+ surfaces: {
1460
+ mountingFace: { center: [0, -16, 0], normal: [0, -1, 0] },
1461
+ },
1462
+ objects: {
1463
+ base,
1464
+ post,
1465
+ },
1466
+ });
1467
+ ```
1468
+
1469
+ ### `.referenceNames(kind?)`
1470
+ Lists named placement references on a shape.
1471
+
1472
+ ```javascript
1473
+ part.referenceNames(); // ['edges.postAxis', 'objects.base', 'objects.post', 'points.mount', ...]
1474
+ part.referenceNames('points'); // ['mount']
1475
+ ```
1476
+
1477
+ ### `.referencePoint(ref)`
1478
+ Resolve a placement reference to a world-space point.
1479
+
1480
+ Supported forms:
1481
+ - `mount` or `points.mount`
1482
+ - `edges.postAxis`
1483
+ - `edges.postAxis.start`
1484
+ - `surfaces.mountingFace`
1485
+ - `objects.post.top`
1486
+
1487
+ ```javascript
1488
+ const p = part.referencePoint("objects.post.top");
1489
+ ```
1490
+
1491
+ ### `.placeReference(ref, [x, y, z], offset?)`
1492
+ Translate a shape so the given placement reference lands on a target coordinate.
1493
+
1494
+ ```javascript
1495
+ const placed = importPart("widget.forge.js")
1496
+ .placeReference("mount", [120, 40, 0]);
1497
+ ```
1498
+
1499
+ ### `attachTo()` with named references
1500
+
1501
+ `attachTo()` still accepts the built-in 3D anchors, but it can now also consume named placement references:
1502
+
1503
+ ```javascript
1504
+ const cap = box(18, 18, 8, true)
1505
+ .attachTo(widget, "objects.post.top", "bottom");
1506
+ ```
1507
+
1508
+ ### Typical Project Structure
1509
+ ```
1510
+ my-project/
1511
+ ├── base-profile.sketch.js ← 2D cross-section
1512
+ ├── bracket.forge.js ← extrudes the sketch, adds holes
1513
+ └── assembly.forge.js ← imports multiple parts, positions them
1514
+ ```
1515
+
1516
+ ## Part Library
1517
+
1518
+ Pre-built parametric parts available via `lib.xxx()`. No imports needed.
1519
+
1520
+ ### `lib.boltHole(diameter, depth)`
1521
+ Through-hole cylinder (centered).
1522
+
1523
+ ### `lib.fastenerHole(opts)`
1524
+ Standardized metric hole helper with fits and optional counterbore/countersink.
1525
+
1526
+ ```javascript
1527
+ const m4 = lib.fastenerHole({
1528
+ size: "M4",
1529
+ fit: "normal", // close | normal | loose | tap
1530
+ depth: 12,
1531
+ counterbore: { depth: 3.5 }, // diameter auto from size unless provided
1532
+ });
1533
+ ```
1534
+
1535
+ ### `lib.counterbore(holeDia, boreDia, boreDepth, totalDepth)`
1536
+ Through-hole with wider recess at top.
1537
+
1538
+ ### `lib.tube(outerX, outerY, outerZ, wall)`
1539
+ Rectangular hollow tube.
1540
+
1541
+ ### `lib.pipe(height, outerRadius, wall, segments?)`
1542
+ Hollow cylinder.
1543
+
1544
+ ### `lib.hexNut(acrossFlats, height, holeDia)`
1545
+ Hex nut via intersection of 3 rotated slabs, with center bore.
1546
+
1547
+ ### `lib.roundedBox(x, y, z, radius)`
1548
+ Approximate rounded box via union of axis-aligned slabs.
1549
+
1550
+ ### `lib.bracket(width, height, depth, thick, holeDia?)`
1551
+ L-shaped mounting bracket with optional holes.
1552
+
1553
+ ### `lib.holePattern(rows, cols, spacingX, spacingY, holeDia, depth)`
1554
+ Grid of cylindrical holes.
1555
+
1556
+ ### `lib.spurGear(options)`
1557
+ Involute external spur gear with optional bore.
1558
+
1559
+ **Options:**
1560
+ - `module` (number) - Metric module (pitch diameter / tooth count)
1561
+ - `teeth` (integer) - Tooth count (>= 6)
1562
+ - `faceWidth` (number) - Extrusion width along Z
1563
+ - `pressureAngleDeg` (number, optional) - Default: `20`
1564
+ - `backlash` (number, optional) - Tangential backlash at pitch circle. Default: `0`
1565
+ - `clearance` (number, optional) - Root clearance. Default: `0.25 * module`
1566
+ - `addendum` (number, optional) - Tooth addendum. Default: `module`
1567
+ - `dedendum` (number, optional) - Tooth dedendum. Default: `addendum + clearance`
1568
+ - `boreDiameter` (number, optional) - Center bore diameter
1569
+ - `center` (boolean, optional) - Center extrusion around Z=0. Default: `true`
1570
+ - `segmentsPerTooth` (number, optional) - Involute sampling quality. Default: `10`
1571
+
1572
+ ```javascript
1573
+ const pinion = lib.spurGear({
1574
+ module: 1.25,
1575
+ teeth: 14,
1576
+ faceWidth: 8,
1577
+ boreDiameter: 5,
1578
+ });
1579
+ ```
1580
+
1581
+ ### `lib.faceGear(options)`
1582
+ Face gear (crown style) where teeth are on one face (`top` or `bottom`) instead of the outer rim.
1583
+
1584
+ Uses the same involute tooth sizing inputs as `lib.spurGear(...)`, then projects the tooth band axially from one side.
1585
+
1586
+ **Options:**
1587
+ - all `lib.spurGear(...)` options, plus:
1588
+ - `side` (`'top' | 'bottom'`, optional) - Which face gets the teeth. Default: `'top'`
1589
+ - `toothHeight` (number, optional) - Tooth projection height from the selected face. Default: `module`
1590
+
1591
+ ```javascript
1592
+ const face = lib.faceGear({
1593
+ module: 1.25,
1594
+ teeth: 36,
1595
+ faceWidth: 8,
1596
+ toothHeight: 1.2,
1597
+ side: 'top',
1598
+ boreDiameter: 8,
1599
+ });
1600
+ ```
1601
+
1602
+ `lib.sideGear(...)` is kept as a compatibility alias.
1603
+
1604
+ ### `lib.ringGear(options)`
1605
+ Internal ring gear with involute-derived tooth spaces.
1606
+
1607
+ **Options:**
1608
+ - `module` (number)
1609
+ - `teeth` (integer, >= 12)
1610
+ - `faceWidth` (number)
1611
+ - `pressureAngleDeg` (number, optional) - Default: `20`
1612
+ - `backlash` (number, optional) - Default: `0`
1613
+ - `clearance` (number, optional) - Default: `0.25 * module`
1614
+ - `addendum` (number, optional) - Default: `module`
1615
+ - `dedendum` (number, optional) - Default: `addendum + clearance`
1616
+ - `rimWidth` (number, optional) - Radial ring thickness outside tooth roots
1617
+ - `outerDiameter` (number, optional) - Overrides `rimWidth` if provided
1618
+ - `center` (boolean, optional) - Default: `true`
1619
+ - `segmentsPerTooth` (number, optional) - Default: `10`
1620
+
1621
+ ```javascript
1622
+ const ring = lib.ringGear({
1623
+ module: 1.25,
1624
+ teeth: 58,
1625
+ faceWidth: 10,
1626
+ rimWidth: 4,
1627
+ });
1628
+ ```
1629
+
1630
+ ### `lib.rackGear(options)`
1631
+ Linear rack gear with pressure-angle flanks.
1632
+
1633
+ **Options:**
1634
+ - `module` (number)
1635
+ - `teeth` (integer, >= 2)
1636
+ - `faceWidth` (number)
1637
+ - `pressureAngleDeg` (number, optional) - Default: `20`
1638
+ - `backlash` (number, optional) - Default: `0`
1639
+ - `clearance` (number, optional) - Default: `0.25 * module`
1640
+ - `addendum` (number, optional) - Default: `module`
1641
+ - `dedendum` (number, optional) - Default: `addendum + clearance`
1642
+ - `baseHeight` (number, optional) - Rack body thickness behind root line
1643
+ - `center` (boolean, optional) - Default: `true`
1644
+
1645
+ ```javascript
1646
+ const rack = lib.rackGear({
1647
+ module: 1.25,
1648
+ teeth: 24,
1649
+ faceWidth: 8,
1650
+ baseHeight: 3.5,
1651
+ });
1652
+ ```
1653
+
1654
+ ### `lib.bevelGear(options)`
1655
+ Conical bevel gear generated from a tapered involute extrusion.
1656
+
1657
+ **Options:**
1658
+ - `module` (number)
1659
+ - `teeth` (integer, >= 6)
1660
+ - `faceWidth` (number)
1661
+ - `pressureAngleDeg` (number, optional) - Default: `20`
1662
+ - `backlash` (number, optional) - Default: `0`
1663
+ - `clearance` (number, optional) - Default: `0.25 * module`
1664
+ - `addendum` (number, optional) - Default: `module`
1665
+ - `dedendum` (number, optional) - Default: `addendum + clearance`
1666
+ - `boreDiameter` (number, optional)
1667
+ - pitch cone setup (choose one):
1668
+ - `pitchAngleDeg` (number, optional), or
1669
+ - `mateTeeth` (+ optional `shaftAngleDeg`, default `90`) for auto pitch-angle derivation
1670
+ - `center` (boolean, optional) - Default: `true`
1671
+ - `segmentsPerTooth` (number, optional) - Default: `10`
1672
+
1673
+ ```javascript
1674
+ const bevelPinion = lib.bevelGear({
1675
+ module: 1.5,
1676
+ teeth: 18,
1677
+ faceWidth: 10,
1678
+ mateTeeth: 36,
1679
+ shaftAngleDeg: 90,
1680
+ boreDiameter: 5,
1681
+ });
1682
+ ```
1683
+
1684
+ ### `lib.gearPair(options)`
1685
+ Build or validate a spur-gear pair and return ratio/backlash/mesh diagnostics.
1686
+
1687
+ Accepts either:
1688
+ - spur gear shapes produced by `lib.spurGear(...)`, or
1689
+ - analytical specs (`{ module, teeth, ... }`) for each member
1690
+
1691
+ **Options:**
1692
+ - `pinion` (`Shape | GearPairSpec`) - input gear
1693
+ - `gear` (`Shape | GearPairSpec`) - mating output gear
1694
+ - `backlash` (number, optional) - target backlash used for auto center distance
1695
+ - `centerDistance` (number, optional) - override center distance directly
1696
+ - `place` (boolean, optional) - auto-place `gear` at +X center distance. Default: `true`
1697
+ - `phaseDeg` (number, optional) - additional Z rotation applied to placed gear before translation
1698
+
1699
+ **Returns:** `GearPairResult` with:
1700
+ - `pinion`, `gear` (shapes)
1701
+ - `jointRatio`, `speedReduction`
1702
+ - `centerDistance`, `centerDistanceNominal`, `backlash`
1703
+ - `pressureAngleDeg`, `workingPressureAngleDeg`, `contactRatio`
1704
+ - `diagnostics[]` and `status` (`ok | warn | error`)
1705
+
1706
+ ```javascript
1707
+ const pair = lib.gearPair({
1708
+ pinion: { module: 1.25, teeth: 14, faceWidth: 8, boreDiameter: 5 },
1709
+ gear: { module: 1.25, teeth: 42, faceWidth: 8, boreDiameter: 8 },
1710
+ backlash: 0.05,
1711
+ });
1712
+
1713
+ if (pair.status !== 'ok') {
1714
+ console.warn(pair.diagnostics);
1715
+ }
1716
+
1717
+ return [pair.pinion, pair.gear];
1718
+ ```
1719
+
1720
+ ### `lib.bevelGearPair(options)`
1721
+ Build or validate a bevel-gear pair and return ratio diagnostics plus recommended joint placement vectors.
1722
+
1723
+ Accepts either:
1724
+ - bevel gear shapes produced by `lib.bevelGear(...)`, or
1725
+ - analytical specs (`{ module, teeth, ... }`) for each member
1726
+
1727
+ **Options:**
1728
+ - `pinion` (`Shape | GearPairSpec`)
1729
+ - `gear` (`Shape | GearPairSpec`)
1730
+ - `shaftAngleDeg` (number, optional) - Default: `90`
1731
+ - `backlash` (number, optional)
1732
+ - `place` (boolean, optional) - Apply recommended transforms to returned shapes. Default: `true`
1733
+ - `phaseDeg` (number, optional) - Extra phase on the placed driven bevel gear
1734
+
1735
+ **Returns:** `BevelGearPairResult` with:
1736
+ - `pinion`, `gear` (shapes)
1737
+ - `jointRatio`, `speedReduction`
1738
+ - `shaftAngleDeg`, `pinionPitchAngleDeg`, `gearPitchAngleDeg`, `coneDistance`
1739
+ - `pinionAxis`, `gearAxis`, `pinionCenter`, `gearCenter` (joint setup helpers)
1740
+ - `diagnostics[]` and `status` (`ok | warn | error`)
1741
+
1742
+ ```javascript
1743
+ const bevelPair = lib.bevelGearPair({
1744
+ pinion: { module: 1.5, teeth: 18, faceWidth: 10 },
1745
+ gear: { module: 1.5, teeth: 36, faceWidth: 9 },
1746
+ shaftAngleDeg: 90,
1747
+ });
1748
+ ```
1749
+
1750
+ ### `lib.faceGearPair(options)`
1751
+ Build or validate a perpendicular pair between a face gear and a vertical spur gear.
1752
+
1753
+ Accepts either:
1754
+ - face gear shapes produced by `lib.faceGear(...)` or face-gear specs (`{ module, teeth, ... }`)
1755
+ - vertical spur shapes produced by `lib.spurGear(...)` or spur specs (`{ module, teeth, ... }`)
1756
+
1757
+ **Options:**
1758
+ - `face` (`Shape | FaceGearSpec`) - face/crown gear member
1759
+ - `vertical` (`Shape | GearPairSpec`) - mating perpendicular spur gear
1760
+ - `backlash` (number, optional) - target radial backlash for auto center distance
1761
+ - `centerDistance` (number, optional) - override center distance directly
1762
+ - `meshPlaneZ` (number, optional) - override the Z plane where the vertical gear is placed
1763
+ - `place` (boolean, optional) - auto-place `vertical`. Default: `true`
1764
+ - `phaseDeg` (number, optional) - phase rotation applied before perpendicular placement
1765
+
1766
+ **Returns:** `FaceGearPairResult` with:
1767
+ - `face`, `vertical` (shapes)
1768
+ - `jointRatio`, `speedReduction`
1769
+ - `centerDistance`, `centerDistanceNominal`, `backlash`
1770
+ - `meshPlaneZ`, `radialOverlap`
1771
+ - `diagnostics[]` and `status` (`ok | warn | error`)
1772
+
1773
+ ```javascript
1774
+ const pair = lib.faceGearPair({
1775
+ face: { module: 1.25, teeth: 36, faceWidth: 8, toothHeight: 1.2, side: 'top' },
1776
+ vertical: { module: 1.25, teeth: 12, faceWidth: 8 },
1777
+ backlash: 0.05,
1778
+ });
1779
+
1780
+ if (pair.status !== 'ok') {
1781
+ console.warn(pair.diagnostics);
1782
+ }
1783
+
1784
+ return [pair.face, pair.vertical];
1785
+ ```
1786
+
1787
+ `lib.sideGearPair(...)` is kept as a compatibility alias.
1788
+
1789
+ ### `lib.tSlotProfile(options?)`
1790
+ Build a 2D T-slot cross-section sketch.
1791
+
1792
+ This is a generic, tunable T-slot generator.
1793
+
1794
+ **Options:**
1795
+ - `size` (number) - Outer profile size. Default: `20`
1796
+ - `slotWidth` (number) - Slot mouth width. Default: `6`
1797
+ - `slotInnerWidth` (number) - Wider interior slot cavity width. Default: `10.4`
1798
+ - `slotDepth` (number) - Slot depth from outer face. Default: `6`
1799
+ - `slotNeckDepth` (number) - Narrow mouth depth before widening. Default: `1.6`
1800
+ - `wall` (number) - Outer shell thickness. Default: `1.4`
1801
+ - `web` (number) - Central cross-web thickness. Default: `2.1`
1802
+ - `centerBossDia` (number) - Center boss diameter. Default: `8.2`
1803
+ - `centerBoreDia` (number) - Center bore diameter. Default: `4.2`
1804
+ - `outerCornerRadius` (number) - Outer corner radius. Default: `1`
1805
+ - `segments` (number) - Circle smoothness for 2D bores/bosses. Default: `36`
1806
+
1807
+ **Returns:** `Sketch`
1808
+
1809
+ ```javascript
1810
+ const profile = lib.tSlotProfile();
1811
+ return profile; // 2D drawing-ready cross-section
1812
+ ```
1813
+
1814
+ ### `lib.tSlotExtrusion(length, options?)`
1815
+ Build a 3D extrusion from `lib.tSlotProfile(...)`.
1816
+
1817
+ **Parameters:**
1818
+ - `length` (number) - Extrusion length along Z
1819
+ - `options` - Same options as `lib.tSlotProfile(...)` plus:
1820
+ - `center` (boolean) - Center the length around Z=0. Default: `false`
1821
+
1822
+ **Returns:** `Shape`
1823
+
1824
+ ```javascript
1825
+ const rail = lib.tSlotExtrusion(300, { center: true });
1826
+ ```
1827
+
1828
+ ### `lib.profile2020BSlot6Profile(options?)`
1829
+ Profile-accurate 2D helper for a 20x20 B-type slot 6 section.
1830
+
1831
+ Defaults target common B-type 20x20 conventions:
1832
+ - slot width `6.0`
1833
+ - slot depth `5.5`
1834
+ - center bore `5.5`
1835
+ - center boss `8.4`
1836
+ - diagonal web width `4.4`
1837
+ - no edge pocket holes (only central bore is cut)
1838
+
1839
+ **Options:**
1840
+ - `slotWidth` (number) - Default: `6.0`
1841
+ - `slotInnerWidth` (number) - Default: `8.2`
1842
+ - `slotDepth` (number) - Default: `5.5`
1843
+ - `slotNeckDepth` (number) - Default: `1.8`
1844
+ - `centerBoreDia` (number) - Default: `5.5` (set `0` to disable)
1845
+ - `centerBossDia` (number) - Default: `8.4`
1846
+ - `diagonalWebWidth` (number) - Default: `4.4`
1847
+ - `outerCornerRadius` (number) - Default: `1.0`
1848
+ - `segments` (number) - Default: `40`
1849
+
1850
+ ```javascript
1851
+ const profile2d = lib.profile2020BSlot6Profile();
1852
+ ```
1853
+
1854
+ ### `lib.profile2020BSlot6(length, options?)`
1855
+ 3D extrusion helper built from `lib.profile2020BSlot6Profile(...)`.
1856
+
1857
+ Use `options` to override supplier-specific tolerances.
1858
+ - Supports all profile options above
1859
+ - Plus `center` (boolean) to center length about Z=0
1860
+
1861
+ ```javascript
1862
+ const profile = lib.profile2020BSlot6(500, { center: true });
1863
+ ```
1864
+
1865
+ ### Exploded-view helpers
1866
+ For scene-layout helpers such as `lib.explode(...)` and viewport explode overrides, see [../runtime/viewport.md](../runtime/viewport.md).
1867
+
1868
+ ### `lib.pipeRoute(points, radius, options?)`
1869
+ Route a pipe through 3D waypoints with smooth torus bends at corners.
1870
+
1871
+ **Parameters:**
1872
+ - `points` ([number, number, number][]) - Array of 3D waypoints
1873
+ - `radius` (number) - Pipe outer radius
1874
+ - `options` (object, optional):
1875
+ - `bendRadius` (number) - Radius of bends at corners. Default: `radius * 4`
1876
+ - `wall` (number) - Wall thickness for hollow pipe. If omitted, pipe is solid
1877
+ - `segments` (number) - Circumferential segments. Default: 32
1878
+
1879
+ **Returns:** `Shape`
1880
+
1881
+ ```javascript
1882
+ // Solid copper pipe with 90° bends
1883
+ const refrigPipe = lib.pipeRoute(
1884
+ [[0, 0, 0], [100, 0, 0], [100, 80, 0], [100, 80, 60]],
1885
+ 4,
1886
+ { bendRadius: 20 }
1887
+ ).color('#B87333');
1888
+
1889
+ // Hollow drain pipe
1890
+ const drainPipe = lib.pipeRoute(
1891
+ [[0, 0, 20], [60, 0, 20], [60, 80, 20]],
1892
+ 3,
1893
+ { bendRadius: 15, wall: 1 }
1894
+ ).color('#CCCCCC');
1895
+ ```
1896
+
1897
+ ### `lib.elbow(pipeRadius, bendRadius, angle?, options?)`
1898
+ Curved pipe section (torus arc) for connecting two pipe directions. Creates a bend at the origin.
1899
+
1900
+ **Parameters:**
1901
+ - `pipeRadius` (number) - Pipe outer radius
1902
+ - `bendRadius` (number) - Centerline bend radius
1903
+ - `angle` (number, optional) - Bend angle in degrees. Default: 90
1904
+
1905
+ **Options:**
1906
+ - `wall` (number) - Wall thickness for hollow pipe
1907
+ - `segments` (number) - Circumferential segments. Default: 32
1908
+ - `from` ([number, number, number]) - Incoming direction vector
1909
+ - `to` ([number, number, number]) - Outgoing direction vector (overrides angle)
1910
+
1911
+ **Alternative call:** `lib.elbow(pipeRadius, bendRadius, { from, to, wall, segments })`
1912
+
1913
+ ```javascript
1914
+ // Simple 90° elbow
1915
+ const bend = lib.elbow(5, 20, 90);
1916
+
1917
+ // 45° hollow elbow
1918
+ const bend45 = lib.elbow(5, 20, 45, { wall: 1.5 });
1919
+
1920
+ // Direction-based: connect Z-up pipe to X-right pipe
1921
+ const bend = lib.elbow(5, 20, { from: [0, 0, 1], to: [1, 0, 0] });
1922
+ ```
1923
+
1924
+ ### `lib.thread(diameter, pitch, length, options?)`
1925
+ External thread (helical ridge) via twisted extrusion. Returns a threaded cylinder along +Z.
1926
+
1927
+ **Options:**
1928
+ - `depth` (number) - Thread depth. Default: `pitch * 0.35`
1929
+ - `segments` (number) - Circumferential segments. Default: 36
1930
+
1931
+ ```javascript
1932
+ const m8thread = lib.thread(8, 1.25, 30);
1933
+ const smooth = lib.thread(8, 1.0, 30, { segments: 48 });
1934
+ ```
1935
+
1936
+ ### `lib.bolt(diameter, length, options?)`
1937
+ Hex bolt with real helical threads. Head at z=0, shaft extends along −Z.
1938
+
1939
+ **Options:**
1940
+ - `pitch` (number) - Thread pitch. Default: `diameter * 0.15`
1941
+ - `headHeight` (number) - Default: `diameter * 0.65`
1942
+ - `headAcrossFlats` (number) - Default: `diameter * 1.6`
1943
+ - `threadLength` (number) - Threaded portion. Default: full length
1944
+ - `segments` (number) - Circumferential segments. Default: 36
1945
+
1946
+ ```javascript
1947
+ const m8bolt = lib.bolt(8, 30);
1948
+ const custom = lib.bolt(10, 40, { pitch: 1.5, headHeight: 7 });
1949
+ ```
1950
+
1951
+ ### `lib.nut(diameter, options?)`
1952
+ Hex nut with bore, centered at origin.
1953
+
1954
+ **Options:**
1955
+ - `pitch` (number) - Default: `diameter * 0.15`
1956
+ - `height` (number) - Default: `diameter * 0.8`
1957
+ - `acrossFlats` (number) - Default: `diameter * 1.6`
1958
+ - `segments` (number) - Circumferential segments. Default: 36
1959
+
1960
+ ```javascript
1961
+ const m8nut = lib.nut(8);
1962
+ const m8nut2 = lib.nut(8, { height: 6.5, acrossFlats: 13 });
1963
+ ```
1964
+
1965
+ ## Query Methods
1966
+
1967
+ ### 3D Shape Queries
1968
+ ```javascript
1969
+ shape.volume() // Volume in mm³
1970
+ shape.surfaceArea() // Surface area in mm²
1971
+ shape.boundingBox() // { min: [x,y,z], max: [x,y,z] }
1972
+ shape.isEmpty() // true if no geometry
1973
+ shape.numTri() // Triangle count
1974
+ shape.minGap(other, 50) // Minimum distance to another shape (within search radius)
1975
+ shape.geometryInfo() // { backend, representation, fidelity, topology, sources }
1976
+ ```
1977
+
1978
+ `geometryInfo()` is the current contract boundary for future hybrid kernels. Today most results are `manifold` + `mesh-solid`; `loft()` / `sweep()` report `sampled`, and tracked extrusions report `topology: 'synthetic'`. A future OCCT/BREP backend can change these values without forcing a language rewrite.
1979
+
1980
+ For the maintained exact STEP/BREP support matrix, see [../output/brep-export.md](../output/brep-export.md).
1981
+
1982
+ ### 2D Sketch Queries
1983
+ ```javascript
1984
+ sketch.area() // Area in mm²
1985
+ sketch.bounds() // { min: [x,y], max: [x,y] }
1986
+ sketch.isEmpty() // true if no area
1987
+ sketch.numVert() // Vertex count
1988
+ ```
1989
+
1990
+ ## Returning Multiple Objects
1991
+
1992
+ Scripts can return arrays to display multiple objects in the viewport:
1993
+
1994
+ ```javascript
1995
+ // Simple array — auto-named "Object 1", "Object 2", etc.
1996
+ return [
1997
+ box(50, 50, 10),
1998
+ cylinder(20, 8).translate(25, 25, 10),
1999
+ ];
2000
+
2001
+ // Named objects with colors
2002
+ return [
2003
+ { name: "Base Plate", shape: box(100, 100, 5), color: "#888888" },
2004
+ { name: "Column", shape: cylinder(50, 10).translate(50, 50, 5), color: "#4488cc" },
2005
+ { name: "Profile", sketch: circle2d(20), color: "#ff6600" },
2006
+ ];
2007
+ ```
2008
+
2009
+ Each object gets its own visibility toggle, opacity slider, and color picker in the View Panel.
2010
+
2011
+ ### Assembly Groups
2012
+
2013
+ For complex assemblies, use nested groups to organize related parts:
2014
+
2015
+ ```javascript
2016
+ return [
2017
+ { name: "Bed Assembly", group: [
2018
+ { name: "Bed Plate", shape: bedPlate },
2019
+ { name: "Glass Bed", shape: glass },
2020
+ { name: "Heater", shape: heater },
2021
+ ]},
2022
+ { name: "Gantry", group: [
2023
+ { name: "Left Rail", shape: leftRail },
2024
+ { name: "Right Rail", shape: rightRail },
2025
+ { name: "Cross Bar", shape: crossBar },
2026
+ ]},
2027
+ ];
2028
+ ```
2029
+
2030
+ **Benefits:**
2031
+ - **Spatial analysis** skips intra-group collision checks (intentional overlaps)
2032
+ - **Group-level summary** reports relationships between assemblies
2033
+ - **Object listing** shows group tags: `Bed Plate [Bed Assembly]`
2034
+ - **Parameter validation** (`param-check` CLI) ignores collisions within groups
2035
+
2036
+ ## Guides and Examples
2037
+
2038
+ See [../guides/modeling-recipes.md](../guides/modeling-recipes.md) for patterns, best practices, debugging, and sample snippets.
2039
+
2040
+ For runnable end-to-end models, read `examples/api/`.
2041
+
2042
+ ---
2043
+
2044
+ <!-- API/model-building/coordinate-system.md -->
2045
+
2046
+ # Coordinate System Convention
2047
+
2048
+ ForgeCAD uses a **Z-up** right-handed coordinate system.
2049
+
2050
+ ## Axes
2051
+
2052
+ | Axis | Direction | Positive |
2053
+ |------|-----------------|----------|
2054
+ | X | Left / Right | Right |
2055
+ | Y | Forward / Back | Forward |
2056
+ | Z | Up / Down | Up |
2057
+
2058
+ ## Standard Views
2059
+
2060
+ | View | Camera position direction | Sees plane | Camera up |
2061
+ |--------|--------------------------|------------|-----------|
2062
+ | Front | −Y (camera at −Y) | XZ | Z |
2063
+ | Back | +Y (camera at +Y) | XZ | Z |
2064
+ | Right | +X (camera at +X) | YZ | Z |
2065
+ | Left | −X (camera at −X) | YZ | Z |
2066
+ | Top | +Z (camera at +Z) | XY | +Y |
2067
+ | Bottom | −Z (camera at −Z) | XY | −Y |
2068
+ | Iso | +X −Y +Z (diagonal) | — | Z |
2069
+
2070
+ ## GizmoViewcube Face Mapping
2071
+
2072
+ Three.js BoxGeometry material indices (cube face order):
2073
+
2074
+ | Index | Three.js direction | ForgeCAD label |
2075
+ |-------|--------------------|----------------|
2076
+ | 0 | +X | Right |
2077
+ | 1 | −X | Left |
2078
+ | 2 | +Y | Front |
2079
+ | 3 | −Y | Back |
2080
+ | 4 | +Z | Top |
2081
+ | 5 | −Z | Bottom |
2082
+
2083
+ Default drei labels are `['Right', 'Left', 'Top', 'Bottom', 'Front', 'Back']` (Y-up).
2084
+ For Z-up we pass `faces={['Right', 'Left', 'Front', 'Back', 'Top', 'Bottom']}`.
2085
+
2086
+ ## Grid
2087
+
2088
+ The ground plane is XY (Z = 0). The grid lies on this plane.
2089
+
2090
+ ---
2091
+
2092
+ <!-- API/model-building/geometry-conventions.md -->
2093
+
2094
+ # Geometry Conventions
2095
+
2096
+ ForgeCAD wraps Manifold (a mesh kernel) and Three.js (a Y-up renderer). These libraries have their own conventions that conflict with each other and with CAD norms. This doc captures every convention mismatch and how ForgeCAD resolves it.
2097
+
2098
+ **Core principle: the user script should never need to know about kernel or renderer internals.** If the user writes something geometrically reasonable, it should work. All convention translation happens inside ForgeCAD's layer.
2099
+
2100
+ ## Winding Order
2101
+
2102
+ **What it is:** The order of vertices in a 2D polygon determines its "direction" — counter-clockwise (CCW) = positive area, clockwise (CW) = negative/zero area in Manifold's `CrossSection`.
2103
+
2104
+ **The problem:** Manifold silently produces empty geometry for CW polygons. A user writing `polygon([[0,0], [50,0], [50,30]])` vs `polygon([[0,0], [50,30], [50,0]])` gets either a triangle or nothing, with no error.
2105
+
2106
+ **ForgeCAD's fix:** All entry points that accept raw points auto-fix winding:
2107
+ - `polygon(points)` — computes signed area, reverses if CW
2108
+ - `path().close()` — same fix
2109
+
2110
+ **Signed area test** (shoelace formula):
2111
+ ```
2112
+ signedArea = Σ (x₂ - x₁)(y₂ + y₁)
2113
+ ```
2114
+ If `signedArea > 0` → CW → reverse to make CCW.
2115
+
2116
+ **Implementation:** `src/forge/sketch/primitives.ts` (polygon), `src/forge/sketch/path.ts` (close).
2117
+
2118
+ **Rule for new code:** Any function that takes user-provided point arrays and creates a `CrossSection` MUST auto-fix winding. Never pass raw user points to Manifold without this check.
2119
+
2120
+ ## Coordinate System (Z-up vs Y-up)
2121
+
2122
+ **The problem:** Three.js uses Y-up. CAD convention (and ForgeCAD) uses Z-up.
2123
+
2124
+ **ForgeCAD's fix:** We set `camera.up = (0, 0, 1)` everywhere. Geometry coordinates are native Z-up — no matrix swizzling. The camera orientation handles the visual mapping.
2125
+
2126
+ **Where this matters:**
2127
+ - `camera.up.set(0, 0, 1)` in `sceneBuilder.ts` and `render.ts`
2128
+ - GizmoViewcube face labels remapped (see coordinate-system.md)
2129
+ - Grid plane is XY (Z=0)
2130
+ - Extrusion goes along +Z
2131
+ - Revolution axis is Y (sketch plane), result maps to Z-up space
2132
+
2133
+ **Rule for new code:** Never swap Y/Z in geometry. Always fix it at the camera/renderer level.
2134
+
2135
+ ## Revolution Axis
2136
+
2137
+ **What it is:** `CrossSection.revolve()` in Manifold revolves around the Y axis. The sketch profile must be in the X-Y plane with X = radius (distance from axis) and Y = height.
2138
+
2139
+ **The mapping:**
2140
+ - Profile X coordinate → radial distance from center
2141
+ - Profile Y coordinate → height (becomes Z after revolution)
2142
+ - Profile must be on the positive X side (X > 0) for valid geometry
2143
+
2144
+ **Rule for new code:** Document which axis any new sweep/revolution operation uses. If it differs from user expectation, add a transform wrapper.
2145
+
2146
+ ## Boolean Winding (3D)
2147
+
2148
+ **What it is:** Manifold requires consistent face normals (outward-pointing) for boolean operations. Manifold handles this internally for its own primitives, but imported meshes or degenerate operations can produce inside-out faces.
2149
+
2150
+ **ForgeCAD's fix:** We only create meshes through Manifold's own constructors (`extrude`, `revolve`, `cylinder`, `sphere`, etc.), which guarantee correct normals. No raw mesh import path exists yet.
2151
+
2152
+ **Rule for new code:** If adding mesh import (STL, OBJ), run `Manifold.asOriginal()` or validate manifoldness before allowing booleans.
2153
+
2154
+ ## Transform Order
2155
+
2156
+ **What it is:** Transforms are applied in call order (left to right in the chain). `shape.translate(10,0,0).rotate(0,0,45)` first moves, then rotates around origin — so the shape orbits.
2157
+
2158
+ **Convention:** This matches the standard "post-multiply" convention. No surprises here, but worth noting because some systems (OpenSCAD) apply transforms in reverse order.
2159
+
2160
+ For explicit transform objects:
2161
+ - `A.mul(B)` means **apply A, then B**.
2162
+ - `composeChain(A, B, C)` means **A -> B -> C**.
2163
+
2164
+ **Rule for new code:** Keep this chain order everywhere. Document any operation that deviates.
2165
+
2166
+ ## Assembly Frame Composition
2167
+
2168
+ This is where regressions are most likely if convention is unclear.
2169
+
2170
+ For a point in child geometry-local coordinates:
2171
+ - local -> `childBase` -> `jointMotion(value)` -> `jointFrame` -> `parentWorld`
2172
+
2173
+ In Forge chain notation:
2174
+ ```ts
2175
+ childWorld = composeChain(childBase, jointMotion, jointFrame, parentWorld)
2176
+ ```
2177
+
2178
+ Equivalent matrix-style equation (for reference):
2179
+ ```txt
2180
+ T_world_child = T_parent_world * T_joint_frame * T_joint_motion * T_child_base
2181
+ ```
2182
+
2183
+ **Rule for new code:** In kinematics/assembly code, prefer `composeChain(...)` over manual `.mul(...).mul(...)` sequences to avoid order mistakes.
2184
+
2185
+ ## Summary of Shield Points
2186
+
2187
+ These are the places where ForgeCAD translates between "what the user means" and "what the kernel needs":
2188
+
2189
+ | Convention | User sees | Kernel needs | Where we fix it |
2190
+ |---|---|---|---|
2191
+ | Winding | Any point order | CCW | `polygon()`, `path().close()` |
2192
+ | Up axis | Z-up | Y-up (Three.js) | `camera.up`, gizmo labels |
2193
+ | Revolution | "revolve this profile" | Profile in X-Y, X>0 | Documented, not auto-fixed |
2194
+ | Face normals | Doesn't think about it | Outward-pointing | Manifold constructors |
2195
+ | Transform order | Left-to-right chain | Post-multiply | Native match, no fix needed |
2196
+
2197
+ When adding new geometry operations, check this table. If the operation introduces a new convention mismatch between user intent and kernel requirement, either auto-fix it (preferred) or document it clearly in the API docs.
2198
+
2199
+ ---
2200
+
2201
+ <!-- API/model-building/positioning.md -->
2202
+
2203
+ # Positioning Strategy
2204
+
2205
+ **This is the most important page for building multi-part assemblies.** Most positioning bugs come from manual coordinate arithmetic. Use the methods below in priority order.
2206
+
2207
+ ## Priority Order
2208
+
2209
+ ### 1. `attachTo()` — Default choice for child-on-parent positioning
2210
+
2211
+ When placing a part relative to another part, use `attachTo()`. It reads as English: "put my bottom on your top."
2212
+
2213
+ ```javascript
2214
+ const base = box(100, 100, 10);
2215
+
2216
+ // Column stands on top of base, centered
2217
+ const column = cylinder(50, 8).attachTo(base, 'top', 'bottom');
2218
+
2219
+ // Button sticks out from front face, near top-right corner
2220
+ const button = box(10, 4, 6, true)
2221
+ .attachTo(panel, 'top-front-right', 'top-back-right', [5, -2, -10]);
2222
+ ```
2223
+
2224
+ **How to read it:** `child.attachTo(parent, parentAnchor, selfAnchor, offset)`
2225
+ - `parentAnchor` = "where on the parent do I want to attach?"
2226
+ - `selfAnchor` = "which part of myself aligns to that point?"
2227
+ - `offset` = "then shift by this much" (optional)
2228
+
2229
+ **Common patterns:**
2230
+ | Intent | parentAnchor | selfAnchor | Why |
2231
+ |--------|-------------|------------|-----|
2232
+ | Stack on top | `'top'` | `'bottom'` | Bottom of child meets top of parent |
2233
+ | Hang below | `'bottom'` | `'top'` | Top of child meets bottom of parent |
2234
+ | Stick out from front | `'front'` | `'back'` | Back of child flush with front of parent |
2235
+ | Protrude from side | `'left'` | `'right'` | Right face of child meets left face of parent |
2236
+
2237
+ ### 2. `pointAlong()` — Orient cylinders/extrusions before positioning
2238
+
2239
+ Cylinders default to Z-up. Instead of `rotate(90, 0, 0)` (which is confusing), use `pointAlong()`:
2240
+
2241
+ ```javascript
2242
+ // Pipe running along Y axis
2243
+ const pipe = cylinder(100, 5).pointAlong([0, 1, 0]);
2244
+
2245
+ // Axle along X
2246
+ const axle = cylinder(80, 3).pointAlong([1, 0, 0]);
2247
+ ```
2248
+
2249
+ **Always call `pointAlong()` BEFORE `attachTo()` or `translate()`** — it reorients around the origin.
2250
+
2251
+ ```javascript
2252
+ // Correct: orient first, then position
2253
+ const grille = cylinder(4, 30)
2254
+ .pointAlong([0, 1, 0])
2255
+ .attachTo(outdoorUnit, 'back', 'front', [0, 2, 0]);
2256
+ ```
2257
+
2258
+ ### 3. `rotateAroundTo()` — Aim a point around a hinge/axis
2259
+
2260
+ Use this when a part already has the correct pivot/axis, and you want to solve the angle from geometry instead of doing trig by hand.
2261
+
2262
+ ```javascript
2263
+ const arm = box(80, 8, 8, true)
2264
+ .translate(40, 0, 0)
2265
+ .withReferences({ points: { tip: [80, 0, 0] } });
2266
+
2267
+ // Rotate around Z until the tip lies in the plane formed by the Z axis and the target point
2268
+ const aimed = arm.rotateAroundTo(
2269
+ [0, 0, 1],
2270
+ [0, 0, 0],
2271
+ "tip",
2272
+ [30, 30, 20],
2273
+ );
2274
+
2275
+ // Exact line solve: throws if the target line is unreachable while preserving radius about the axis
2276
+ const lineHit = arm.rotateAroundTo(
2277
+ [0, 0, 1],
2278
+ [0, 0, 0],
2279
+ "tip",
2280
+ [30, 30, 0],
2281
+ { mode: 'line' },
2282
+ );
2283
+ ```
2284
+
2285
+ ### 4. `moveToLocal()` — Position relative to another shape's corner
2286
+
2287
+ When you need to place something at a specific offset from another shape's bounding box origin (min corner):
2288
+
2289
+ ```javascript
2290
+ const base = box(100, 100, 10);
2291
+ const part = box(20, 20, 30).moveToLocal(base, 10, 10, 10);
2292
+ ```
2293
+
2294
+ ### 5. `translate()` — Only for simple offsets or connecting independently-positioned parts
2295
+
2296
+ Use `translate()` when:
2297
+ - Moving a shape by a known fixed amount
2298
+ - Positioning between two shapes whose locations you've already computed via `boundingBox()`
2299
+
2300
+ ```javascript
2301
+ // Pipe spanning between two independently-positioned units
2302
+ const bb1 = indoor.boundingBox();
2303
+ const bb2 = outdoor.boundingBox();
2304
+ const pipeLen = bb2.min[1] - bb1.max[1];
2305
+ const pipe = cylinder(pipeLen, 5)
2306
+ .pointAlong([0, 1, 0])
2307
+ .translate(40, (bb1.max[1] + bb2.min[1]) / 2, bb1.min[2] + 15);
2308
+ ```
2309
+
2310
+ ### 6. `placeReference()` / named import references — For reusable multi-file parts
2311
+
2312
+ When a part will be imported elsewhere, define semantic placement references once in the source file:
2313
+
2314
+ ```javascript
2315
+ // widget.forge.js
2316
+ return union(base, post).withReferences({
2317
+ points: {
2318
+ mount: [0, -16, -4],
2319
+ },
2320
+ objects: {
2321
+ post,
2322
+ },
2323
+ });
2324
+ ```
2325
+
2326
+ Then consume them in the importing file:
2327
+
2328
+ ```javascript
2329
+ const widget = importPart("widget.forge.js")
2330
+ .placeReference("mount", [120, 40, 0]);
2331
+
2332
+ const cap = box(18, 18, 8, true)
2333
+ .attachTo(widget, "objects.post.top", "bottom");
2334
+ ```
2335
+
2336
+ Use this when manual coordinate math starts to feel like assembly bookkeeping.
2337
+
2338
+ ## Common Mistakes
2339
+
2340
+ ### ❌ Manual center-offset math
2341
+ ```javascript
2342
+ // BAD: easy to get wrong, hard to read
2343
+ const child = box(w, d, h, true)
2344
+ .translate(0, -parentThickness/2 - d/2 - 5, parentHeight/2 - h/2 - 20);
2345
+ ```
2346
+
2347
+ ### ✅ Anchor-based positioning
2348
+ ```javascript
2349
+ // GOOD: intent is clear, no arithmetic
2350
+ const child = box(w, d, h, true)
2351
+ .attachTo(parent, 'top-front', 'top-back', [0, -5, -20]);
2352
+ ```
2353
+
2354
+ ### ❌ rotate() for cylinder orientation
2355
+ ```javascript
2356
+ // BAD: which axis? what happens to center?
2357
+ const pipe = cylinder(100, 5).rotate(90, 0, 0).translate(x, y, z);
2358
+ ```
2359
+
2360
+ ### ✅ pointAlong() for cylinder orientation
2361
+ ```javascript
2362
+ // GOOD: reads as "pipe pointing along Y"
2363
+ const pipe = cylinder(100, 5).pointAlong([0, 1, 0]).translate(x, y, z);
2364
+ ```
2365
+
2366
+ ## Anchor Reference
2367
+
2368
+ See the [main API doc](API.md#3d-anchor-positioning) for the full list of 26 anchor names. Quick mental model:
2369
+
2370
+ - **1 word** = face center: `'top'`, `'front'`, `'left'`...
2371
+ - **2 words** = edge midpoint: `'top-front'`, `'back-left'`...
2372
+ - **3 words** = corner: `'top-front-left'`, `'bottom-back-right'`...
2373
+
2374
+ ---
2375
+
2376
+ <!-- API/model-building/entities.md -->
2377
+
2378
+ # Entity-Based API
2379
+
2380
+ Named geometric entities with stable identity, topology tracking, and constraint integration.
2381
+
2382
+ ## 2D Entities
2383
+
2384
+ ### `point(x, y)` / `new Point2D(x, y)`
2385
+ A named 2D point.
2386
+
2387
+ ```javascript
2388
+ const p = point(10, 20);
2389
+ p.distanceTo(point(30, 40)); // distance
2390
+ p.midpointTo(point(30, 40)); // midpoint
2391
+ p.translate(5, 5); // new point
2392
+ p.toTuple(); // [10, 20]
2393
+ ```
2394
+
2395
+ ### `line(x1, y1, x2, y2)` / `Line2D`
2396
+ A named 2D line segment.
2397
+
2398
+ ```javascript
2399
+ const l = line(0, 0, 50, 0);
2400
+ l.length; // 50
2401
+ l.midpoint; // Point2D
2402
+ l.angle; // degrees
2403
+ l.direction; // [1, 0]
2404
+ l.parallel(10); // parallel line offset by 10
2405
+
2406
+ // Line-line intersection (infinite lines)
2407
+ const l2 = line(25, -10, 25, 40);
2408
+ l.intersect(l2); // Point2D(25, 0) — treats as infinite lines
2409
+ l.intersectSegment(l2); // Point2D or null — only if segments actually cross
2410
+
2411
+ // Construction methods
2412
+ Line2D.fromCoordinates(0, 0, 50, 0);
2413
+ Line2D.fromPointAndAngle(point(0, 0), 45, 100);
2414
+ Line2D.fromPointAndDirection(point(0, 0), [1, 1], 50);
2415
+ ```
2416
+
2417
+ ### `circle(cx, cy, radius)` / `Circle2D`
2418
+ A named 2D circle.
2419
+
2420
+ ```javascript
2421
+ const c = circle(0, 0, 25);
2422
+ c.diameter; // 50
2423
+ c.circumference; // ~157
2424
+ c.area; // ~1963
2425
+ c.pointAtAngle(90); // Point2D at top
2426
+
2427
+ // Extrude to cylinder with topology
2428
+ const cyl = c.extrude(30);
2429
+ cyl.face('top'); // FaceRef (planar)
2430
+ cyl.face('side'); // FaceRef (curved, planar === false)
2431
+
2432
+ // Construction methods
2433
+ Circle2D.fromCenterAndRadius(point(0, 0), 25);
2434
+ Circle2D.fromDiameter(point(0, 0), 50);
2435
+ ```
2436
+
2437
+ ### `rectangle(x, y, w, h)` / `Rectangle2D`
2438
+ A rectangle with named sides and vertices.
2439
+
2440
+ ```javascript
2441
+ const r = rectangle(0, 0, 100, 60);
2442
+
2443
+ // Named sides
2444
+ r.side('top'); // Line2D
2445
+ r.side('bottom'); // Line2D
2446
+ r.side('left'); // Line2D
2447
+ r.side('right'); // Line2D
2448
+ r.sideAt(0); // bottom (by index)
2449
+
2450
+ // Named vertices
2451
+ r.vertex('top-left'); // Point2D
2452
+ r.vertex('bottom-right'); // Point2D
2453
+
2454
+ // Properties
2455
+ r.width; // 100
2456
+ r.height; // 60
2457
+ r.center; // Point2D
2458
+
2459
+ // Diagonals — returns [bl-tr, br-tl] as Line2D pair
2460
+ const [d1, d2] = r.diagonals();
2461
+ const center = d1.intersect(d2); // Point2D at center
2462
+
2463
+ // Convert to Sketch for rendering
2464
+ r.toSketch();
2465
+
2466
+ // Extrude to 3D with topology tracking
2467
+ const tracked = r.extrude(20); // TrackedShape
2468
+
2469
+ // Construction methods
2470
+ Rectangle2D.fromDimensions(0, 0, 100, 60);
2471
+ Rectangle2D.fromCenterAndDimensions(point(50, 30), 100, 60);
2472
+ Rectangle2D.from2Corners(point(0, 0), point(100, 60));
2473
+ Rectangle2D.from3Points(p1, p2, p3); // free-angle rectangle
2474
+ ```
2475
+
2476
+ ## 3D Topology (TrackedShape)
2477
+
2478
+ When you extrude a `Rectangle2D`, you get a `TrackedShape` that knows its faces and edges by name.
2479
+
2480
+ ```javascript
2481
+ const rect = Rectangle2D.fromCenterAndDimensions(point(0, 0), 100, 60);
2482
+ const box = rect.extrude(20);
2483
+
2484
+ // Named faces
2485
+ box.face('top'); // FaceRef { normal, center, planar, uAxis, vAxis }
2486
+ box.face('bottom');
2487
+ box.face('side-left');
2488
+ box.face('side-right');
2489
+ box.face('side-top'); // the side from rect's top edge
2490
+ box.face('side-bottom'); // the side from rect's bottom edge
2491
+
2492
+ // Named edges
2493
+ box.edge('top-left'); // EdgeRef { start, end } — top face, left side
2494
+ box.edge('bottom-right'); // bottom face, right side
2495
+ box.edge('vert-bl'); // vertical edge at bottom-left corner
2496
+
2497
+ // List all
2498
+ box.faceNames(); // ['top', 'bottom', 'side-bottom', 'side-right', 'side-top', 'side-left']
2499
+ box.edgeNames(); // all 12 edges
2500
+
2501
+ // Use the underlying Shape for booleans
2502
+ const result = box.toShape().subtract(cylinder(25, 10));
2503
+
2504
+ // Translate preserves topology
2505
+ const moved = box.translate(50, 0, 0);
2506
+ moved.face('top').center; // shifted by [50, 0, 0]
2507
+
2508
+ // Duplicate preserves topology metadata too
2509
+ const copy = box.clone();
2510
+ copy.face('side-left');
2511
+ ```
2512
+
2513
+ ## Constraint Helpers
2514
+
2515
+ ```javascript
2516
+ const sketch = constrainedSketch();
2517
+ const p1 = sketch.point(0, 0, true);
2518
+ const p2 = sketch.point(50, 0);
2519
+ const p3 = sketch.point(50, 30);
2520
+ const l1 = sketch.line(p1, p2);
2521
+ const l2 = sketch.line(p2, p3);
2522
+
2523
+ Constraint.horizontal(sketch, l1);
2524
+ Constraint.vertical(sketch, l2);
2525
+ Constraint.length(sketch, l1, 50);
2526
+ Constraint.perpendicular(sketch, l1, l2);
2527
+
2528
+ const result = sketch.close().solve();
2529
+ ```
2530
+
2531
+ ### Entity-aware constraints
2532
+
2533
+ Constraint functions accept `Point2D`/`Line2D` directly — they auto-import into the builder:
2534
+
2535
+ ```javascript
2536
+ const sketch = constrainedSketch();
2537
+ const myLine = line(0, 0, 50, 0);
2538
+ const myRect = rectangle(10, 10, 40, 30);
2539
+
2540
+ // Pass Line2D directly — auto-imported
2541
+ Constraint.makeParallel(sketch, myLine, myRect.side('top'));
2542
+ Constraint.horizontal(sketch, myLine);
2543
+ ```
2544
+
2545
+ ### Importing entities into a constrained sketch
2546
+
2547
+ ```javascript
2548
+ const sketch = constrainedSketch();
2549
+ const r = rectangle(0, 0, 100, 60);
2550
+ const sides = sketch.importRectangle(r);
2551
+ // sides.bottom, sides.right, sides.top, sides.left are LineIds
2552
+ // sides.points is [bl, br, tr, tl] PointIds
2553
+
2554
+ Constraint.horizontal(sketch, sides.bottom);
2555
+ Constraint.length(sketch, sides.bottom, 100);
2556
+ ```
2557
+
2558
+
2559
+ ## Patterns
2560
+
2561
+ ### `linearPattern(shape, count, dx, dy, dz?)`
2562
+ Repeat a shape along a direction vector, returning the union.
2563
+
2564
+ ```javascript
2565
+ const bolt = cylinder(10, 3);
2566
+ const row = linearPattern(bolt, 5, 20, 0); // 5 bolts, 20mm apart along X
2567
+ ```
2568
+
2569
+ ### `circularPattern(shape, count, centerX?, centerY?)`
2570
+ Repeat a shape around the Z axis, returning the union.
2571
+
2572
+ ```javascript
2573
+ const hole = cylinder(12, 4).translate(30, 0, -1);
2574
+ const holes = circularPattern(hole, 8); // 8 holes evenly spaced
2575
+ ```
2576
+
2577
+ ### `mirrorCopy(shape, normal)`
2578
+ Mirror a shape and union with the original.
2579
+
2580
+ ```javascript
2581
+ const half = box(50, 30, 10);
2582
+ const full = mirrorCopy(half, [1, 0, 0]); // Mirror across YZ plane
2583
+ ```
2584
+
2585
+ For compile-covered source shapes, repeated instances created by `linearPattern`, `circularPattern`, `Shape.mirror()`, and `mirrorCopy()` keep distinct compiler owner lineage. Supported boolean unions now preserve owner-scoped canonical face queries for those repeated descendants, so later compiler inspections can still trace which repeated instance a preserved face came from. Durable post-merge face identity is still narrower than full CAD-style topology naming: reusing the same owner lineage twice without a fresh mirror/pattern owner is reported as ambiguous, and downstream subtract/intersect rewrites still record split descendants explicitly instead of guessing.
2586
+
2587
+ ## Utility Functions
2588
+
2589
+ ### `degrees(deg)` / `radians(rad)`
2590
+ Angle conversion helpers for readability:
2591
+
2592
+ ```javascript
2593
+ degrees(45); // 45 (identity — just for clarity)
2594
+ radians(Math.PI / 4); // 45 (converts radians to degrees)
2595
+ ```
2596
+
2597
+ ## Fillets & Chamfers
2598
+
2599
+ ### `filletEdge(shape, edge, radius, quadrant?, segments?)`
2600
+ Compiler-owned edge fillet for the current tracked-edge subset.
2601
+
2602
+ Supported today:
2603
+ - tracked vertical edges from compile-covered `box()` bodies
2604
+ - tracked vertical edges from `rectangle(...).extrude(...)`
2605
+ - rigid transforms between the tracked source body and the target shape
2606
+ - untouched sibling tracked vertical edges after earlier supported `filletEdge(...)` / `chamferEdge(...)` rewrites on the same body
2607
+ - preserved propagated vertical-edge queries after those supported edge-finish rewrites when a later supported boolean union keeps one defended edge lineage
2608
+
2609
+ Still out of subset today:
2610
+ - the selected edge after an earlier `filletEdge(...)` / `chamferEdge(...)` rewrite as a new single finish target, because Forge now records that path as an explicit descendant edge-chain rather than pretending it stayed one edge
2611
+ - edge descendants after shell, hole/cut, trim, boolean difference/intersection, or boolean unions that did not already record one supported propagated edge lineage for the selection
2612
+ - generic sketch extrudes, tapered extrudes, and arbitrary feature-created edges
2613
+
2614
+ Canonical quadrants for the supported rectangle/box edges:
2615
+ - `vert-bl` -> `[1, -1]`
2616
+ - `vert-br` -> `[-1, -1]`
2617
+ - `vert-tr` -> `[-1, 1]`
2618
+ - `vert-tl` -> `[1, 1]`
2619
+
2620
+ ```javascript
2621
+ const b = rectangle(0, 0, 50, 50).extrude(20);
2622
+ const filleted = filletEdge(b.toShape(), b.edge('vert-br'), 5, [-1, -1]);
2623
+ ```
2624
+
2625
+ ### `chamferEdge(shape, edge, size, quadrant?)`
2626
+ Compiler-owned edge chamfer for the same tracked vertical-edge subset as `filletEdge(...)`.
2627
+
2628
+ ```javascript
2629
+ const b = rectangle(0, 0, 50, 50).extrude(20);
2630
+ const chamfered = chamferEdge(b.toShape(), b.edge('vert-br'), 3, [-1, -1]);
2631
+ ```
2632
+
2633
+ ## Arc Bridge
2634
+
2635
+ ### `arcBridgeBetweenRects(rectA, rectB, segments?)`
2636
+ Build a smooth arc surface connecting two rectangular areas. Automatically finds the closest pair of parallel edges and bridges them with a semicircular arc.
2637
+
2638
+ **Parameters:**
2639
+ - `rectA` — `Rectangle2D` or `{ corners: [[x,y,z], [x,y,z], [x,y,z], [x,y,z]] }`
2640
+ - `rectB` — same format as rectA
2641
+ - `segments` (number, optional) — Arc smoothness. Default: 12
2642
+
2643
+ **Returns:** `Shape` — thin arc solid
2644
+
2645
+ ```javascript
2646
+ // 2D rectangles (z=0)
2647
+ const base = rectangle(0, 0, 300, 200);
2648
+ const screen = rectangle(0, 200, 300, 200);
2649
+ const hinge = arcBridgeBetweenRects(base, screen, 16);
2650
+ ```
2651
+
2652
+ ```javascript
2653
+ // 3D corners for non-planar rectangles
2654
+ const hinge = arcBridgeBetweenRects(
2655
+ { corners: [[0,0,0], [300,0,0], [300,200,0], [0,200,0]] },
2656
+ { corners: [[0,200,15], [300,200,15], [300,400,15], [0,400,15]] },
2657
+ 16,
2658
+ );
2659
+ ```
2660
+
2661
+ ---
2662
+
2663
+ <!-- API/model-building/assembly.md -->
2664
+
2665
+ # Assembly + Mechanism API
2666
+
2667
+ Use this API when your model is a mechanism, not a single booleaned solid.
2668
+
2669
+ ## Mental model
2670
+ - `Part` = manufacturable object (shape + metadata)
2671
+ - `Joint` = relationship between parent and child part
2672
+ - `State` = current joint values
2673
+ - `Solve` = compute world transforms for all parts
2674
+ - `Validate` = collisions / clearances / sweep checks
2675
+
2676
+ ## Quick start
2677
+
2678
+ ```javascript
2679
+ const mech = assembly("Arm")
2680
+ .addPart("base", box(80, 80, 20, true), {
2681
+ metadata: { material: "PETG", process: "FDM", qty: 1 },
2682
+ })
2683
+ .addPart("link", box(140, 24, 24).translate(0, -12, -12))
2684
+ .addJoint("shoulder", "revolute", "base", "link", {
2685
+ axis: [0, 1, 0],
2686
+ min: -30,
2687
+ max: 120,
2688
+ default: 25,
2689
+ frame: Transform.identity().translate(0, 0, 20),
2690
+ });
2691
+
2692
+ const solved = mech.solve();
2693
+ return solved.toScene();
2694
+ ```
2695
+
2696
+ ## Ergonomic helpers
2697
+ - `addFrame(name, { transform? })` adds a virtual reference frame (no geometry)
2698
+ - `addRevolute(name, parent, child, opts)` shorthand for `addJoint(..., "revolute", ...)`
2699
+ - `addPrismatic(name, parent, child, opts)` shorthand for `addJoint(..., "prismatic", ...)`
2700
+ - `addFixed(name, parent, child, opts)` shorthand for `addJoint(..., "fixed", ...)`
2701
+ - `addJointCoupling(jointName, { terms, offset? })` links joints with linear relationships
2702
+ - `addGearCoupling(drivenJoint, driverJoint, opts)` links revolute joints using gear ratios
2703
+
2704
+ ## Joint couplings
2705
+
2706
+ Use couplings when one joint should be derived from other joints.
2707
+
2708
+ Formula:
2709
+ - `driven = offset + Σ(ratio_i * source_i)`
2710
+
2711
+ Example:
2712
+
2713
+ ```javascript
2714
+ const mech = assembly("Differential")
2715
+ .addFrame("Base")
2716
+ .addFrame("Turret")
2717
+ .addFrame("Wheel")
2718
+ .addFrame("TopInput")
2719
+ .addRevolute("Steering", "Base", "Turret", { axis: [0, 0, 1] })
2720
+ .addRevolute("WheelDrive", "Turret", "Wheel", { axis: [1, 0, 0] })
2721
+ .addRevolute("TopGear", "Base", "TopInput", { axis: [0, 0, 1] })
2722
+ .addJointCoupling("TopGear", {
2723
+ terms: [
2724
+ { joint: "Steering", ratio: 1 },
2725
+ { joint: "WheelDrive", ratio: 20 / 14 },
2726
+ ],
2727
+ });
2728
+ ```
2729
+
2730
+ Notes:
2731
+ - Coupled joints ignore direct values in `solve(state)` and emit a warning.
2732
+ - Coupling cycles are rejected.
2733
+ - `sweepJoint(...)` cannot sweep a coupled target; sweep one of its source joints instead.
2734
+
2735
+ ## Gear couplings
2736
+
2737
+ Use this helper to connect two **revolute** joints as a gear mesh without manually writing `addJointCoupling(...)`.
2738
+
2739
+ ```javascript
2740
+ const pair = lib.gearPair({
2741
+ pinion: { module: 1.25, teeth: 14, faceWidth: 8 },
2742
+ gear: { module: 1.25, teeth: 42, faceWidth: 8 },
2743
+ });
2744
+
2745
+ const mech = assembly("Spur Stage")
2746
+ .addFrame("Base")
2747
+ .addFrame("PinionPart")
2748
+ .addFrame("GearPart")
2749
+ .addRevolute("Pinion", "Base", "PinionPart", { axis: [0, 0, 1] })
2750
+ .addRevolute("Driven", "Base", "GearPart", { axis: [0, 0, 1] })
2751
+ .addGearCoupling("Driven", "Pinion", { pair }); // uses pair.jointRatio
2752
+ ```
2753
+
2754
+ `addGearCoupling(...)` ratio sources (choose exactly one):
2755
+ - `ratio` (explicit multiplier)
2756
+ - `pair` (`lib.gearPair(...)`, `lib.bevelGearPair(...)`, or `lib.faceGearPair(...)` result using `pair.jointRatio`)
2757
+ - `driverTeeth` + `drivenTeeth` (auto ratio; `internal` mesh is positive, `external`/`bevel`/`face` are negative)
2758
+
2759
+ For bevel stages, pairing helpers also return placement aids:
2760
+ - `pinionAxis`, `gearAxis`
2761
+ - `pinionCenter`, `gearCenter`
2762
+
2763
+ For face stages, use `centerDistance` and `meshPlaneZ` from `lib.faceGearPair(...)`; with `place: true`, the face gear stays on the Z axis and the vertical spur is placed at `[centerDistance, 0, meshPlaneZ]`.
2764
+
2765
+ ## Joint frames
2766
+
2767
+ `frame` is a transform from the **parent part frame** to the **joint frame at zero state**.
2768
+
2769
+ For a child part:
2770
+
2771
+ Matrix form:
2772
+ - `childWorld = parentWorld * frame * motion(value) * childBase`
2773
+
2774
+ Forge chain form:
2775
+ - `childWorld = composeChain(childBase, motion(value), frame, parentWorld)`
2776
+
2777
+ This keeps kinematic chains declarative and avoids repeated manual pivot math.
2778
+
2779
+ ## Validation helpers
2780
+ - `solved.collisionReport()` returns overlapping part pairs and volume
2781
+ - `solved.minClearance("PartA", "PartB", 10)` computes minimum gap
2782
+ - `assembly.sweepJoint("elbow", -20, 140, 24)` samples motion and reports collisions
2783
+
2784
+ Notebook-friendly pattern:
2785
+
2786
+ ```javascript
2787
+ const solved = mech.solve({ shoulder: 35, elbow: 60 });
2788
+ console.log("Collisions", solved.collisionReport());
2789
+
2790
+ const sweep = mech.sweepJoint("elbow", -10, 135, 12, { shoulder: 35 });
2791
+ console.log("Sweep collisions", sweep.filter((step) => step.collisions.length > 0).length);
2792
+
2793
+ show(solved.toScene());
2794
+ ```
2795
+
2796
+ That keeps mechanism setup in earlier cells and collision/sweep investigation in the current preview cell.
2797
+
2798
+ ## Importing assemblies from other files
2799
+
2800
+ Use `importAssembly(fileName, paramOverrides?)` to import an assembly defined in another file. The source file must `return` the `Assembly` instance directly (not `.solve()`).
2801
+
2802
+ ```javascript
2803
+ // arm.forge.js — source file
2804
+ const mech = assembly("Arm")
2805
+ .addPart("Base", box(80, 80, 20, true))
2806
+ .addPart("Link", box(140, 24, 24).translate(0, -12, -12))
2807
+ .addRevolute("shoulder", "Base", "Link", {
2808
+ axis: [0, 1, 0],
2809
+ min: -30,
2810
+ max: 120,
2811
+ default: 25,
2812
+ frame: Transform.identity().translate(0, 0, 20),
2813
+ });
2814
+
2815
+ return mech; // return Assembly, not mech.solve()
2816
+ ```
2817
+
2818
+ ```javascript
2819
+ // scene.forge.js — consumer
2820
+ const arm = importAssembly("arm.forge.js");
2821
+
2822
+ // Access named parts by name (positioned at default or given joint state)
2823
+ const base = arm.part("Base");
2824
+ const link = arm.part("Link", { shoulder: 60 });
2825
+
2826
+ // Convert to a ShapeGroup — children named after assembly part names
2827
+ const g = arm.toGroup({ shoulder: 45 });
2828
+ const baseChild = g.child("Base");
2829
+
2830
+ // Full kinematic access
2831
+ arm.assembly.sweepJoint("shoulder", -30, 120, 24);
2832
+ const solved = arm.solve({ shoulder: 45 });
2833
+ console.log(solved.bom());
2834
+
2835
+ return arm.toGroup({ shoulder: 45 });
2836
+ ```
2837
+
2838
+ ## Common pitfalls
2839
+ - If parts vanish in the viewport, check whether a cut plane is active before debugging kinematics. The viewer-side APIs live in [../runtime/viewport.md](../runtime/viewport.md).
2840
+ - If a returned object is empty, Forge logs a warning in script output.
2841
+ - `importAssembly()` requires the source file to return the `Assembly` object before calling `.solve()`. If you call `.solve()` in the source file and return a `SolvedAssembly`, use `importGroup()` instead (convert with `.toScene()` → group).
2842
+
2843
+ ## Metadata
2844
+ - `addPart(..., { metadata })` attaches per-part metadata to an assembly part.
2845
+ - BOM/report helpers such as `solved.bom()` and `solved.bomCsv()` live in [../output/bom.md](../output/bom.md).
2846
+
2847
+ ## Naming grouped assembly children
2848
+
2849
+ When an assembly part is a `ShapeGroup`, Forge flattens the group into separate viewport objects. To avoid opaque labels like `Base Assembly.1`, name the group children explicitly:
2850
+
2851
+ ```javascript
2852
+ const housing = group(
2853
+ { name: "Body", shape: body },
2854
+ { name: "Lid", shape: lid },
2855
+ );
2856
+
2857
+ const mech = assembly("Case")
2858
+ .addPart("Base Assembly", housing);
2859
+ ```
2860
+
2861
+ That produces labels such as `Base Assembly.Body` and `Base Assembly.Lid`.
2862
+
2863
+ ## Robot export
2864
+
2865
+ Use `robotExport({...})` when an assembly should become a simulator package instead of only a viewport scene.
2866
+
2867
+ ```javascript
2868
+ const rover = assembly("Scout")
2869
+ .addPart("Chassis", box(300, 220, 50, true))
2870
+ .addPart("Left Wheel", cylinder(30, 60, undefined, 48, true).pointAlong([0, 1, 0]))
2871
+ .addPart("Right Wheel", cylinder(30, 60, undefined, 48, true).pointAlong([0, 1, 0]))
2872
+ .addRevolute("leftWheel", "Chassis", "Left Wheel", {
2873
+ axis: [0, 1, 0],
2874
+ frame: Transform.identity().translate(90, 140, 60),
2875
+ effort: 20,
2876
+ velocity: 1080,
2877
+ })
2878
+ .addRevolute("rightWheel", "Chassis", "Right Wheel", {
2879
+ axis: [0, 1, 0],
2880
+ frame: Transform.identity().translate(90, -140, 60),
2881
+ effort: 20,
2882
+ velocity: 1080,
2883
+ });
2884
+
2885
+ robotExport({
2886
+ assembly: rover,
2887
+ modelName: "Scout",
2888
+ links: {
2889
+ Chassis: { massKg: 10 },
2890
+ "Left Wheel": { massKg: 0.8 },
2891
+ "Right Wheel": { massKg: 0.8 },
2892
+ },
2893
+ plugins: {
2894
+ diffDrive: {
2895
+ leftJoints: ["leftWheel"],
2896
+ rightJoints: ["rightWheel"],
2897
+ wheelSeparationMm: 280,
2898
+ wheelRadiusMm: 60,
2899
+ },
2900
+ },
2901
+ world: {
2902
+ generateDemoWorld: true,
2903
+ },
2904
+ });
2905
+ ```
2906
+
2907
+ Notes:
2908
+ - Revolute joint `velocity` values are expressed in degrees/second in Forge; the SDF exporter converts them to radians/second.
2909
+ - Prismatic distances are authored in millimeters and exported in meters.
2910
+ - `massKg` is preferred for demo robots; `densityKgM3` is a decent fallback when mass is unknown.
2911
+
2912
+ ---
2913
+
2914
+ <!-- API/runtime/viewport.md -->
2915
+
2916
+ # Viewport Runtime APIs
2917
+
2918
+ These APIs affect the viewer and scene presentation. They do not change the underlying model geometry contract, so they are not part of the required model-building reading set.
2919
+
2920
+ ## `cutPlane(name, normal, offsetOrOptions?, options?)`
2921
+
2922
+ Define a named section plane for inspection.
2923
+
2924
+ **Parameters:**
2925
+ - `name` (string) - label shown in the viewport controls
2926
+ - `normal` (`[number, number, number]`) - direction toward the clipped side
2927
+ - `offsetOrOptions` (number or object, optional):
2928
+ - number: plane offset from origin along `normal`
2929
+ - object: `{ offset?: number, exclude?: string | string[] }`
2930
+ - `options` (object, optional; used with numeric offset):
2931
+ - `exclude` (`string | string[]`) - returned object `name` values to keep uncut
2932
+
2933
+ **Returns:** `void`
2934
+
2935
+ ```javascript
2936
+ const cutZ = param("Cut Height", 10, { min: -50, max: 50, unit: "mm" });
2937
+
2938
+ cutPlane("Inspection", [0, 0, 1], cutZ, {
2939
+ exclude: ["Probe", "Fasteners"],
2940
+ });
2941
+ ```
2942
+
2943
+ Notes:
2944
+ - planes are registered per script run
2945
+ - viewport toggle state persists across parameter changes
2946
+ - clipping is applied to returned named objects, so `exclude` only works when names are stable
2947
+ - newly exposed section faces render with a hatched overlay; pre-existing coplanar boundary faces are left unhatched
2948
+
2949
+ ## `explodeView(options?)`
2950
+
2951
+ Override how the viewport explode slider offsets returned objects.
2952
+
2953
+ Explode offsets are resolved from the returned object tree, not from a flat list.
2954
+ In `radial` mode each node follows its parent branch direction, then adds a smaller
2955
+ local fan from the immediate parent/subassembly center, so nested assemblies peel
2956
+ apart level by level without losing their branch structure.
2957
+
2958
+ In fixed-axis or fixed-vector modes, the branch itself follows that axis/vector, but
2959
+ nested descendants fan out perpendicular to the branch by default so deep trees do
2960
+ not keep stacking farther along the same axis.
2961
+
2962
+ By default this is container-oriented: named groups/subassemblies advance along the
2963
+ tree, while plain leaves inside a group stay much closer and mostly fan locally
2964
+ around their parent cluster unless you override them explicitly.
2965
+
2966
+ **Parameters:**
2967
+ - `enabled` (boolean) - disable explode offsets for this script when `false`
2968
+ - `amountScale` (number) - multiply the UI explode amount
2969
+ - `stages` (number[]) - per-depth multipliers (depth 1 = first level, defaults to `1, 1/2, 1/3, ...`)
2970
+ - `mode` (`'radial' | 'x' | 'y' | 'z' | [x, y, z]`) - default explode direction
2971
+ - `axisLock` (`'x' | 'y' | 'z'`) - optional global axis lock
2972
+ - `byName` (`Record<string, { stage?, direction?, axisLock? }>`)- per-object overrides keyed by returned object `name`
2973
+ - `byPath` (`Record<string, { stage?, direction?, axisLock? }>`)- per-tree-path overrides using slash-separated object tree paths such as `"Drive/Shaft"`
2974
+
2975
+ **Returns:** `void`
2976
+
2977
+ ```javascript
2978
+ explodeView({
2979
+ amountScale: 1.2,
2980
+ stages: [0.35, 0.8],
2981
+ mode: 'radial',
2982
+ byPath: {
2983
+ "Drive/Shaft": { direction: [1, 0, 0], stage: 1.6 },
2984
+ },
2985
+ });
2986
+ ```
2987
+
2988
+ ## `jointsView(options?)`
2989
+
2990
+ Register viewport-only mechanism controls that animate returned objects without rerunning the script.
2991
+
2992
+ Use this when you want interactive articulation in the viewer but the geometry itself stays fixed.
2993
+
2994
+ Animation values are interpolated linearly between keyframes. Forge does **not**
2995
+ auto-wrap revolute values across `-180/180` or `0/360` for you, because doing
2996
+ that globally would break intentional multi-turn tracks.
2997
+
2998
+ **Key options:**
2999
+ - `enabled`
3000
+ - `joints`: `{ name, child, parent?, type?, axis?, pivot?, min?, max?, default?, unit? }[]`
3001
+ - `couplings`: `{ joint, terms, offset? }[]`
3002
+ - `animations`: `{ name, duration?, loop?, continuous?, keyframes }[]`
3003
+ - `defaultAnimation`
3004
+
3005
+ ```javascript
3006
+ jointsView({
3007
+ joints: [
3008
+ {
3009
+ name: "Shoulder",
3010
+ child: "Upper Arm",
3011
+ parent: "Base",
3012
+ type: "revolute",
3013
+ axis: [0, -1, 0],
3014
+ pivot: [0, 0, 46],
3015
+ min: -30,
3016
+ max: 110,
3017
+ default: 15,
3018
+ },
3019
+ ],
3020
+ animations: [
3021
+ {
3022
+ name: "Walk Cycle",
3023
+ duration: 1.6,
3024
+ loop: true,
3025
+ keyframes: [
3026
+ { at: 0.0, values: { "Shoulder": 20 } },
3027
+ { at: 0.5, values: { "Shoulder": -10 } },
3028
+ { at: 1.0, values: { "Shoulder": 20 } },
3029
+ ],
3030
+ },
3031
+ ],
3032
+ });
3033
+ ```
3034
+
3035
+ `continuous: true` is for looping tracks that should keep accumulating across
3036
+ cycles instead of snapping back to the first keyframe each time. Use it for
3037
+ monotonic multi-turn drives such as `0 -> 360 -> 720`.
3038
+
3039
+ ### Animation continuity for revolute joints
3040
+
3041
+ If an animation channel comes from `atan2(...)`, `normalizeAngleDeg(...)`, or
3042
+ any other wrapped angle source, keep the sampled keyframes continuous before
3043
+ passing them to `jointsView()`.
3044
+
3045
+ Bad branch-cut sample stream:
3046
+
3047
+ ```javascript
3048
+ keyframes: [
3049
+ { at: 0.48, values: { "Power Rod": -171 } },
3050
+ { at: 0.50, values: { "Power Rod": -180 } },
3051
+ { at: 0.52, values: { "Power Rod": 171 } },
3052
+ ]
3053
+ ```
3054
+
3055
+ That `-180 -> 171` jump is interpreted literally and the viewer will spin the
3056
+ part the long way around.
3057
+
3058
+ Good continuous sample stream:
3059
+
3060
+ ```javascript
3061
+ keyframes: [
3062
+ { at: 0.48, values: { "Power Rod": -171 } },
3063
+ { at: 0.50, values: { "Power Rod": -180 } },
3064
+ { at: 0.52, values: { "Power Rod": -189 } },
3065
+ ]
3066
+ ```
3067
+
3068
+ Guidelines:
3069
+ - Keep high-speed multi-turn joints authored as continuous angles (`0`, `360`,
3070
+ `720`, etc.).
3071
+ - Only unwrap channels that represent cyclic angles. Do not apply angle
3072
+ unwrapping blindly to prismatic or other scalar values.
3073
+ - If you build sampled helper utilities, let them unwrap a named set of joints
3074
+ instead of guessing from every numeric channel.
3075
+
3076
+ ## `viewConfig(options?)`
3077
+
3078
+ Configure viewport helper visuals for the current script.
3079
+
3080
+ Current support:
3081
+ - `jointOverlay.enabled`
3082
+ - joint overlay colors such as `axisColor`, `axisCoreColor`, `arcColor`, `zeroColor`
3083
+ - joint overlay sizing and tessellation controls such as `axisLengthScale`, `arcVisualLimitDeg`, `arcStepDeg`
3084
+
3085
+ **Returns:** `void`
3086
+
3087
+ ```javascript
3088
+ viewConfig({
3089
+ jointOverlay: {
3090
+ axisColor: "#13dfff",
3091
+ arcColor: "#ff7a1a",
3092
+ axisLineRadiusScale: 0.03,
3093
+ arcLineRadiusScale: 0.022,
3094
+ },
3095
+ });
3096
+ ```
3097
+
3098
+ ## `lib.explode(items, options?)`
3099
+
3100
+ Apply deterministic exploded-view offsets to an assembly tree while preserving names, colors, and nesting.
3101
+
3102
+ `radial` separation is branch-aware and parent-relative: each child follows the
3103
+ direction of its parent branch, then fans out locally inside that branch. This keeps
3104
+ subassemblies visually grouped while still letting their internals break apart.
3105
+
3106
+ For non-radial fixed-axis or fixed-vector modes, nested descendants keep the branch
3107
+ offset but spread perpendicular to it by default.
3108
+
3109
+ Default behavior is tree-like rather than flat: containers separate recursively,
3110
+ while unconfigured leaves inside a container use a smaller local fan so sibling parts
3111
+ stay visually associated with their parent group.
3112
+
3113
+ Works with:
3114
+ - arrays of shapes/sketches/named items
3115
+ - nested `{ name, group: [...] }` structures
3116
+ - `ShapeGroup` outputs
3117
+
3118
+ **Parameters:**
3119
+ - `items` (`ExplodeItem[] | ShapeGroup`)
3120
+ - `options`:
3121
+ - `amount` (number)
3122
+ - `stages` (number[])
3123
+ - `mode` (`'radial' | 'x' | 'y' | 'z' | [x, y, z]`)
3124
+ - `axisLock` (`'x' | 'y' | 'z'`)
3125
+ - `byName`
3126
+ - `byPath`
3127
+
3128
+ Named items may also include:
3129
+ - `explode: { stage?, direction?, axisLock? }`
3130
+
3131
+ **Returns:** same structure type as input, with translated geometry
3132
+
3133
+ ```javascript
3134
+ const explodeAmt = param("Explode", 0, { min: 0, max: 40, unit: "mm" });
3135
+
3136
+ return lib.explode(assembly, {
3137
+ amount: explodeAmt,
3138
+ stages: [0.4, 0.8],
3139
+ mode: 'radial',
3140
+ byName: {
3141
+ "Shaft": { direction: [1, 0, 0], stage: 1.4 },
3142
+ },
3143
+ });
3144
+ ```
3145
+
3146
+ ---
3147
+
3148
+ <!-- API/model-building/sketch-core.md -->
3149
+
3150
+ # Sketch Core
3151
+
3152
+ The `Sketch` class is an immutable wrapper around Manifold's `CrossSection` that provides a chainable 2D API.
3153
+
3154
+ ## Class: Sketch
3155
+
3156
+ Represents a 2D profile that can be transformed, combined with other sketches, or converted to 3D.
3157
+
3158
+ ### Color
3159
+
3160
+ #### `.color(hex: string): Sketch`
3161
+ Set the display color of this sketch. Returns a new Sketch.
3162
+
3163
+ ```javascript
3164
+ const red = rect(50, 30).color('#ff0000');
3165
+ const blue = circle2d(25).color('#0066ff');
3166
+ ```
3167
+
3168
+ Colors are preserved through transforms and boolean operations.
3169
+
3170
+ #### `.clone()` / `.duplicate()`
3171
+ Create an explicit duplicate of a sketch wrapper.
3172
+
3173
+ ```javascript
3174
+ const base = rect(50, 30);
3175
+ const a = base.clone();
3176
+ const b = base.duplicate().translate(60, 0);
3177
+ ```
3178
+
3179
+ ### Query Methods
3180
+
3181
+ #### `.area(): number`
3182
+ Returns the area of the sketch.
3183
+
3184
+ ```javascript
3185
+ const sq = rect(50, 50);
3186
+ console.log(sq.area()); // 2500
3187
+ ```
3188
+
3189
+ #### `.bounds()`
3190
+ Returns the bounding box: `{ min: [x, y], max: [x, y] }`.
3191
+
3192
+ ```javascript
3193
+ const c = circle2d(25);
3194
+ const b = c.bounds();
3195
+ // b.min ≈ [-25, -25], b.max ≈ [25, 25]
3196
+ ```
3197
+
3198
+ #### `.isEmpty(): boolean`
3199
+ Returns true if the sketch has no area.
3200
+
3201
+ #### `.numVert(): number`
3202
+ Returns the number of vertices in the contour.
3203
+
3204
+ #### `.toPolygons()`
3205
+ Returns raw polygon contours for rendering (internal use).
3206
+
3207
+ ## Type: Anchor
3208
+
3209
+ Anchor points for positioning sketches:
3210
+ - `'center'` — geometric center
3211
+ - `'top-left'`, `'top-right'`, `'bottom-left'`, `'bottom-right'` — corners
3212
+ - `'top'`, `'bottom'`, `'left'`, `'right'` — edge midpoints
3213
+
3214
+ ## Dimensions
3215
+
3216
+ Use `dim()` / `dimLine()` for visual measurement callouts and report annotations.
3217
+ See [../output/dimensions.md](../output/dimensions.md) for options and ownership behavior.
3218
+
3219
+ ---
3220
+
3221
+ <!-- API/model-building/sketch-primitives.md -->
3222
+
3223
+ # Sketch Primitives
3224
+
3225
+ 2D primitive shapes for creating sketches.
3226
+
3227
+ ## Functions
3228
+
3229
+ ### `rect(width, height, center?)`
3230
+ Creates a rectangle.
3231
+
3232
+ **Parameters:**
3233
+ - `width` (number) - Width
3234
+ - `height` (number) - Height
3235
+ - `center` (boolean, optional) - If true, centers at origin. Default: false (corner at origin)
3236
+
3237
+ ```javascript
3238
+ const r = rect(50, 30);
3239
+ const centered = rect(50, 30, true);
3240
+ ```
3241
+
3242
+ ### `circle2d(radius, segments?)`
3243
+ Creates a circle.
3244
+
3245
+ **Parameters:**
3246
+ - `radius` (number) - Circle radius
3247
+ - `segments` (number, optional) - Number of segments. Default: auto (smooth)
3248
+
3249
+ ```javascript
3250
+ const c = circle2d(25);
3251
+ const octagon = circle2d(25, 8);
3252
+ ```
3253
+
3254
+ ### `roundedRect(width, height, radius, center?)`
3255
+ Creates a rectangle with rounded corners.
3256
+
3257
+ **Parameters:**
3258
+ - `width` (number) - Width
3259
+ - `height` (number) - Height
3260
+ - `radius` (number) - Corner radius
3261
+ - `center` (boolean, optional) - If true, centers at origin. Default: false
3262
+
3263
+ ```javascript
3264
+ const rounded = roundedRect(60, 40, 5);
3265
+ ```
3266
+
3267
+ ### `polygon(points)`
3268
+ Creates a polygon from an array of [x, y] points or Point2D objects.
3269
+
3270
+ **Parameters:**
3271
+ - `points` (([number, number] | Point2D)[]) - Array of vertex coordinates or Point2D objects
3272
+
3273
+ ```javascript
3274
+ const triangle = polygon([[0, 0], [50, 0], [25, 40]]);
3275
+
3276
+ // Also accepts Point2D objects
3277
+ const p1 = point(0, 0), p2 = point(50, 0), p3 = point(25, 40);
3278
+ const triangle2 = polygon([p1, p2, p3]);
3279
+ ```
3280
+
3281
+ ### `ngon(sides, radius)`
3282
+ Creates a regular polygon (equilateral).
3283
+
3284
+ **Parameters:**
3285
+ - `sides` (number) - Number of sides
3286
+ - `radius` (number) - Radius from center to vertex
3287
+
3288
+ ```javascript
3289
+ const hex = ngon(6, 25);
3290
+ const triangle = ngon(3, 30);
3291
+ ```
3292
+
3293
+ ### `ellipse(rx, ry, segments?)`
3294
+ Creates an ellipse.
3295
+
3296
+ **Parameters:**
3297
+ - `rx` (number) - X radius
3298
+ - `ry` (number) - Y radius
3299
+ - `segments` (number, optional) - Number of segments. Default: 64
3300
+
3301
+ ```javascript
3302
+ const oval = ellipse(40, 20);
3303
+ ```
3304
+
3305
+ ### `slot(length, width)`
3306
+ Creates an oblong shape (rectangle with semicircle ends).
3307
+
3308
+ **Parameters:**
3309
+ - `length` (number) - Total length
3310
+ - `width` (number) - Width
3311
+
3312
+ ```javascript
3313
+ const oblong = slot(60, 20);
3314
+ ```
3315
+
3316
+ ### `star(points, outerRadius, innerRadius)`
3317
+ Creates a star shape.
3318
+
3319
+ **Parameters:**
3320
+ - `points` (number) - Number of star points
3321
+ - `outerRadius` (number) - Outer radius (tip of points)
3322
+ - `innerRadius` (number) - Inner radius (between points)
3323
+
3324
+ ```javascript
3325
+ const star5 = star(5, 30, 15);
3326
+ ```
3327
+
3328
+ ---
3329
+
3330
+ <!-- API/model-building/sketch-path.md -->
3331
+
3332
+ # Sketch Path Builder
3333
+
3334
+ Fluent API for tracing 2D outlines point by point.
3335
+
3336
+ ## Class: PathBuilder
3337
+
3338
+ ### `path()`
3339
+ Creates a new path builder.
3340
+
3341
+ ```javascript
3342
+ const triangle = path()
3343
+ .moveTo(0, 0)
3344
+ .lineH(50)
3345
+ .lineV(30)
3346
+ .close();
3347
+ ```
3348
+
3349
+ ### Methods
3350
+
3351
+ #### `.moveTo(x, y)`
3352
+ Set starting point.
3353
+
3354
+ #### `.lineTo(x, y)`
3355
+ Line to absolute position.
3356
+
3357
+ #### `.lineH(dx)`
3358
+ Horizontal line (relative).
3359
+
3360
+ #### `.lineV(dy)`
3361
+ Vertical line (relative).
3362
+
3363
+ #### `.lineAngled(length, degrees)`
3364
+ Line at angle (0°=right, 90°=up).
3365
+
3366
+ #### `.close()`
3367
+ Close path into a `Sketch` (auto-fixes winding).
3368
+
3369
+ #### `.stroke(width, join?)`
3370
+ Thicken path into solid profile (see below).
3371
+
3372
+ ## Stroke
3373
+
3374
+ Thicken a polyline (centerline) into a solid profile with uniform width. Proper miter joins at vertices.
3375
+
3376
+ ### `path().stroke(width, join?)`
3377
+ ### `stroke(points, width, join?)`
3378
+
3379
+ **Parameters:**
3380
+ - `width` (number) — Profile thickness
3381
+ - `join` ('Square' | 'Round', optional) — Corner style. Default: 'Square' (miter)
3382
+
3383
+ **Returns:** `Sketch`
3384
+
3385
+ ```javascript
3386
+ // Fluent path builder
3387
+ const bracket = path()
3388
+ .moveTo(0, 0)
3389
+ .lineH(50)
3390
+ .lineV(-70)
3391
+ .lineAngled(20, 235)
3392
+ .stroke(4);
3393
+
3394
+ // Or with point array
3395
+ const bracket = stroke([[0, 0], [50, 0], [50, -70]], 4);
3396
+
3397
+ // Rounded corners
3398
+ const rounded = stroke([[0, 0], [50, 0], [50, -50]], 4, 'Round');
3399
+ ```
3400
+
3401
+ Use `stroke(..., 'Round')` for centerline-based geometry such as ribs, traces, and wire-like profiles. It is not the same as rounding selected corners of an existing closed polygon. For mixed sharp-and-rounded outlines, build the polygon first and use `filletCorners(...)`.
3402
+
3403
+ ---
3404
+
3405
+ <!-- API/model-building/sketch-transforms.md -->
3406
+
3407
+ # Sketch Transforms
3408
+
3409
+ 2D transformations for sketches. All transforms are **chainable** and **immutable** (return new sketches). Colors are preserved through all transforms.
3410
+
3411
+ ## Methods
3412
+
3413
+ ### `.clone()` / `.duplicate()`
3414
+ Create an explicit copy handle of a sketch (same profile/color) so variants are easy to branch.
3415
+
3416
+ ```javascript
3417
+ const profile = rect(40, 20);
3418
+ const left = profile.clone().translate(-30, 0);
3419
+ const right = profile.duplicate().translate(30, 0);
3420
+ ```
3421
+
3422
+ ### `.translate(x, y?)`
3423
+ Moves the sketch.
3424
+
3425
+ ```javascript
3426
+ const moved = rect(50, 30).translate(100, 50);
3427
+ ```
3428
+
3429
+ ### `.rotate(degrees)`
3430
+ Rotates around the origin.
3431
+
3432
+ ```javascript
3433
+ const rotated = rect(50, 30).rotate(45);
3434
+ ```
3435
+
3436
+ ### `.rotateAround(degrees, pivot)`
3437
+ Rotates around a specific point instead of origin.
3438
+
3439
+ **Parameters:**
3440
+ - `degrees` (number) — Rotation angle
3441
+ - `pivot` ([number, number]) — Point to rotate around
3442
+
3443
+ ```javascript
3444
+ const hook = rect(4, 20).rotateAround(-35, [2, 0]);
3445
+ ```
3446
+
3447
+ ### `.scale(v)`
3448
+ Scales the sketch.
3449
+
3450
+ **Parameters:**
3451
+ - `v` (number | [number, number]) — Uniform scale or per-axis scale
3452
+
3453
+ ```javascript
3454
+ const bigger = circle2d(10).scale(2);
3455
+ const stretched = rect(10, 10).scale([2, 0.5]);
3456
+ ```
3457
+
3458
+ ### `.mirror(normal)`
3459
+ Mirrors across a line defined by its normal vector.
3460
+
3461
+ **Parameters:**
3462
+ - `normal` ([number, number]) — Line normal (doesn't need to be unit length)
3463
+
3464
+ ```javascript
3465
+ const mirrored = sketch.mirror([1, 0]); // Mirror across Y axis
3466
+ ```
3467
+
3468
+ ---
3469
+
3470
+ <!-- API/model-building/sketch-booleans.md -->
3471
+
3472
+ # Sketch Booleans
3473
+
3474
+ 2D boolean operations for combining, subtracting, and intersecting sketches.
3475
+
3476
+ ## Methods
3477
+
3478
+ ### `.add(...others)`
3479
+ Combines sketches (union). Accepts `sketch.add(a, b)` and `sketch.add([a, b])`.
3480
+
3481
+ ```javascript
3482
+ const combined = rect(50, 30).add(
3483
+ circle2d(20).translate(25, 15),
3484
+ ngon(6, 15).translate(40, 15)
3485
+ );
3486
+ ```
3487
+
3488
+ ### `.subtract(...others)`
3489
+ Subtracts one or more sketches from this one. Accepts `sketch.subtract(a, b)` and `sketch.subtract([a, b])`.
3490
+
3491
+ ```javascript
3492
+ const plate = rect(100, 80);
3493
+ const hole = circle2d(10);
3494
+ const slotCut = rect(18, 8).translate(41, 36);
3495
+ const result = plate.subtract(hole.translate(25, 40), slotCut);
3496
+ ```
3497
+
3498
+ ### `.intersect(...others)`
3499
+ Keeps only the area shared by every operand. Accepts `sketch.intersect(a, b)` and `sketch.intersect([a, b])`.
3500
+
3501
+ ```javascript
3502
+ const overlap = rect(50, 50).intersect(
3503
+ circle2d(30).translate(25, 25),
3504
+ rect(40, 20).translate(5, 15)
3505
+ );
3506
+ ```
3507
+
3508
+ ## Functions
3509
+
3510
+ ### `union2d(...sketches)`
3511
+ Combines multiple sketches into one.
3512
+
3513
+ ```javascript
3514
+ const combined = union2d(
3515
+ rect(50, 30),
3516
+ circle2d(20).translate(25, 15),
3517
+ ngon(6, 15).translate(75, 15)
3518
+ );
3519
+ ```
3520
+
3521
+ `union2d([a, b, c])` is also supported when your sketches are already in an array.
3522
+
3523
+ ### `difference2d(...sketches)`
3524
+ Subtracts sketches[1..n] from sketches[0].
3525
+
3526
+ ```javascript
3527
+ const plate = rect(100, 80);
3528
+ const hole1 = circle2d(10).translate(25, 40);
3529
+ const hole2 = circle2d(10).translate(75, 40);
3530
+ const result = difference2d(plate, hole1, hole2);
3531
+ ```
3532
+
3533
+ `difference2d([base, cutter1, cutter2])` works too.
3534
+
3535
+ ### `intersection2d(...sketches)`
3536
+ Keeps only the area where all sketches overlap.
3537
+
3538
+ ```javascript
3539
+ const overlap = intersection2d(
3540
+ rect(50, 50),
3541
+ circle2d(30).translate(25, 25)
3542
+ );
3543
+ ```
3544
+
3545
+ `intersection2d([a, b, c])` is also supported.
3546
+
3547
+ ### `hull2d(...sketches)`
3548
+ Creates the convex hull of multiple sketches.
3549
+
3550
+ ```javascript
3551
+ const hull = hull2d(
3552
+ circle2d(10),
3553
+ circle2d(10).translate(50, 0),
3554
+ circle2d(10).translate(25, 40)
3555
+ );
3556
+ ```
3557
+
3558
+ `hull2d([a, b, c])` is also supported when your sketches are already in an array.
3559
+
3560
+ `hull2d()` is best for intentionally blended convex silhouettes. If you need true corner fillets while keeping some neighboring corners sharp, use `filletCorners(...)` instead.
3561
+
3562
+ ## Performance Note
3563
+
3564
+ The multi-argument functions (`union2d`, `difference2d`, `intersection2d`) use Manifold's batch operations internally, which are faster than chaining `.add()` / `.subtract()` calls one by one. Prefer them when combining many sketches.
3565
+
3566
+ ```javascript
3567
+ // Fast — single batch operation
3568
+ const combined = union2d(s1, s2, s3, s4, s5);
3569
+
3570
+ // Slower — sequential pairwise operations
3571
+ const combined = s1.add(s2).add(s3).add(s4).add(s5);
3572
+ ```
3573
+
3574
+ ---
3575
+
3576
+ <!-- API/model-building/sketch-operations.md -->
3577
+
3578
+ # Sketch Operations
3579
+
3580
+ 2D operations for modifying sketch contours.
3581
+
3582
+ ## Methods
3583
+
3584
+ All operations preserve the sketch's color.
3585
+
3586
+ ### `.offset(delta, join?)`
3587
+ Inflate (positive) or deflate (negative) the contour.
3588
+
3589
+ **Parameters:**
3590
+ - `delta` (number) - Offset distance. Positive = outward, negative = inward
3591
+ - `join` ('Square' | 'Round' | 'Miter', optional) - Corner style. Default: 'Round'
3592
+
3593
+ ```javascript
3594
+ const outer = rect(50, 30).offset(5); // Expand by 5mm
3595
+ const inner = circle2d(20).offset(-2); // Shrink by 2mm
3596
+ const sharp = ngon(6, 20).offset(3, 'Miter');
3597
+ ```
3598
+
3599
+ Use the common `offset(-r).offset(+r)` pattern when you want to round **every convex corner** of a closed sketch.
3600
+
3601
+ ### `filletCorners(points, corners)`
3602
+ Round only specific convex corners of a polygon point list.
3603
+
3604
+ **Parameters:**
3605
+ - `points` (([number, number] | Point2D)[]) - Closed polygon vertices in order
3606
+ - `corners` (`{ index: number, radius: number, segments?: number }[]`) - Which vertices to fillet
3607
+
3608
+ **Returns:** `Sketch`
3609
+
3610
+ ```javascript
3611
+ const roofPoints = [
3612
+ [0, 0],
3613
+ [90, 0],
3614
+ [90, 44],
3615
+ [66, 74],
3616
+ [45, 86],
3617
+ [24, 74],
3618
+ [0, 44],
3619
+ ];
3620
+
3621
+ const roof = filletCorners(roofPoints, [
3622
+ { index: 3, radius: 19 },
3623
+ { index: 4, radius: 19 },
3624
+ { index: 5, radius: 19 },
3625
+ ]);
3626
+ ```
3627
+
3628
+ Notes:
3629
+ - only convex corners are supported
3630
+ - if two neighboring fillets would overlap on the same edge, the function throws
3631
+ - compare `polygon(points)` and `filletCorners(points, ...)` before extruding when debugging mixed sharp-and-rounded outlines
3632
+
3633
+ ## Choosing A Rounding Strategy
3634
+
3635
+ - `offset(-r).offset(+r)` rounds all convex corners of an existing closed profile
3636
+ - `stroke(points, width, 'Round')` thickens a centerline path; use it for ribs, traces, and wire-like geometry
3637
+ - `hull2d()` of circles creates a blended convex silhouette, closer to a capsule or cap than a true corner fillet
3638
+ - `filletCorners(points, ...)` is the right tool when some corners stay sharp and others need true tangent fillets
3639
+ - See `examples/api/sketch-rounding-strategies.forge.js` for a side-by-side comparison
3640
+
3641
+ ### `.hull()`
3642
+ Returns the convex hull of this sketch.
3643
+
3644
+ ```javascript
3645
+ const hull = complexShape.hull();
3646
+ ```
3647
+
3648
+ ### `.simplify(epsilon?)`
3649
+ Removes vertices that don't significantly affect the shape.
3650
+
3651
+ **Parameters:**
3652
+ - `epsilon` (number, optional) - Tolerance for vertex removal. Default: 1e-6
3653
+
3654
+ ```javascript
3655
+ const simplified = complexSketch.simplify(0.1);
3656
+ ```
3657
+
3658
+ ### `.warp(fn)`
3659
+ Warp vertices with an arbitrary function.
3660
+
3661
+ **Parameters:**
3662
+ - `fn` ((vert: [number, number]) => void) - Function that modifies vertex coordinates in-place
3663
+
3664
+ ```javascript
3665
+ const warped = rect(50, 50).warp(([x, y]) => {
3666
+ // Modify x and y in place
3667
+ x += Math.sin(y * 0.1) * 5;
3668
+ });
3669
+ ```
3670
+
3671
+ ---
3672
+
3673
+ <!-- API/model-building/sketch-on-face.md -->
3674
+
3675
+ # Sketch On Face
3676
+
3677
+ Attach a 2D sketch to a 3D face so it renders in-place and extrudes along that face normal.
3678
+
3679
+ This supports:
3680
+ - canonical body faces: `front`, `back`, `left`, `right`, `top`, `bottom`
3681
+ - tracked planar faces on `TrackedShape`, like `side-left`
3682
+ - direct `FaceRef` targets from `tracked.face('top')`
3683
+ - supported compiler-owned created faces on `shell()` / `hole()` / `cutout()` results, such as `inner-side-right`, `floor`, `counterbore-floor`, and `wall-right`
3684
+ - supported compiler-owned created faces on `shell()` / `hole()` / `cutout()` results, such as `inner-side-right`, `floor`, `counterbore-floor`, and `wall-right`
3685
+ - defended preserved faces on compile-covered boolean results when one propagated descendant keeps a unique name
3686
+ - direct `FaceRef` targets from preserved/repeated descendants that still validate against a later compile-covered boolean target
3687
+
3688
+ ## `.onFace(parent, face, opts?)`
3689
+
3690
+ Places a sketch onto a parent face using face-local coordinates.
3691
+
3692
+ **Parameters:**
3693
+ - `parent` (`Shape | TrackedShape`) - target body
3694
+ - `face` (`'front' | 'back' | 'left' | 'right' | 'top' | 'bottom' | string | FaceRef`)
3695
+ - `opts` (object, optional):
3696
+ - `u` (number) - face-local horizontal offset from the face center
3697
+ - `v` (number) - face-local vertical offset from the face center
3698
+ - `protrude` (number) - offset along the face normal. Positive = outward
3699
+ - `selfAnchor` (`Anchor`) - which 2D sketch anchor aligns to the face center. Default: `'center'`
3700
+
3701
+ **Returns:** `Sketch`
3702
+
3703
+ ## `.onFace(faceRef, opts?)`
3704
+
3705
+ Places a sketch directly from a tracked or compiler-owned planar `FaceRef`.
3706
+
3707
+ This is useful when the script has already selected a face semantically:
3708
+
3709
+ ```javascript
3710
+ const panel = Rectangle2D.from3Points(
3711
+ point(-30, -18),
3712
+ point(28, -6),
3713
+ point(18, 24),
3714
+ ).extrude(16);
3715
+
3716
+ const cap = circle2d(5)
3717
+ .onFace(panel.face('top'), { u: 12, protrude: 0.05 })
3718
+ .extrude(1.2);
3719
+ ```
3720
+
3721
+ ```javascript
3722
+ const cup = roundedRect(70, 42, 5, true)
3723
+ .extrude(22)
3724
+ .shell(2, { openFaces: ['top'] });
3725
+
3726
+ const rib = rect(6, 4)
3727
+ .onFace(cup, 'inner-side-right', { u: 0, v: 0, protrude: 0.05 })
3728
+ .extrude(1.2);
3729
+ ```
3730
+
3731
+ ```javascript
3732
+ const body = box(120, 60, 40, true).color('#d8dce3');
3733
+
3734
+ const badge = roundedRect(28, 10, 2, true)
3735
+ .onFace(body, 'front', { v: 8 })
3736
+ .extrude(2)
3737
+ .color('#1d2733');
3738
+
3739
+ return [
3740
+ { name: 'Body', shape: body },
3741
+ { name: 'Badge', shape: badge },
3742
+ ];
3743
+ ```
3744
+
3745
+ ## Face-local coordinates
3746
+
3747
+ - Canonical faces:
3748
+ - `front` / `back`: `u = X`, `v = Z`
3749
+ - `left` / `right`: `u` runs across the face, `v = Z`
3750
+ - `top` / `bottom`: `u = X`, `v` runs across the face
3751
+ - Tracked planar faces use their own stored local frame:
3752
+ - side faces of extruded rectangles: `u` follows the source edge, `v = Z`
3753
+ - tracked `top` / `bottom` faces follow the source sketch axes
3754
+ - direct `FaceRef` placement uses that face's `uAxis` / `vAxis`
3755
+ - supported shell inner walls, blind-hole floors, counterbore shoulder floors, and defended cut walls reuse compiler-owned local frames for downstream workplanes
3756
+ - supported shell inner walls, blind-hole floors, counterbore shoulder floors, and defended cut walls reuse compiler-owned local frames for downstream workplanes
3757
+ - compile-covered `Shape` targets now resolve defended named faces through the shared face-query table before falling back to bare canonical body heuristics
3758
+
3759
+ The sketch's local `+Z` becomes the face normal, so `extrude(positive)` goes outward from that face.
3760
+
3761
+ ## Notes
3762
+
3763
+ - This is a planar face-placement feature, not arbitrary curved-surface projection.
3764
+ - Tracked curved faces like `cylinder(...).face('side')` are rejected because they do not have a planar sketch frame.
3765
+ - Supported created-face names on compiler-owned feature results are intentionally narrow, but defended split descendants now stay visible as semantic regions where Forge can keep one stable source surface.
3766
+ - Hole/cut host faces, supported `upToFace` termination faces, and defended boolean-difference / boolean-intersection descendants can now stay queryable as face regions instead of collapsing straight to "missing face".
3767
+ - Coplanar boolean face sets now stay placeable through `onFace(shape, name, ...)` when Forge can defend one shared planar frame; non-coplanar sets stay explicit and reject planar placement honestly.
3768
+ - The placed sketch still supports normal 2D operations like `translate`, `rotate`, `scale`, and sketch booleans before extrusion.
3769
+ - If multiple sketches share the same face placement, their 2D booleans preserve that shared placement.
3770
+ - If booleans mix sketches with different 3D placements, the result drops back to an unplaced sketch.
3771
+ - Extruding a placed sketch keeps the tracked `top` / `bottom` / `side` metadata from that extrusion, transformed into world space.
3772
+ - Projection-driven follow-on sketches now keep compiler-visible provenance when you `projectToPlane()` a compatible projected source back onto a matching parallel plane. The defended exact subset now covers straight extrusions plus compatible shell/hole/cut/union descendants that reduce to one planar projection basis, but arbitrary projection targets still stay runtime-only.
3773
+
3774
+ ---
3775
+
3776
+ <!-- API/model-building/sketch-extrude.md -->
3777
+
3778
+ # Sketch Extrude & Revolve
3779
+
3780
+ Convert 2D sketches into 3D shapes through extrusion or revolution. The sketch's color (if set) is carried over to the resulting Shape.
3781
+
3782
+ If a sketch has been placed with [`onFace()`](sketch-on-face.md), extrusion follows that face normal instead of the global Z axis.
3783
+
3784
+ ## Methods
3785
+
3786
+ ### `.extrude(height, options?)`
3787
+ Extrudes sketch along Z axis.
3788
+
3789
+ **Parameters:**
3790
+ - `height` (number) - Extrusion height
3791
+ - `options` (object, optional):
3792
+ - `twist` (number) - Twist angle in degrees
3793
+ - `divisions` (number) - Number of twist steps (needed for twist)
3794
+ - `scaleTop` (number | [number, number]) - Scale factor at top
3795
+ - `center` (boolean) - Center along Z axis
3796
+
3797
+ **Returns:** `TrackedShape` (with faces: top, bottom, side)
3798
+
3799
+ ```javascript
3800
+ const simple = rect(50, 30).extrude(10);
3801
+
3802
+ const twisted = ngon(6, 20).extrude(60, {
3803
+ twist: 90,
3804
+ divisions: 32
3805
+ });
3806
+
3807
+ const tapered = circle2d(20).extrude(50, {
3808
+ scaleTop: 0.5
3809
+ });
3810
+
3811
+ const badge = roundedRect(28, 10, 2, true)
3812
+ .onFace(box(120, 60, 40, true), 'front', { v: 8 })
3813
+ .extrude(2);
3814
+ ```
3815
+
3816
+ ### `.revolve(degrees?, segments?)`
3817
+ Revolves sketch around Y axis (becomes Z in result).
3818
+
3819
+ Performance tip: prefer `revolve()` over `loft()` whenever the part is rotationally symmetric. Loft is for profile interpolation and is substantially heavier.
3820
+
3821
+ **Parameters:**
3822
+ - `degrees` (number, optional) - Rotation angle. Default: 360 (full revolution)
3823
+ - `segments` (number, optional) - Number of segments. Default: auto
3824
+
3825
+ **Returns:** `Shape`
3826
+
3827
+ ```javascript
3828
+ // Vase profile
3829
+ const profile = polygon([[20, 0], [25, 30], [20, 60]]);
3830
+ const vase = profile.revolve();
3831
+
3832
+ // Partial revolution (C-shape)
3833
+ const partial = rect(5, 40).translate(20, 0).revolve(270);
3834
+ ```
3835
+
3836
+ ---
3837
+
3838
+ <!-- API/model-building/sketch-anchor.md -->
3839
+
3840
+ # Sketch Anchor Positioning
3841
+
3842
+ Position sketches relative to each other using named anchor points.
3843
+
3844
+ ## Methods
3845
+
3846
+ ### `.attachTo(target, targetAnchor, selfAnchor?, offset?)`
3847
+ Position a sketch relative to another using named anchor points.
3848
+
3849
+ **Parameters:**
3850
+ - `target` (Sketch) — The sketch to attach to
3851
+ - `targetAnchor` (Anchor) — Point on target: 'center', 'top-left', 'top-right', 'bottom-left', 'bottom-right', 'top', 'bottom', 'left', 'right'
3852
+ - `selfAnchor` (Anchor, optional) — Point on this sketch to align. Default: 'center'
3853
+ - `offset` ([number, number], optional) — Additional offset after alignment
3854
+
3855
+ **Returns:** `Sketch`
3856
+
3857
+ ```javascript
3858
+ const plate = rect(50, 4);
3859
+ const arm = rect(4, 70).attachTo(plate, 'bottom-left', 'top-left');
3860
+ return union2d(plate, arm);
3861
+
3862
+ // With offset: attach then shift 5mm right
3863
+ const shifted = rect(4, 70).attachTo(plate, 'bottom-left', 'top-left', [5, 0]);
3864
+ ```
3865
+
3866
+ ## Anchor Points
3867
+
3868
+ Available anchor positions:
3869
+ - `'center'` — geometric center
3870
+ - `'top-left'`, `'top-right'`, `'bottom-left'`, `'bottom-right'` — corners
3871
+ - `'top'`, `'bottom'`, `'left'`, `'right'` — edge midpoints
3872
+
3873
+ ---
3874
+
3875
+ <!-- API/guides/modeling-recipes.md -->
3876
+
3877
+ # Modeling Recipes
3878
+
3879
+ This file collects patterns, best practices, debugging tips, and example snippets that are useful once you already know the model-building API.
3880
+
3881
+ ## Iteration Bias
3882
+
3883
+ - For unfamiliar or high-risk geometry work, start in a notebook and keep setup, experiments, and validation in separate cells until the structure is obvious.
3884
+ - Default to a buildable first pass instead of a long proposal when the user clearly wants geometry changed.
3885
+ - Replace a broken or incoherent model wholesale when that is faster and cleaner than incremental patching.
3886
+ - Keep printed hardware structurally honest: use it for guides, spacers, retainers, and moderate-load mechanisms; use wood or metal for primary strength.
3887
+ - Validate early with `forgecad run <file>` and refine from the actual runtime result.
3888
+ - Prefer a few clean part files over one giant script once a design has repeated hardware or a small mechanism.
3889
+
3890
+ Notebook helpers worth using during iteration:
3891
+
3892
+ - `show(...)` pins the current intermediate geometry in the viewport
3893
+ - `forgecad notebook view <file> preview` prints the preview cell with stored outputs in the terminal
3894
+ - `forgecad run <file>.forge-notebook.json` validates the preview cell and runs the usual spatial analysis
3895
+
3896
+ ## Common Patterns
3897
+
3898
+ ### Parametric Box with Holes
3899
+ ```javascript
3900
+ const w = param("Width", 80, { min: 40, max: 150, unit: "mm" });
3901
+ const h = param("Height", 60, { min: 30, max: 100, unit: "mm" });
3902
+ const t = param("Thickness", 5, { min: 2, max: 10, unit: "mm" });
3903
+ const holeD = param("Hole Diameter", 8, { min: 4, max: 20, unit: "mm" });
3904
+
3905
+ const base = box(w, h, t);
3906
+ const hole = cylinder(t + 2, holeD / 2).translate(w / 2, h / 2, -1);
3907
+
3908
+ return base.subtract(hole);
3909
+ ```
3910
+
3911
+ ### Hollow Shell (Wall Thickness)
3912
+ ```javascript
3913
+ const outer = param("Outer Size", 50, { min: 20, max: 100, unit: "mm" });
3914
+ const wall = param("Wall", 3, { min: 1, max: 10, unit: "mm" });
3915
+
3916
+ const outerBox = box(outer, outer, outer, true);
3917
+ const innerBox = box(outer - 2 * wall, outer - 2 * wall, outer - 2 * wall, true);
3918
+
3919
+ return outerBox.subtract(innerBox);
3920
+ ```
3921
+
3922
+ ### Array/Pattern
3923
+ ```javascript
3924
+ const count = param("Count", 5, { min: 2, max: 10 });
3925
+ const spacing = param("Spacing", 15, { min: 5, max: 30, unit: "mm" });
3926
+
3927
+ let shapes = [];
3928
+ for (let i = 0; i < count; i++) {
3929
+ shapes.push(cylinder(10, 5).translate(i * spacing, 0, 0));
3930
+ }
3931
+
3932
+ return union(...shapes);
3933
+ ```
3934
+
3935
+ ### Sketch-Based Design
3936
+ ```javascript
3937
+ const sides = param("Sides", 6, { min: 3, max: 12 });
3938
+ const radius = param("Radius", 25, { min: 10, max: 50, unit: "mm" });
3939
+ const height = param("Height", 60, { min: 20, max: 120, unit: "mm" });
3940
+ const wall = param("Wall", 3, { min: 1, max: 8, unit: "mm" });
3941
+
3942
+ const outer = ngon(sides, radius);
3943
+ const inner = ngon(sides, radius - wall);
3944
+ const profile = outer.subtract(inner);
3945
+
3946
+ return profile.extrude(height, { twist: 45, divisions: 32 });
3947
+ ```
3948
+
3949
+ ### Rounded Profiles
3950
+ ```javascript
3951
+ const base = rect(50, 30).offset(-3, 'Round').offset(3, 'Round');
3952
+ return base.extrude(10);
3953
+ ```
3954
+
3955
+ Use that pattern when every convex corner should round. For mixed sharp-and-rounded outlines, fillet only the intended vertices instead:
3956
+
3957
+ ```javascript
3958
+ const roofPoints = [
3959
+ [0, 0],
3960
+ [90, 0],
3961
+ [90, 44],
3962
+ [66, 74],
3963
+ [45, 86],
3964
+ [24, 74],
3965
+ [0, 44],
3966
+ ];
3967
+
3968
+ const roof = filletCorners(roofPoints, [
3969
+ { index: 3, radius: 19 },
3970
+ { index: 4, radius: 19 },
3971
+ { index: 5, radius: 19 },
3972
+ ]);
3973
+
3974
+ return roof.extrude(12);
3975
+ ```
3976
+
3977
+ ### Chamfers and Fillets
3978
+ ```javascript
3979
+ const part = box(50, 50, 20);
3980
+ const chamfer = box(10, 60, 10)
3981
+ .rotate(0, 45, 0)
3982
+ .translate(50, -5, 15);
3983
+
3984
+ return part.subtract(chamfer);
3985
+ ```
3986
+
3987
+ ### Choosing the right sketch-rounding tool
3988
+
3989
+ - `offset(-r).offset(+r)` for rounding every convex corner of a closed outline
3990
+ - `stroke(points, width, 'Round')` for centerline-based geometry such as ribs or traces
3991
+ - `hull2d()` of circles for a blended cap/capsule silhouette
3992
+ - `filletCorners(points, ...)` for selective true-corner fillets on mixed profiles
3993
+
3994
+ ## Best Practices
3995
+
3996
+ ### Performance
3997
+ - Boolean operations are expensive; minimize them
3998
+ - Use parameters for values that might change
3999
+ - Avoid deep nesting of operations in loops
4000
+
4001
+ ### Readability
4002
+ ```javascript
4003
+ const base = box(100, 100, 10);
4004
+ const hole = cylinder(12, 8);
4005
+ const result = base.subtract(hole.translate(50, 50, 0));
4006
+ return result;
4007
+ ```
4008
+
4009
+ Prefer named intermediate values over deeply nested one-liners.
4010
+
4011
+ ### Units
4012
+ - All dimensions are millimeters by default
4013
+ - Angles are degrees
4014
+ - Use the `unit` parameter option when it helps the reader
4015
+
4016
+ ### Centering
4017
+ ```javascript
4018
+ const centered = box(50, 50, 50, true).translate(x, y, z);
4019
+ const corner = box(50, 50, 50).translate(x - 25, y - 25, z - 25);
4020
+ ```
4021
+
4022
+ Centered primitives are usually easier to position.
4023
+
4024
+ ## Debugging
4025
+
4026
+ ### Console Output
4027
+ ```javascript
4028
+ console.log("Width:", width);
4029
+ console.log("Volume:", shape.volume());
4030
+ ```
4031
+
4032
+ ### Incremental Building
4033
+ ```javascript
4034
+ const base = box(50, 50, 10);
4035
+ // return base;
4036
+
4037
+ const withHole = base.subtract(cylinder(12, 5).translate(25, 25, 0));
4038
+ // return withHole;
4039
+
4040
+ return withHole.add(cylinder(20, 3).translate(25, 25, 10));
4041
+ ```
4042
+
4043
+ For sketch-heavy work, compare the raw profile and the rounded profile before extruding:
4044
+
4045
+ ```javascript
4046
+ const raw = polygon(roofPoints);
4047
+ const rounded = filletCorners(roofPoints, [
4048
+ { index: 3, radius: 19 },
4049
+ { index: 4, radius: 19 },
4050
+ { index: 5, radius: 19 },
4051
+ ]);
4052
+
4053
+ return [
4054
+ { name: "Raw", sketch: raw },
4055
+ { name: "Rounded", sketch: rounded.translate(120, 0) },
4056
+ ];
4057
+ ```
4058
+
4059
+ ## Error Handling
4060
+
4061
+ Common errors:
4062
+ - `"Kernel not initialized"` - internal/runtime issue, reload the app
4063
+ - `"Cannot read property of undefined"` - usually a bad variable name or missing declaration
4064
+ - invalid geometry - commonly caused by zero dimensions or self-intersecting sketches
4065
+ - script execution error - inspect the JS error in console output
4066
+
4067
+ ## Example Snippets
4068
+
4069
+ ### Parametric Phone Stand
4070
+ ```javascript
4071
+ const width = param("Width", 80, { min: 40, max: 150, unit: "mm" });
4072
+ const depth = param("Depth", 60, { min: 30, max: 100, unit: "mm" });
4073
+ const thick = param("Thickness", 5, { min: 2, max: 15, unit: "mm" });
4074
+ const backH = param("Back Height", 40, { min: 20, max: 80, unit: "mm" });
4075
+ const cableD = param("Cable Hole", 8, { min: 4, max: 15, unit: "mm" });
4076
+
4077
+ const base = box(width, depth, thick);
4078
+ const back = box(width, thick, backH).translate(0, depth - thick, thick);
4079
+ const lip = box(width, 10, 8).translate(0, 0, thick);
4080
+ const hole = cylinder(thick + 2, cableD / 2)
4081
+ .rotate(90, 0, 0)
4082
+ .translate(width / 2, depth / 2, -1);
4083
+
4084
+ return union(base, back, lip).subtract(hole);
4085
+ ```
4086
+
4087
+ ### Multi-Object Scene with Colors
4088
+ ```javascript
4089
+ const base = box(100, 100, 5).color('#888888');
4090
+ const col1 = cylinder(40, 5).translate(20, 20, 5).color('#cc4444');
4091
+ const col2 = cylinder(40, 5).translate(80, 20, 5).color('#4444cc');
4092
+ const col3 = cylinder(40, 5).translate(50, 80, 5).color('#44cc44');
4093
+ const top = box(100, 100, 3).translate(0, 0, 45).color('#888888');
4094
+
4095
+ return [
4096
+ { name: "Base", shape: base },
4097
+ { name: "Column A", shape: col1 },
4098
+ { name: "Column B", shape: col2 },
4099
+ { name: "Column C", shape: col3 },
4100
+ { name: "Top", shape: top },
4101
+ ];
4102
+ ```
4103
+
4104
+ ### Entity-Based Design with Topology
4105
+ ```javascript
4106
+ const baseRect = rectangle(0, 0, 80, 60);
4107
+ const base = baseRect.extrude(20);
4108
+
4109
+ const result = filletEdge(base.toShape(), base.edge('vert-br'), 8, [-1, -1])
4110
+ .hole(base.face('top'), { diameter: 6, u: -16, v: 10, depth: 8 });
4111
+
4112
+ const holes = circularPattern(
4113
+ cylinder(25, 4).translate(40, 30, -1),
4114
+ 4, 40, 30,
4115
+ );
4116
+
4117
+ return result.subtract(holes);
4118
+ ```
4119
+
4120
+ Use the original tracked body (`base`) when you need semantic faces after edge finishing, and keep using its untouched sibling vertical tracked edges if you apply another supported fillet/chamfer later. Those sibling edges can now also survive a later supported union when the compiler still records one preserved propagated edge lineage for them. The currently selected finished edge is still recorded as a merged descendant set, so Forge does not claim a new durable tracked edge for that rewritten corner yet.
4121
+
4122
+ For larger runnable examples, read `examples/api/`.
4123
+
4124
+ ---
4125
+
4126
+ <!-- CLI.md -->
4127
+
4128
+ # ForgeCAD CLI
4129
+
4130
+ ## Architecture
4131
+
4132
+ All CLI tools share the **same forge engine** as the browser UI. There is one source of truth for geometry logic — no code duplication.
4133
+
4134
+ ```
4135
+ src/forge/headless.ts ← Single entry point for all contexts
4136
+ ├── kernel.ts ← Manifold WASM wrapper (Shape, box, cylinder, sphere, etc.)
4137
+ ├── runner.ts ← Script sandbox (Function() with full forge API injected)
4138
+ ├── section.ts ← Plane intersection / projection
4139
+ ├── sketch/ ← Complete 2D sketch system (primitives, transforms, booleans,
4140
+ │ constraints, entities, topology, patterns, fillets, arc bridge)
4141
+ ├── params.ts ← Parameter system
4142
+ ├── library.ts ← Part library
4143
+ ├── meshToGeometry.ts ← Manifold mesh → Three.js BufferGeometry
4144
+ └── sceneBuilder.ts ← Three.js scene setup (lighting, camera, materials)
4145
+ ```
4146
+
4147
+ **Browser** imports via `src/forge/index.ts` → re-exports from `headless.ts`.
4148
+ **CLI tools** import directly from `src/forge/headless.ts`.
4149
+
4150
+ The key function is `runScript(code, fileName, allFiles)` — it wraps user code in a `Function()` sandbox with the entire forge API injected, and transpiles project files so standard JS `import` / `export` / `require(...)` work for shared utility modules. CLI scripts just call `init()` + `runScript()` and work with the results.
4151
+
4152
+ ## Install
4153
+
4154
+ Install the package and link the local binary once:
4155
+
4156
+ ```bash
4157
+ npm install
4158
+ npm link
4159
+ ```
4160
+
4161
+ After that, use `forgecad ...` directly from your shell.
4162
+
4163
+ ### Shell Autocomplete
4164
+
4165
+ ForgeCAD now ships shell completion scripts in the usual modern-tool style:
4166
+
4167
+ ```bash
4168
+ forgecad completion bash
4169
+ forgecad completion zsh
4170
+ forgecad completion fish
4171
+ ```
4172
+
4173
+ Quick install:
4174
+
4175
+ ```bash
4176
+ # bash
4177
+ echo 'source <(forgecad completion bash)' >> ~/.bashrc
4178
+
4179
+ # zsh
4180
+ mkdir -p ~/.zsh/completions
4181
+ forgecad completion zsh > ~/.zsh/completions/_forgecad
4182
+ echo 'fpath=(~/.zsh/completions $fpath)' >> ~/.zshrc
4183
+ echo 'autoload -Uz compinit && compinit' >> ~/.zshrc
4184
+
4185
+ # fish
4186
+ mkdir -p ~/.config/fish/completions
4187
+ forgecad completion fish > ~/.config/fish/completions/forgecad.fish
4188
+ ```
4189
+
4190
+ The completions are contextual:
4191
+
4192
+ - nested subcommands such as `forgecad notebook view` and `forgecad export step`
4193
+ - command-specific flags and common enum values
4194
+ - ForgeCAD file suggestions where a command expects `.forge.js`, `.sketch.js`, or `.forge-notebook.json`
4195
+
4196
+ ## Available Commands
4197
+
4198
+ ### Notebook Cells (server-backed)
4199
+
4200
+ Forge notebooks live in `.forge-notebook.json` files and behave like lightweight Jupyter notebooks for ForgeCAD code cells.
4201
+
4202
+ The browser and CLI both use the Vite server for notebook execution. The CLI does not run Forge locally for notebook cells; it auto-starts or reuses the Forge server, sends the cell code, then prints the returned output summary.
4203
+
4204
+ Append a new code cell and run it immediately in one command:
4205
+
4206
+ ```bash
4207
+ forgecad notebook examples/demo.forge-notebook.json --code "show(box(40, 20, 10));"
4208
+ ```
4209
+
4210
+ If the target notebook file does not exist yet, append mode auto-creates it first with the default ForgeCAD notebook structure, then adds the new cell.
4211
+
4212
+ Or pipe a larger cell in through stdin:
4213
+
4214
+ ```bash
4215
+ cat /tmp/cell.js | forgecad notebook examples/demo.forge-notebook.json
4216
+ ```
4217
+
4218
+ Re-run the last preview cell, or a specific cell id:
4219
+
4220
+ ```bash
4221
+ forgecad notebook examples/demo.forge-notebook.json
4222
+ forgecad notebook run examples/demo.forge-notebook.json <cell-id>
4223
+ ```
4224
+
4225
+ View the notebook in the terminal without dumping raw JSON:
4226
+
4227
+ ```bash
4228
+ forgecad notebook view examples/demo.forge-notebook.json
4229
+ forgecad notebook view examples/demo.forge-notebook.json preview
4230
+ forgecad notebook view examples/demo.forge-notebook.json 2
4231
+ ```
4232
+
4233
+ `view` is local-only. It parses the notebook JSON and renders notebook metadata, numbered source lines, and stored outputs for each cell. The optional selector accepts a 1-based cell number, an exact cell id, or `preview`.
4234
+
4235
+ `run`/`view` expect the notebook file to already exist. Auto-creation only applies to append flows (`--code`, `--file`, stdin, or the explicit `append` subcommand).
4236
+
4237
+ Export a notebook into a plain `.forge.js` script:
4238
+
4239
+ ```bash
4240
+ forgecad notebook export examples/demo.forge-notebook.json
4241
+ forgecad notebook export examples/demo.forge-notebook.json out/demo-from-notebook.forge.js
4242
+ ```
4243
+
4244
+ If you already have a Forge server running, point the CLI at it:
4245
+
4246
+ ```bash
4247
+ forgecad notebook examples/demo.forge-notebook.json --server http://localhost:5173 --code "show(box(40, 20, 10));"
4248
+ ```
4249
+
4250
+ Notebook paths are resolved from the shell working directory before the CLI calls the server, so the server's opened project root does not add an extra path prefix.
4251
+
4252
+ Notebook cell behavior:
4253
+
4254
+ - Cells share state top-to-bottom
4255
+ - `show(value)` pins the geometry that should stay visible in the viewport
4256
+ - A trailing expression is also treated as the cell value
4257
+ - Cell outputs are written back into the notebook JSON, similar to Jupyter
4258
+
4259
+ For the `forgecad` entrypoints below, passing a `.forge-notebook.json` uses that notebook's preview cell. That means you can inspect with `view`, validate with `run`, and render or capture the current preview without exporting first.
4260
+
4261
+ ### Script Validation
4262
+
4263
+ ```bash
4264
+ forgecad run examples/cup.forge.js
4265
+ forgecad run examples/api/notebook-iteration.forge-notebook.json
4266
+ forgecad run examples/cup.forge.js --debug-imports
4267
+ ```
4268
+
4269
+ Runs a `.forge.js`, `.sketch.js`, or notebook preview cell in the real runtime and prints object stats, diagnostics, and execution time.
4270
+
4271
+ `--debug-imports` adds an import trace (source file, target file, overrides, return type, success/error phase), useful when debugging `importPart()`/`importSketch()` behavior.
4272
+
4273
+ ### SVG Export (no browser needed)
4274
+
4275
+ ```bash
4276
+ forgecad export svg examples/frame.sketch.js [output.svg]
4277
+ ```
4278
+
4279
+ Runs a `.sketch.js` script in Node.js using the real forge engine and outputs SVG. No browser, no Puppeteer — pure Node.
4280
+
4281
+ **How it works:** Initializes the Manifold WASM kernel, runs the script through `runScript()`, extracts the Sketch result, converts polygons to SVG paths.
4282
+
4283
+ ### STEP / BREP Export (exact subset, Python + CadQuery)
4284
+
4285
+ ```bash
4286
+ forgecad export step examples/api/brep-exportable.forge.js
4287
+ forgecad export brep examples/api/brep-exportable.forge.js
4288
+
4289
+ # Optional overrides:
4290
+ forgecad export step examples/api/brep-exportable.forge.js --output out/demo.step
4291
+ forgecad export step examples/api/brep-exportable.forge.js --python 3.11
4292
+ forgecad export step examples/api/brep-exportable.forge.js --uv /custom/path/to/uv
4293
+ forgecad export step examples/chess-set.forge.js --allow-faceted
4294
+ ```
4295
+
4296
+ This exporter is `uv`-first. `cli/forge-brep-export.py` carries inline dependency metadata, so `uv run` provisions CadQuery automatically for the exporter environment.
4297
+
4298
+ By default this exporter is exact-subset only. It does **not** silently convert arbitrary triangle meshes back into fake BREP. Instead, Forge lowers compile-covered geometry into the `cadquery-occt` compiler target and exports that exact subset through CadQuery/OpenCascade.
4299
+
4300
+ If you pass `--allow-faceted`, unsupported closed mesh solids are exported as explicit faceted OCCT solids. This keeps hull-heavy designs exportable to STEP/BREP, but that fallback is tessellation-driven rather than exact replay.
4301
+
4302
+ The maintained feature matrix lives in [`docs/permanent/API/output/brep-export.md`](API/output/brep-export.md).
4303
+
4304
+ If any returned solid object falls outside the exact subset, the CLI fails with a reason instead of silently exporting degraded geometry. When a scene mixes solids and 2D sketches, the exact solids export and the sketch-only objects are skipped with a warning.
4305
+
4306
+ With `--allow-faceted`, mesh-solid blockers that still lack an exact replay plan are exported as faceted solids instead of failing. The CLI prints which objects used the fallback.
4307
+
4308
+ For coverage runs across many examples, use the `uv` matrix scripts:
4309
+
4310
+ ```bash
4311
+ uv run scripts/brep/matrix.py --format step examples
4312
+ uv run scripts/brep/matrix.py --format brep examples
4313
+ uv run scripts/brep/rerun_failures.py tmp/brep-matrix-step-20260306T120000Z.json
4314
+ ```
4315
+
4316
+ These scripts use the repo-local `.venv-brep/.venv/bin/python` by default, run exports through a bounded parallel worker pool, and write JSON reports under `tmp/`.
4317
+
4318
+ ### SDF Robot Export (Gazebo package)
4319
+
4320
+ ```bash
4321
+ forgecad export sdf examples/api/sdf-rover-demo.forge.js
4322
+
4323
+ # Optional output directory:
4324
+ forgecad export sdf examples/api/sdf-rover-demo.forge.js --output out/forge_scout
4325
+ ```
4326
+
4327
+ This exporter writes a Gazebo-friendly package workspace:
4328
+
4329
+ - `models/<model-name>/model.sdf`
4330
+ - `models/<model-name>/model.config`
4331
+ - `models/<model-name>/meshes/*.stl`
4332
+ - `worlds/<world-name>.sdf` when the script requests a demo world
4333
+ - `manifest.json` with topic names, link/joint mappings, and exporter warnings
4334
+
4335
+ The script must call `robotExport({...})` with an `assembly(...)` graph. The exporter uses the declared parts + joints directly; it does **not** try to infer a robot from flattened scene meshes.
4336
+
4337
+ When `world.generateDemoWorld` and `world.keyboardTeleop.enabled` are on, the exported world includes both:
4338
+
4339
+ - Gazebo's GUI `KeyPublisher` plugin
4340
+ - server-side `TriggeredPublisher` bindings that map arrow keys to the diff-drive `cmd_vel` topic
4341
+
4342
+ Recommended launch flow:
4343
+
4344
+ ```bash
4345
+ export GZ_SIM_RESOURCE_PATH="$PWD/out/forge_scout/models${GZ_SIM_RESOURCE_PATH:+:$GZ_SIM_RESOURCE_PATH}"
4346
+
4347
+ # Terminal 1: server
4348
+ gz sim -s -r out/forge_scout/worlds/forge_scout_trial.sdf
4349
+
4350
+ # Terminal 2: GUI client using the same world layout
4351
+ gz sim -g out/forge_scout/worlds/forge_scout_trial.sdf
4352
+ ```
4353
+
4354
+ Notes:
4355
+
4356
+ - On macOS, use the split `-s` / `-g` flow above. `gz sim <world.sdf>` is not supported there.
4357
+ - Click the 3D view so it has keyboard focus, then use `W` / `X` for forward / reverse, `A` / `D` to rotate, `Q` / `E` / `Z` / `C` for diagonals, and `S` or `Space` to stop.
4358
+ - For older exports created before the GUI plugin was added, load `Key Publisher` manually from the Gazebo GUI plugins menu.
4359
+
4360
+ Current behavior:
4361
+
4362
+ - Per-link geometry is exported as STL mesh assets
4363
+ - Collision geometry reuses the same mesh unless `collision: 'none'` is set on a link
4364
+ - Link mass comes from `massKg`, else `densityKgM3 * volume`, else a default density
4365
+ - Inertia is an approximate box fit based on link bounds
4366
+ - Coupled joints are currently rejected
4367
+ - Parts without geometry are currently rejected
4368
+
4369
+ ### PNG Render (requires Chrome)
4370
+
4371
+ ```bash
4372
+ forgecad render examples/cup.forge.js [output.png]
4373
+ forgecad render examples/api/notebook-iteration.forge-notebook.json [output.png]
4374
+ forgecad render examples/cup.forge.js out/scene.png --scene '{"camera":{"projectionMode":"perspective","position":[200,-160,120],"target":[0,0,20],"up":[0,0,1]},"objects":{"obj-2":{"visible":false},"obj-3":{"opacity":0.35}}}'
4375
+ ```
4376
+
4377
+ Renders 3D shapes to PNG images from multiple camera angles. Uses Puppeteer to launch headless Chrome with WebGL for Three.js rendering.
4378
+
4379
+ When the input is a notebook, `forgecad render` renders the notebook's preview cell.
4380
+
4381
+ **How it works:**
4382
+ 1. `cli/forge-render.mjs` — Node launcher script. Auto-starts Vite dev server if not running, launches Puppeteer.
4383
+ 2. `cli/render.html` + `cli/render.ts` — Loaded in the browser by Puppeteer. Imports from `src/forge/headless.ts`, runs the script, builds a Three.js scene, renders from multiple angles.
4384
+ 3. Screenshots are captured as base64 PNG and saved to disk.
4385
+
4386
+ **Environment variables:**
4387
+
4388
+ | Variable | Default | Description |
4389
+ |----------|---------|-------------|
4390
+ | `FORGE_ANGLES` | `front,side,top,iso` | Camera angles to render |
4391
+ | `FORGE_SIZE` | `1024` | Image size in pixels |
4392
+ | `FORGE_PORT` | `5173` | Vite dev server port |
4393
+ | `CHROME_PATH` | Auto-detected | Chrome/Chromium executable path |
4394
+
4395
+ **CLI options:**
4396
+ - `--angles <front,side,top,iso>` — standard angles to render
4397
+ - `--size <px>` — output size override
4398
+ - `--port <n>` — Vite port override
4399
+ - `--camera <spec>` — exact camera pose, e.g. `proj=perspective;pos=120,80,120;target=0,0,0;up=0,0,1`
4400
+ - `--scene <json>` — full scene state copied from the viewport, including camera plus object visibility/opacity/color overrides
4401
+ - `--background <color>` — background override
4402
+ - `--chrome-path <path>` — Chrome executable path override
4403
+
4404
+ **Camera angles:** `front` (−Y), `back` (+Y), `side` (+X), `top` (+Z), `iso` (diagonal)
4405
+
4406
+ ### Animated Capture (GIF or MP4, requires Chrome)
4407
+
4408
+ ```bash
4409
+ forgecad capture gif examples/cup.forge.js [output.gif]
4410
+ forgecad capture mp4 examples/cup.forge.js [output.mp4]
4411
+ forgecad capture gif examples/api/notebook-assembly-debug.forge-notebook.json --list
4412
+ forgecad capture mp4 examples/api/runtime-joints-view.forge.js out/step.mp4 --capture animation --animation Step
4413
+ forgecad capture gif examples/3d-printer.forge.js out/section.gif --cut-plane "Front Section"
4414
+ ```
4415
+
4416
+ Creates high-quality animated captures from the real Forge viewport renderer:
4417
+ - Orbit captures with optional wireframe pass
4418
+ - Fixed-camera animation captures for `jointsView()` clips
4419
+ - Named cut-plane captures
4420
+ - Exact camera replay via `--camera`
4421
+ - Full viewport scene replay via `--scene`
4422
+
4423
+ When the input is a notebook, `forgecad capture gif` / `forgecad capture mp4` capture the notebook's preview cell.
4424
+
4425
+ **How it works:**
4426
+ 1. Auto-starts (or reuses) the Vite dev server.
4427
+ 2. Loads `cli/render.html` in headless Chrome.
4428
+ 3. Runs the script once, then captures frames from the same scene while applying the selected animation, cut planes, and camera pose.
4429
+ 4. Encodes with `ffmpeg` when available:
4430
+ - GIF: palettegen/paletteuse for much better colors
4431
+ - MP4: H.264 via `libx264`
4432
+ 5. Falls back to the pure-JS GIF encoder only when `ffmpeg` is unavailable.
4433
+
4434
+ **Options:**
4435
+ - `--format <gif|mp4>` — output format
4436
+ - `--capture <orbit|animation>` — moving orbit camera or fixed animation camera
4437
+ - `--animation <name>` — select one `jointsView()` clip
4438
+ - `--animation-loops <n>` — repeat the chosen clip
4439
+ - `--cut-plane <name>` — enable a named cut plane (repeatable)
4440
+ - `--camera <spec>` — exact camera pose, e.g. `proj=perspective;pos=120,80,120;target=0,0,0;up=0,0,1`
4441
+ - `--scene <json>` — full scene state copied from the viewport, including camera plus object visibility/opacity/color overrides
4442
+ - `--render-mode <solid|wireframe>` — primary render mode
4443
+ - `--include-wireframe-pass` / `--no-wireframe-pass` — control the extra wireframe pass
4444
+ - `--size <px>` — output frame resolution (default `960`)
4445
+ - `--pixel-ratio <n>` — render supersampling factor (default `2`)
4446
+ - `--fps <n>` — capture frame rate (default `24`)
4447
+ - `--frames-per-turn <n>` — frames per full orbit pass (default `72`)
4448
+ - `--hold-frames <n>` — freeze frames before each pass (default `6`)
4449
+ - `--pitch <deg>` — orbit elevation override
4450
+ - `--background <color>` — background color (default `#252526`)
4451
+ - `--quality <default|live|high>` — Forge geometry quality preset for export (default `high`)
4452
+ - `--encoder <auto|ffmpeg|js>` — GIF encoder strategy
4453
+ - `--crf <n>` — MP4 quality for `libx264` (default `18`)
4454
+ - `--list` — print the script's available animation clips and cut planes
4455
+ - `--port <n>` — Vite port (default `5173`)
4456
+ - `--chrome-path <path>` — Chrome executable path override
4457
+ - `--ffmpeg-path <path>` — ffmpeg executable path override
4458
+
4459
+ **Environment variables:**
4460
+ - `FORGE_CAPTURE_SIZE`
4461
+ - `FORGE_CAPTURE_PIXEL_RATIO`
4462
+ - `FORGE_CAPTURE_FPS`
4463
+ - `FORGE_CAPTURE_FRAMES_PER_TURN`
4464
+ - `FORGE_CAPTURE_HOLD_FRAMES`
4465
+ - `FORGE_CAPTURE_PITCH_DEG`
4466
+ - `FORGE_CAPTURE_BACKGROUND`
4467
+ - `FORGE_CAPTURE_QUALITY`
4468
+ - `FORGE_CAPTURE_ANIMATION_LOOPS`
4469
+ - `FORGE_CAPTURE_CRF`
4470
+ - `FFMPEG_PATH`
4471
+ - Legacy `FORGE_GIF_*` vars are still honored as fallbacks
4472
+ - `FORGE_PORT`
4473
+ - `CHROME_PATH`
4474
+
4475
+ **UI scene handoff:**
4476
+ - The View Panel exposes a `Camera` section.
4477
+ - Use `Copy CLI --scene` to grab the current viewport framing plus per-object scene overrides and paste it directly into `render`, `capture gif`, or `capture mp4`.
4478
+
4479
+ ### PDF Report (2D drawing pack)
4480
+
4481
+ ```bash
4482
+ forgecad export report examples/cup.forge.js [output.pdf]
4483
+ forgecad export report examples/cup.forge.js [output.pdf] --dim-angle-tol 18
4484
+ ```
4485
+
4486
+ Generates a searchable-text PDF report with multiple projected drawing views:
4487
+ - Bill of Materials page (auto-summed from script `bom()` entries)
4488
+ - Combined model page (front/right/top/isometric)
4489
+ - Disassembled component pages (same view set per unique component geometry; repeated identical items collapse into one page)
4490
+ - Auto-generated detail continuation pages for elongated/high-detail views (separate pages, not overlayed)
4491
+ - `dim()` annotations included per view only when their axis aligns with that view's projection plane axes
4492
+
4493
+ BOM aggregation rules:
4494
+ - Each `bom(quantity, description, { unit })` call contributes one raw entry
4495
+ - Report export groups by `key` (if provided) else by normalized `description + unit`
4496
+ - Quantities are summed per group and rendered as line items in the BOM table
4497
+
4498
+ Component dimension ownership for disassembled pages:
4499
+ - Preferred: explicit binding via `dim(..., { component: \"Part Name\" })`
4500
+ - Imported-part ownership: `dim(..., { currentComponent: true })` to pin to the owning returned component instance (no bbox heuristic)
4501
+ - Other-component ownership: `dim(..., { component: \"Tabletop\" })`
4502
+ - If multiple owners are bound (e.g. `currentComponent: true` plus another component), it is treated as shared and stays on the overview page
4503
+ - Fallback: automatic ownership only when both dimension endpoints are unambiguously inside exactly one returned component bounding box
4504
+ - Ambiguous dimensions are intentionally skipped for disassembled pages
4505
+
4506
+ Optional report flag:
4507
+ - `--dim-angle-tol <degrees>`: include dimensions whose projected direction is within this many degrees of the nearest view axis (default: `12`)
4508
+
4509
+ ### STL Export (from browser)
4510
+
4511
+ STL export is available in the browser UI via the Export panel. Binary STL format.
4512
+
4513
+ ### Parameter Validation
4514
+
4515
+ ```bash
4516
+ forgecad check params examples/shoe-rack-doors.forge.js [--samples 10]
4517
+ ```
4518
+
4519
+ Samples each parameter across its range and checks for runtime errors, degenerate geometry (volume ≈ 0), and new collisions between parts. Skips intra-group collisions when assembly groups are used.
4520
+
4521
+ **Options:**
4522
+ - `--samples N` — Number of sample points per parameter (default: 8)
4523
+
4524
+ **Output example:**
4525
+ ```
4526
+ ✓ Baseline: 6 objects, 12 params
4527
+ ✓ Checked 91 parameter samples (8 per param)
4528
+
4529
+ ⚠ Found 8 issues across 4 parameters:
4530
+
4531
+ Parameter "Bottom Left Door":
4532
+ 💥 New collision at values: -120.0, -102.9
4533
+ Bottom Left Door ∩ Frame (shared vol: 2561.9mm³)
4534
+ ```
4535
+
4536
+ ### Transform/Assembly Invariant Check
4537
+
4538
+ ```bash
4539
+ forgecad check transforms
4540
+ ```
4541
+
4542
+ Runs fast math-level invariants to catch transform order and frame composition regressions before they leak into examples.
4543
+
4544
+ ### Compiler Snapshot Check
4545
+
4546
+ ```bash
4547
+ forgecad check compiler
4548
+ forgecad check compiler --case segmented-runtime-hints
4549
+ forgecad check compiler --update
4550
+ ```
4551
+
4552
+ Runs curated compiler regression cases and compares them against committed snapshots.
4553
+ This is a unit-style invariant check, not just a debugger convenience.
4554
+ The ordinary multi-feature part corpus lives in [`examples/compiler-corpus/README.md`](../../examples/compiler-corpus/README.md).
4555
+
4556
+ Each snapshot records:
4557
+ - Forge compile plans
4558
+ - CadQuery/OCCT lowerings
4559
+ - export routing decisions
4560
+ - quantized runtime Manifold mesh summaries
4561
+ - quantized compiler-lowered Manifold mesh summaries
4562
+
4563
+ This check also fails if:
4564
+ - a plan-covered shape or sketch no longer matches its compiler-lowered runtime output
4565
+ - export manifests drift away from the per-object compiler routing decisions
4566
+ - exact/faceted support claims stop matching the lowered artifacts and diagnostics
4567
+
4568
+ ### Query Propagation Snapshot Check
4569
+
4570
+ ```bash
4571
+ forgecad check query-propagation
4572
+ forgecad check query-propagation --case hull-runtime-boundary
4573
+ forgecad check query-propagation --update
4574
+ ```
4575
+
4576
+ Runs focused topology-rewrite query-propagation snapshots without dumping the
4577
+ entire compiler scene. This keeps supported, ambiguous, and intentionally
4578
+ unsupported rewrite semantics reviewable as the propagation layer evolves.
4579
+
4580
+ Each snapshot records:
4581
+ - the propagated shape objects that actually carry topology-rewrite metadata
4582
+ - exact versus faceted routing outcomes for those objects
4583
+ - deterministic rewrite-operation ordering
4584
+ - preserved and created query summaries
4585
+ - explicit ambiguity/unsupported diagnostic codes
4586
+
4587
+ This check also fails if:
4588
+ - a defended propagation case loses the expected preserved or created query shape
4589
+ - a known unsupported rewrite stops reporting its explicit diagnostic boundary
4590
+ - a multi-feature corpus part stops surfacing the expected rewrite ordering
4591
+
4592
+ ### Example Architecture Gate
4593
+
4594
+ ```bash
4595
+ forgecad check examples
4596
+ forgecad check examples --family api-parts --family compiler-corpus
4597
+ forgecad check examples --example examples/api/brep-exportable.forge.js
4598
+ ```
4599
+
4600
+ Runs the checked example manifest for the entire `examples/` tree.
4601
+
4602
+ The manifest currently lives in `cli/example-manifest/` and covers every:
4603
+
4604
+ - `.forge.js`
4605
+ - `.sketch.js`
4606
+ - `.forge-notebook.json`
4607
+
4608
+ The command always verifies manifest coverage first, so it fails if:
4609
+
4610
+ - a new example file was added without classification
4611
+ - a checked manifest entry points at a missing file
4612
+ - an example's assigned validation path fails
4613
+ - a `part` example's declared route expectation no longer matches the compiler report
4614
+
4615
+ Current example classes:
4616
+
4617
+ - `part`: runtime execution plus optional exact/faceted route assertions on the selected primary shapes
4618
+ - `assembly`: runtime solve + scene emission, not exact-route parity
4619
+ - `runtime-scene`: viewport/report/runtime examples that still need to execute successfully
4620
+ - `sketch`: sketch payload validation via the sketch export path
4621
+ - `notebook`: preview-cell validation for `.forge-notebook.json`
4622
+ - `experimental`: temporary fenced examples that still have to run
4623
+
4624
+ The gate dispatches by declared validation path, not just by class label:
4625
+
4626
+ - `part-runtime`: execute and then enforce any declared exact/faceted route contract
4627
+ - `assembly-runtime`: execute and validate solved-scene/assembly-owned runtime behavior
4628
+ - `runtime-scene`: execute as a viewport/report/runtime scene without treating it as part-route evidence
4629
+ - `sketch-svg`: render returned sketch payloads through the sketch SVG path
4630
+ - `notebook-preview`: materialize and execute the notebook preview cell
4631
+ - `experimental-runtime`: execute only, while the example stays outside the active architecture claim
4632
+
4633
+ For non-part entries, the manifest can also pin specific runtime surfaces that
4634
+ must remain available to repo checks, such as BOM entries, cut planes,
4635
+ `jointsView()` controls, grouped scene structure, or collected
4636
+ `robotExport(...)` data.
4637
+
4638
+ Current part route states:
4639
+
4640
+ - `exact`: selected primary shapes must stay on the exact compiler route
4641
+ - `faceted`: exact must stay blocked and allow-faceted must succeed with diagnostics
4642
+ - `holdout`: runtime-checked, but intentionally outside the exact-route claim because the example still mixes route outcomes or depends on a documented unsupported capability; this is a temporary recovery state and should normally trend back to zero
4643
+
4644
+ Successful runs also print the current temporary fence list, including each
4645
+ remaining `holdout` or `experimental` entry's blocker and follow-up task, so
4646
+ the command output can be used directly in a phase-entry review.
4647
+
4648
+ Use `--family` when a task owns only one manifest lane, and `--example` when you
4649
+ want to debug a single checked artifact.
4650
+
4651
+ ### Invariant Test Suite
4652
+
4653
+ ```bash
4654
+ forgecad check suite
4655
+ npm test
4656
+ npm run test:examples
4657
+ npm run test:compiler
4658
+ npm run test:compiler:update
4659
+ npm run test:query-propagation
4660
+ npm run test:query-propagation:update
4661
+ ```
4662
+
4663
+ ForgeCAD's current unit-test surface is assertion-based CLI checks, not a separate Vitest/Jest harness.
4664
+
4665
+ The important entrypoints are:
4666
+ - `npm test` runs the repo invariant suite (`transforms`, `dimensions`, `placement`, `js-modules`, `brep`, `compiler`, `query-propagation`, `examples`, `api`)
4667
+ - `npm run test:examples` runs the example architecture gate across the checked `examples/` manifest
4668
+ - `npm run test:compiler` runs just the compiler snapshot/invariant suite
4669
+ - `npm run test:compiler:update` refreshes committed compiler snapshots after an intentional change
4670
+ - `npm run test:query-propagation` runs the focused topology-rewrite query-propagation snapshots
4671
+ - `npm run test:query-propagation:update` refreshes those query-propagation snapshots after an intentional change
4672
+ - `forgecad check suite` is the CLI equivalent of the invariant suite runner
4673
+
4674
+ ### Dimension Propagation Invariant Check
4675
+
4676
+ ```bash
4677
+ forgecad check dimensions
4678
+ ```
4679
+
4680
+ Runs shape-level invariants for dimension metadata propagation across:
4681
+ - transform APIs (`translate`, `rotate`, `transform`, `scale`, `mirror`, `rotateAround`)
4682
+ - copy/style APIs (`clone`, `color`, `setColor`, `smooth/refine/simplify`)
4683
+ - boolean APIs (`add/subtract/intersect`, plus `union/difference/intersection/hull3d`)
4684
+ - import runtime path (`importPart(...).color(...).translate(...)`)
4685
+
4686
+ ### Dimension Debugger
4687
+
4688
+ ```bash
4689
+ forgecad debug dimensions /path/to/file.forge.js [--all]
4690
+ forgecad debug dimensions /path/to/file.forge.js [--all] [--dim-angle-tol 12]
4691
+ ```
4692
+
4693
+ Prints:
4694
+ - total object count
4695
+ - total dimension count
4696
+ - per-view visibility counts (`front/right/top/iso`) using report angle tolerance
4697
+ - report ownership routing (`combined` vs `component:<name>`) per dimension
4698
+ - per-object approximate dimension ownership (both endpoints inside object bbox)
4699
+ - a dimension coordinate list (first 20 by default, `--all` for full dump)
4700
+
4701
+ ### Compiler Debugger
4702
+
4703
+ ```bash
4704
+ forgecad debug compiler /path/to/file.forge.js
4705
+ forgecad debug compiler /path/to/file.forge.js --compact
4706
+ ```
4707
+
4708
+ Prints JSON for the current script's compiler state, including:
4709
+ - per-object compile plans
4710
+ - CadQuery/OCCT lowering diagnostics and lowered plans
4711
+ - faceted fallback eligibility
4712
+ - runtime Manifold summaries
4713
+ - compiler-lowered Manifold summaries
4714
+
4715
+ ### Local Branch Cleanup
4716
+
4717
+ ```bash
4718
+ uv run cli/forge-prune-local-branches.py
4719
+ uv run cli/forge-prune-local-branches.py --dry-run
4720
+ uv run cli/forge-prune-local-branches.py --base mainline
4721
+ ```
4722
+
4723
+ This is a `uv`-backed Python utility for repository housekeeping. It finds local branches with no matching remote branch that are already merged into the selected base ref, shows them in a Rich terminal UI, then prompts one by one before deleting anything.
4724
+
4725
+ Behavior:
4726
+ - Deletes with `git branch -d`, not force-delete
4727
+ - Removes linked worktrees first when the branch is checked out in a secondary worktree
4728
+ - Requires an explicit `force` choice if one of those linked worktrees is dirty
4729
+ - Refuses to touch the current worktree, the primary worktree, or prunable/missing worktree entries
4730
+ - `--path` lets you point at any location inside the target repository
4731
+
4732
+ ## Adding New CLI Commands
4733
+
4734
+ 1. Create or extend a module under `cli/`
4735
+ 2. Import from `../src/forge/headless`
4736
+ 3. Call `await init()` to load the WASM kernel
4737
+ 4. Use `runScript(code, fileName, allFiles)` to execute user scripts
4738
+ 5. Register the new subcommand in `cli/forgecad.ts`
4739
+
4740
+ ### Minimal Example
4741
+
4742
+ ```typescript
4743
+ #!/usr/bin/env node
4744
+ import { readFileSync } from 'fs';
4745
+ import { init, runScript } from '../src/forge/headless';
4746
+
4747
+ const code = readFileSync(process.argv[2], 'utf-8');
4748
+
4749
+ await init();
4750
+ const result = runScript(code, 'main.forge.js', {});
4751
+
4752
+ if (result.error) {
4753
+ console.error(result.error);
4754
+ process.exit(1);
4755
+ }
4756
+
4757
+ for (const obj of result.objects) {
4758
+ if (obj.shape) {
4759
+ console.log(`${obj.name}: volume=${obj.shape.volume().toFixed(1)}mm³`);
4760
+ }
4761
+ if (obj.sketch) {
4762
+ console.log(`${obj.name}: area=${obj.sketch.area().toFixed(1)}mm²`);
4763
+ }
4764
+ }
4765
+ ```
4766
+
4767
+ ### Cross-file imports
4768
+
4769
+ When running scripts that use `importSketch()` / `importSvgSketch()` / `importPart()` or plain JS module imports, pass all project files (or at least all files reachable by imports), keyed by project-relative path. This supports root-relative and relative imports, utility `.js` modules, and `.svg` assets (`./assets/logo.svg`):
4770
+
4771
+ ```typescript
4772
+ import { readdirSync, readFileSync } from 'fs';
4773
+
4774
+ const allFiles: Record<string, string> = {};
4775
+ for (const f of readdirSync(scriptDir)) {
4776
+ if (f.endsWith('.forge.js') || f.endsWith('.sketch.js') || f.endsWith('.js') || f.endsWith('.svg')) {
4777
+ allFiles[f] = readFileSync(join(scriptDir, f), 'utf-8');
4778
+ }
4779
+ }
4780
+
4781
+ const result = runScript(code, 'main.forge.js', allFiles);
4782
+ ```
4783
+
4784
+ For utility modules that want explicit ForgeCAD imports instead of globals, use the virtual runtime module:
4785
+
4786
+ ```javascript
4787
+ import { box, union } from "forgecad";
4788
+ ```
4789
+
4790
+ Keep using `importPart()` / `importSketch()` for model/sketch files when you want ForgeCAD-specific behavior like param override scopes or SVG parsing.
4791
+
4792
+ ## Dependencies
4793
+
4794
+ | Package | Purpose | Context |
4795
+ |---------|---------|---------|
4796
+ | `forgecad` | Installable CLI binary (`forgecad ...`) | Runtime package |
4797
+ | `puppeteer-core` | Headless Chrome for PNG/GIF/MP4 rendering | Runtime dependency |
4798
+ | `manifold-3d` | Geometry kernel (WASM) | Works in both Node and browser |
4799
+ | `three` | 3D rendering (used by render.ts) | Loaded in browser context by Puppeteer |