partforge 0.40.0 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +46 -6
  2. package/bin/cli.js +103 -27
  3. package/docs/AUTHORING-PARTS.md +143 -15
  4. package/docs/ERROR-PATTERNS.md +6 -0
  5. package/package.json +48 -7
  6. package/skills/partforge/SKILL.md +17 -3
  7. package/src/app-embed-test.js +1 -1
  8. package/src/app-hinged-box.js +12 -0
  9. package/src/framework/animation-controls.js +243 -0
  10. package/src/framework/animation.js +217 -0
  11. package/src/framework/app.css +32 -0
  12. package/src/framework/assembly.js +1 -1
  13. package/src/framework/backend-select.js +25 -0
  14. package/src/framework/camera-tween.js +58 -0
  15. package/src/framework/chrome.css +16 -0
  16. package/src/framework/controls.js +13 -3
  17. package/src/framework/cutaway-gizmo-scene.js +244 -0
  18. package/src/framework/cutaway-gizmo.js +80 -243
  19. package/src/framework/default-view.js +46 -0
  20. package/src/framework/download.js +7 -2
  21. package/src/framework/export-controller.js +13 -2
  22. package/src/framework/geometry/probe.js +3 -22
  23. package/src/framework/jobs.js +23 -42
  24. package/src/framework/lint/finding.js +4 -0
  25. package/src/framework/lint/index.js +7 -3
  26. package/src/framework/lint/rules-animations.js +404 -0
  27. package/src/framework/lint/rules-place.js +76 -0
  28. package/src/framework/lint/rules-shape.js +12 -0
  29. package/src/framework/lint/rules-verify.js +2 -2
  30. package/src/framework/mount.js +113 -18
  31. package/src/{testing → framework/oracle}/build.js +1 -1
  32. package/src/framework/oracle/bvh.js +463 -0
  33. package/src/{testing → framework/oracle}/gaps.js +6 -3
  34. package/src/{testing → framework/oracle}/measure.js +34 -4
  35. package/src/framework/oracle/min-wall.js +98 -0
  36. package/src/{testing → framework/oracle}/verify.js +61 -5
  37. package/src/framework/param-deps.js +1 -1
  38. package/src/framework/part-model.js +48 -0
  39. package/src/framework/pick-request/client.js +11 -3
  40. package/src/framework/pick-request/endpoint.js +60 -0
  41. package/src/framework/pick-request/index.js +6 -0
  42. package/src/framework/pick-request/server.js +222 -34
  43. package/src/framework/pick-request/token-store.js +31 -0
  44. package/src/framework/pose-fast-path.js +12 -1
  45. package/src/framework/pose-probe-core.js +129 -0
  46. package/src/framework/pose-probe.js +7 -123
  47. package/src/framework/regen-loop.js +10 -3
  48. package/src/framework/safe-name.js +26 -0
  49. package/src/framework/verify-metrics.js +19 -6
  50. package/src/framework/view-state.js +25 -21
  51. package/src/framework/view-tabs.js +22 -7
  52. package/src/framework/viewer-controls.js +5 -26
  53. package/src/framework/viewer.js +126 -16
  54. package/src/hinged-box-worker.js +3 -0
  55. package/src/index.js +1 -1
  56. package/src/parts/hinged-box.js +94 -0
  57. package/src/testing/render.js +19 -8
  58. package/src/testing.js +15 -8
  59. package/types/derive.d.ts +14 -0
  60. package/types/geometry.d.ts +117 -0
  61. package/types/index.d.ts +240 -0
  62. package/types/kernel.d.ts +409 -0
  63. package/types/lint.d.ts +85 -0
  64. package/types/part.d.ts +381 -0
  65. package/types/testing.d.ts +362 -0
  66. package/types/worker.d.ts +21 -0
  67. package/src/testing/bvh.js +0 -273
  68. package/src/testing/min-wall.js +0 -38
  69. /package/src/{testing → framework/oracle}/assert-dsl.js +0 -0
  70. /package/src/{testing → framework/oracle}/cases.js +0 -0
  71. /package/src/{testing → framework/oracle}/dfm-profiles.js +0 -0
  72. /package/src/{testing → framework/oracle}/mesh.js +0 -0
@@ -5,6 +5,7 @@ import { LineSegments2 } from "three/addons/lines/LineSegments2.js";
5
5
  import { LineSegmentsGeometry } from "three/addons/lines/LineSegmentsGeometry.js";
6
6
  import { LineMaterial } from "three/addons/lines/LineMaterial.js";
7
7
  import { createCutaway } from "./cutaway.js";
8
+ import { createCameraTween } from "./camera-tween.js";
8
9
  import { addViewerLights, captureLightPoses, createCaptureLights } from "./viewer-lighting.js";
9
10
  import { CANONICAL_VIEWS, cameraPoseForView } from "./view-angles.js";
10
11
 
@@ -94,6 +95,10 @@ export function createViewer(container, part) {
94
95
  renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
95
96
  container.appendChild(renderer.domElement);
96
97
 
98
+ // Declared up here, not beside setActive() below, because the initial resize()
99
+ // runs during construction and reads it.
100
+ let active = true;
101
+
97
102
  const scene = new THREE.Scene();
98
103
 
99
104
  // Light/dark scene palettes (the page chrome is themed separately, via CSS on the
@@ -109,8 +114,6 @@ export function createViewer(container, part) {
109
114
 
110
115
  const controls = new OrbitControls(camera, renderer.domElement);
111
116
  controls.enableDamping = true;
112
- controls.autoRotate = true;
113
- controls.autoRotateSpeed = 1.6;
114
117
 
115
118
  // --- lights + grid --------------------------------------------------------
116
119
  const liveLights = addViewerLights(scene);
@@ -210,6 +213,39 @@ export function createViewer(container, part) {
210
213
  cutaway.setSubpart(name, subMesh[name], subLines[name]);
211
214
  }
212
215
 
216
+ // --- animation hooks --------------------------------------------------------
217
+ // Frame listeners get dt (seconds, clamped so a background-tab return doesn't
218
+ // fast-forward playback) inside the render loop — so a parked viewer
219
+ // (setActive(false)) automatically halts playback too: no loop, no ticks.
220
+ const frameListeners = new Set();
221
+ function onFrame(cb) { frameListeners.add(cb); return () => frameListeners.delete(cb); }
222
+
223
+ const camTween = createCameraTween();
224
+ // Tween the orbit camera to a canonical angle, framed on what's visible now.
225
+ // Presentational only; a caller passing duration 0 gets a jump cut.
226
+ function tweenCameraTo(viewName, { duration = 0.6, onComplete } = {}) {
227
+ const box = getVisibleWorldBounds();
228
+ if (!box || box.isEmpty()) { onComplete?.(); return; }
229
+ const center = box.getCenter(new THREE.Vector3()).toArray();
230
+ const size = box.getSize(new THREE.Vector3());
231
+ // radius = full max extent (not half), matching frameTo's framing distance so a
232
+ // live camera cue doesn't land twice as close as the reframe button and crop the part.
233
+ const pose = cameraPoseForView(viewName, { center, radius: Math.max(size.x, size.y, size.z) || 12 });
234
+ camTween.start(
235
+ { position: camera.position.toArray(), target: controls.target.toArray() },
236
+ { position: pose.position, target: pose.target },
237
+ { duration, onComplete },
238
+ );
239
+ }
240
+ const cancelCameraTween = () => camTween.cancel();
241
+
242
+ // User grabbing the orbit cancels any cue tween (the user owns the camera) and
243
+ // tells subscribers (the animation driver disarms remaining cues).
244
+ const cameraStartListeners = new Set();
245
+ const onControlsStart = () => { camTween.cancel(); for (const cb of [...cameraStartListeners]) cb(); };
246
+ controls.addEventListener("start", onControlsStart);
247
+ function onCameraStart(cb) { cameraStartListeners.add(cb); return () => cameraStartListeners.delete(cb); }
248
+
213
249
  // Smooth shading within CREASE_ANGLE of a shared edge, hard edge past it — so the
214
250
  // round body and helical groove read smooth while bore rims, drum faces, and
215
251
  // groove walls stay crisp. Lower = more hard edges; raise toward Math.PI/3 for
@@ -326,18 +362,8 @@ export function createViewer(container, part) {
326
362
  frameTo(names.filter((n) => subMesh[n].visible && subCache[n]));
327
363
  }
328
364
 
329
- let autoRotateRequested = true;
330
- function syncAutoRotate() {
331
- controls.autoRotate = autoRotateRequested && !cutaway.isEnabled;
332
- }
333
- function setAutoRotate(on) {
334
- autoRotateRequested = !!on;
335
- syncAutoRotate();
336
- }
337
365
  function setCutawayEnabled(on) {
338
- const changed = cutaway.setEnabled(on);
339
- syncAutoRotate();
340
- return changed;
366
+ return cutaway.setEnabled(on);
341
367
  }
342
368
 
343
369
  // Swap the scene background, grid, and edge-line colors for the given theme.
@@ -361,6 +387,10 @@ export function createViewer(container, part) {
361
387
  // --- resize ---------------------------------------------------------------
362
388
  // Size from the host container (not the window) so embedders control the pane.
363
389
  function resize() {
390
+ // Parked (see setActive): the buffer is deliberately 1x1 and must stay that
391
+ // way. iOS fires resizes constantly as the URL bar collapses, and every one
392
+ // of them would otherwise re-allocate a full MSAA buffer for a hidden pane.
393
+ if (!active) return;
364
394
  const w = container.clientWidth || 300, h = container.clientHeight || 150;
365
395
  renderer.setSize(w, h);
366
396
  camera.aspect = w / h;
@@ -481,12 +511,78 @@ export function createViewer(container, part) {
481
511
  }
482
512
 
483
513
  // --- render loop ----------------------------------------------------------
484
- renderer.setAnimationLoop(() => {
514
+ // The tween is applied after controls.update() so the cue wins the frame, and
515
+ // the frame listeners run before render so a playback frame draws its own pose.
516
+ let lastFrameTime = null;
517
+ function renderFrame(time) {
518
+ const dt = lastFrameTime == null ? 0 : Math.min(0.1, (time - lastFrameTime) / 1000);
519
+ lastFrameTime = time;
485
520
  controls.update();
521
+ const tw = camTween.update(dt);
522
+ if (tw) {
523
+ camera.position.fromArray(tw.position);
524
+ controls.target.fromArray(tw.target);
525
+ }
526
+ for (const cb of [...frameListeners]) cb(dt);
486
527
  if (cutaway.isEnabled) cutaway.updateForCamera();
487
528
  renderer.render(scene, camera);
488
529
  cutaway.renderOverlay(renderer, camera);
489
- });
530
+ }
531
+ renderer.setAnimationLoop(renderFrame);
532
+
533
+ // --- active / parked ------------------------------------------------------
534
+ // For a host that HIDES the viewer without unmounting it. partforge's own
535
+ // narrow layout uses `display: none` on the stage, which zeroes clientWidth
536
+ // and lets the ResizeObserver above collapse the buffer for free. An embedder
537
+ // that cannot do that — partforge-cloud's phone tab bar uses
538
+ // `visibility: hidden`, because the canvas has to keep its size for build
539
+ // screenshots — gets no such signal: the full-resolution MSAA drawing buffer
540
+ // stays resident and this loop keeps rendering the scene at 60fps behind an
541
+ // invisible pane. On an iPhone that is tens of megabytes and
542
+ // continuous GPU work nobody can see, so the host has to say so explicitly.
543
+ //
544
+ // Parking stops the loop and releases the drawing buffer. `setSize(1, 1,
545
+ // false)` leaves the canvas element's CSS box alone, so the host's layout
546
+ // does not move and the pane can be revealed again without a reflow.
547
+ function setActive(next) {
548
+ const want = next !== false;
549
+ if (disposed || want === active) return;
550
+ active = want;
551
+ if (!active) {
552
+ renderer.setAnimationLoop(null);
553
+ renderer.setSize(1, 1, false);
554
+ // The cached 1024² 4x-MSAA + stencil capture target is the other large
555
+ // allocation here — on a phone it is comparable to the canvas itself, so
556
+ // parking that kept it would leave half the memory behind. Dropping it
557
+ // costs one re-allocation on the next capture, which a parked viewer
558
+ // barely notices: the cache only ever hits on an exactly-square request,
559
+ // and a phone's capture aspect is not square, so those captures were
560
+ // allocating per call regardless.
561
+ _rt?.dispose();
562
+ _rt = null;
563
+ return;
564
+ }
565
+ resize(); // rebuild the buffer at whatever size the container is now
566
+ lastFrameTime = null; // parked time is not elapsed time — no dt jump on unpark
567
+ renderer.setAnimationLoop(renderFrame);
568
+ }
569
+
570
+ // --- context loss ---------------------------------------------------------
571
+ // Losing the WebGL context is how a memory-starved phone tells you it gave
572
+ // up. With no handler the canvas just freezes, indistinguishable from a hang,
573
+ // and three never re-initialises. preventDefault() is what makes the loss
574
+ // recoverable (three's own listener re-uploads on restore); the subscribers
575
+ // let an embedder surface it instead of showing a dead rectangle.
576
+ const contextLostListeners = new Set();
577
+ const onContextLostEvent = (event) => {
578
+ event.preventDefault();
579
+ for (const listener of [...contextLostListeners]) listener();
580
+ };
581
+ renderer.domElement.addEventListener("webglcontextlost", onContextLostEvent);
582
+ function onContextLost(listener) {
583
+ contextLostListeners.add(listener);
584
+ return () => contextLostListeners.delete(listener);
585
+ }
490
586
 
491
587
  // --- camera state (read/write for persistence; mount.js owns storage) -------
492
588
  function getCameraState() {
@@ -528,6 +624,15 @@ export function createViewer(container, part) {
528
624
  disposed = true;
529
625
  ro.disconnect();
530
626
  renderer.setAnimationLoop(null);
627
+ // Embedder callbacks must not outlive teardown — a disposed viewer has no
628
+ // context left to lose, and a surviving listener would keep the embedder's
629
+ // closure (and whatever it captured) alive.
630
+ renderer.domElement.removeEventListener("webglcontextlost", onContextLostEvent);
631
+ contextLostListeners.clear();
632
+ controls.removeEventListener("start", onControlsStart);
633
+ cameraStartListeners.clear();
634
+ frameListeners.clear();
635
+ camTween.cancel();
531
636
  controls.dispose();
532
637
  for (const t of flashTimers) clearTimeout(t);
533
638
  flashTimers.clear();
@@ -557,7 +662,12 @@ export function createViewer(container, part) {
557
662
  frame,
558
663
  captureCanonicalViews,
559
664
  captureCurrent,
560
- setAutoRotate,
665
+ onFrame,
666
+ tweenCameraTo,
667
+ cancelCameraTween,
668
+ onCameraStart,
669
+ setActive,
670
+ onContextLost,
561
671
  setTheme,
562
672
  getCameraState,
563
673
  setCameraState,
@@ -0,0 +1,3 @@
1
+ import part from "./parts/hinged-box.js";
2
+ import { runWorker } from "./framework/worker.js";
3
+ runWorker(part);
package/src/index.js CHANGED
@@ -3,4 +3,4 @@
3
3
  // must NOT be imported from a part's build functions — those run in a Web Worker.
4
4
  // Part build functions import geometry helpers from "partforge/geometry" instead.
5
5
  export { mount } from "./framework/index.js";
6
- export { viewSubParts } from "./framework/jobs.js";
6
+ export { viewSubParts } from "./framework/part-model.js";
@@ -0,0 +1,94 @@
1
+ // Animation reference part — a box with a hinged lid. Worked example for
2
+ // docs/AUTHORING-PARTS.md "Animations": pose-only animated params (lidAngle,
3
+ // lidLift) driven through place(), an intro camera + markdown description on
4
+ // `open`, a looping `cycle`, and a stepped `assemble` with per-step cameras.
5
+ export default {
6
+ meta: { title: "Hinged Box", units: "mm" },
7
+ parameters: [
8
+ {
9
+ id: "box",
10
+ title: "Box",
11
+ description: "Outer dimensions of the base. The lid is a flat plate of the same wall thickness.",
12
+ advanced: [
13
+ { key: "width", label: "Width", unit: "mm", min: 20, max: 120, step: 1,
14
+ description: "Outer width (X)." },
15
+ { key: "depth", label: "Depth", unit: "mm", min: 20, max: 120, step: 1,
16
+ description: "Outer depth (Y). The hinge runs along the rear edge." },
17
+ { key: "height", label: "Height", unit: "mm", min: 10, max: 80, step: 1,
18
+ description: "Outer height of the base (Z)." },
19
+ { key: "wall", label: "Wall", unit: "mm", min: 1.2, max: 5, step: 0.2,
20
+ description: "Wall and lid thickness." },
21
+ ],
22
+ },
23
+ {
24
+ id: "pose",
25
+ title: "Pose",
26
+ description: "Presentation pose. The **Open lid** and **Assemble** animations drive these — both are pose-only, so animating them never rebuilds geometry.",
27
+ advanced: [
28
+ { key: "lidAngle", label: "Lid angle", unit: "°", min: 0, max: 110, step: 1,
29
+ description: "Hinge opening angle about the rear top edge." },
30
+ { key: "lidLift", label: "Lid lift", unit: "mm", min: 0, max: 60, step: 1,
31
+ description: "Assembly explode offset: raises the lid straight up off the hinge." },
32
+ ],
33
+ },
34
+ ],
35
+ defaults: { width: 60, depth: 40, height: 24, wall: 2, lidAngle: 0, lidLift: 0 },
36
+ parts: {
37
+ base: {
38
+ label: "Base",
39
+ views: ["box"],
40
+ export: { name: "base" },
41
+ build: (k, p) =>
42
+ k.box({ min: [0, 0, 0], max: [p.width, p.depth, p.height] })
43
+ .cut(k.box({ min: [p.wall, p.wall, p.wall], max: [p.width - p.wall, p.depth - p.wall, p.height + 1] })),
44
+ },
45
+ lid: {
46
+ label: "Lid",
47
+ views: ["box"],
48
+ export: { name: "lid" },
49
+ build: (k, p) => k.box({ min: [0, 0, p.height], max: [p.width, p.depth, p.height + p.wall] }),
50
+ // Display: swing about the hinge line (rear top edge, axis +X through
51
+ // [0, depth, height]; negative angle opens upward), then the assembly
52
+ // lift. Export: the lid prints flat beside the base. Both poses are
53
+ // rigid motions of the same solid, and neither reads `view` — the two
54
+ // invariants lint's place rules hold every part to.
55
+ place: (s, { purpose, p }) =>
56
+ purpose === "export"
57
+ ? s.translate([p.width + 10, 0, -p.height])
58
+ : s.rotate(-p.lidAngle, [0, p.depth, p.height], [1, 0, 0]).translate([0, 0, p.lidLift]),
59
+ },
60
+ },
61
+ views: { box: { label: "Box" } },
62
+ animations: {
63
+ open: {
64
+ label: "Open lid",
65
+ description: "Swings the lid to **110°** about the rear hinge line.\n\nPose-only: playback runs at frame rate with no geometry rebuild.",
66
+ camera: "front",
67
+ duration: 1.2,
68
+ tracks: { lidAngle: [[0, 0], [1, 110]] },
69
+ },
70
+ cycle: {
71
+ label: "Open / close",
72
+ duration: 2.4,
73
+ loop: true,
74
+ easing: "linear",
75
+ autoplay: true,
76
+ tracks: { lidAngle: [[0, 0], [0.5, 110], [1, 0]] },
77
+ },
78
+ assemble: {
79
+ label: "Assemble",
80
+ description: "How the parts come together: the lid drops onto the base, then swings open to check hinge clearance.",
81
+ steps: [
82
+ { label: "Lower the lid", camera: "left", duration: 1.0, tracks: { lidLift: [[0, 40], [1, 0]] } },
83
+ { label: "Open to check clearance", camera: "iso", duration: 1.0, tracks: { lidAngle: [[0, 0], [1, 110]] } },
84
+ ],
85
+ },
86
+ },
87
+ verify: {
88
+ process: "fdm-pla",
89
+ expect: {
90
+ base: { bbox: "<=[200,200,200]" },
91
+ _view: { overlaps: 0 },
92
+ },
93
+ },
94
+ };
@@ -1,7 +1,8 @@
1
1
  import { writeFileSync, mkdirSync } from "node:fs";
2
- import { join } from "node:path";
3
- import { buildView } from "./build.js";
4
- import { bounds } from "./mesh.js";
2
+ import { join, resolve, sep } from "node:path";
3
+ import { safeName } from "../framework/safe-name.js";
4
+ import { buildView } from "../framework/oracle/build.js";
5
+ import { bounds } from "../framework/oracle/mesh.js";
5
6
 
6
7
  // Canonical view directions in MODEL space (Z-up). `dir` is the direction from
7
8
  // the part centre toward the camera; `up` is the camera up vector.
@@ -22,7 +23,6 @@ export const RENDER_ANGLES = {
22
23
  };
23
24
  export const RENDER_VIEWS = Object.keys(RENDER_ANGLES);
24
25
 
25
- const slug = (s) => String(s).toLowerCase().replace(/\s+/g, "-");
26
26
  const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
27
27
  const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
28
28
  const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
@@ -33,7 +33,7 @@ const norm = (a) => { const l = Math.hypot(a[0], a[1], a[2]) || 1; return [a[0]
33
33
  // overlays). No native module, no browser. Returns the written file paths.
34
34
  // pngjs is lazy-imported so importing the testing barrel for measure never loads it.
35
35
  export async function renderViews(kernel, part, view = Object.keys(part.views)[0], {
36
- views = ["iso", "front", "top"], out = "render", size = [800, 600], edges = true, params = {},
36
+ views = ["iso", "front", "top"], out = "render", size = [800, 600], edges = true, params = {}, tag = "",
37
37
  } = {}) {
38
38
  const { PNG } = await import("pngjs");
39
39
  const [W, H] = size;
@@ -54,8 +54,13 @@ export async function renderViews(kernel, part, view = Object.keys(part.views)[0
54
54
  const ambient = 0.35, diffuse = 0.75;
55
55
  const bias = radius * 0.02; // edge depth bias so visible edges win ties
56
56
 
57
- mkdirSync(out, { recursive: true });
58
- const name = slug(part.meta?.title ?? view);
57
+ // `out` is operator-supplied (a CLI flag) and stays verbatim; the part-derived
58
+ // title and view key are sanitized, since this is the one place a part's
59
+ // strings reach the filesystem.
60
+ const outDir = resolve(out);
61
+ mkdirSync(outDir, { recursive: true });
62
+ const name = safeName(part.meta?.title ?? view);
63
+ const viewName = safeName(view);
59
64
  const written = [];
60
65
 
61
66
  for (const angle of views) {
@@ -121,7 +126,13 @@ export async function renderViews(kernel, part, view = Object.keys(part.views)[0
121
126
  for (let i = 0; i < W * H; i++) {
122
127
  png.data[i * 4] = color[i * 3]; png.data[i * 4 + 1] = color[i * 3 + 1]; png.data[i * 4 + 2] = color[i * 3 + 2]; png.data[i * 4 + 3] = 255;
123
128
  }
124
- const file = join(out, `${name}-${view}-${angle}.png`);
129
+ // The animation frame tag goes through safeName() as well — one rule for
130
+ // every string that reaches a filename here.
131
+ const file = join(out, `${name}-${viewName}-${angle}${tag ? `-${safeName(tag)}` : ""}.png`);
132
+ // Belt and braces over safeName(): assert the escape never happened rather
133
+ // than trusting the slug, because a miss here writes bytes to disk. (The
134
+ // returned paths stay relative to `out` — the CLI echoes them.)
135
+ if (!resolve(file).startsWith(outDir + sep)) throw new Error(`renderViews: refusing to write outside ${out}`);
125
136
  writeFileSync(file, PNG.sync.write(png));
126
137
  written.push(file);
127
138
  }
package/src/testing.js CHANGED
@@ -1,18 +1,25 @@
1
1
  // partforge/testing — utilities for testing parts headlessly (Manifold kernel, the
2
2
  // job loop, the assembly collision check, an OCCT kernel, and mesh measures).
3
3
  // See docs/AUTHORING-PARTS.md "Testing a part".
4
+ //
5
+ // Most of what this re-exports is NOT test-only code: the oracle (measure/verify/
6
+ // buildView/gaps/BVH/min-wall) also runs inside the browser geometry worker, so it
7
+ // lives in src/framework/oracle/. Only the Node-bound harness — the two kernel
8
+ // booters, the PNG renderer — lives in src/testing/. This barrel is the published
9
+ // entry point and hides that split from downstream consumers.
4
10
  export { createManifoldKernel } from "./framework/geometry/manifold-backend.js";
5
11
  export { bootManifoldKernel } from "./testing/manifold.js";
6
- export { handle, viewSubParts } from "./framework/jobs.js";
12
+ export { handle } from "./framework/jobs.js";
13
+ export { viewSubParts } from "./framework/part-model.js";
7
14
  export { resolveDerived } from "./framework/derive.js";
8
15
  export { relevantParamKeys, RELEVANT_ALL } from "./framework/param-deps.js";
9
16
  export { assemblyOverlaps } from "./framework/assembly.js";
10
- export { assemblyGaps, meshGaps } from "./testing/gaps.js";
17
+ export { assemblyGaps, meshGaps } from "./framework/oracle/gaps.js";
11
18
  export { bootOcctKernel } from "./testing/occt.js";
12
- export { meshVolume, bboxSize } from "./testing/mesh.js";
13
- export { buildView } from "./testing/build.js";
14
- export { measure } from "./testing/measure.js";
19
+ export { meshVolume, bboxSize } from "./framework/oracle/mesh.js";
20
+ export { buildView } from "./framework/oracle/build.js";
21
+ export { measure } from "./framework/oracle/measure.js";
15
22
  export { renderViews, RENDER_VIEWS } from "./testing/render.js";
16
- export { verify } from "./testing/verify.js";
17
- export { buildBVH } from "./testing/bvh.js";
18
- export { minWall } from "./testing/min-wall.js";
23
+ export { verify } from "./framework/oracle/verify.js";
24
+ export { buildBVH } from "./framework/oracle/bvh.js";
25
+ export { minWall } from "./framework/oracle/min-wall.js";
@@ -0,0 +1,14 @@
1
+ // partforge/derive — a lean, DOM-free entry so a part module (or a helper, or a
2
+ // test) can merge a grouped `derive` exactly the way the framework does.
3
+
4
+ import type { Derived, PartDefinition, ResolvedParams } from "./part.js";
5
+
6
+ /**
7
+ * Resolve a part's `derive` into the derived-values object `d` builds receive.
8
+ *
9
+ * Both authoring forms are handled: one function computed in a single pass, or
10
+ * named groups run in declaration order (each seeing the merged outputs of the
11
+ * groups before it). A group that reads a key no earlier group produced throws.
12
+ * Returns `{}` when the part declares no `derive`.
13
+ */
14
+ export function resolveDerived(part: Pick<PartDefinition, "derive">, p: ResolvedParams): Derived;
@@ -0,0 +1,117 @@
1
+ // partforge/geometry — pure 2-D profile helpers and solid patterns.
2
+ //
3
+ // DOM-free and kernel-free: this is the entry a part's build functions import
4
+ // (importing "partforge" inside a worker throws `document is not defined`).
5
+
6
+ import type { ArcContour, Point2, Point3, PointsContour, Region2D, Solid } from "./kernel.js";
7
+
8
+ export type { ArcContour, Point2, Point3, PointsContour, Region2D, Solid };
9
+
10
+ /** A pie/sector wedge with its tip at the origin. */
11
+ export function piePolygon(tipR: number, arcDeg: number, segs?: number): PointsContour;
12
+
13
+ /** A regular hexagon of circumradius `r`. */
14
+ export function hexPolygon(r: number): PointsContour;
15
+
16
+ /** A `w` × `h` rectangle centred at the origin with corner radius `r`. */
17
+ export function roundedRectPolygon(w: number, h: number, r: number, segs?: number): PointsContour;
18
+
19
+ /** A regular `n`-gon of circumradius `r`; `flat: true` seats a flat side down. */
20
+ export function regularPolygon(n: number, r: number, opts?: { flat?: boolean }): PointsContour;
21
+
22
+ export function ellipsePolygon(rx: number, ry: number, segs?: number): PointsContour;
23
+
24
+ /** A stadium/slot; overall length is `length + 2r`. */
25
+ export function slotPolygon(length: number, r: number, segs?: number): PointsContour;
26
+
27
+ export function starPolygon(points: number, outerR: number, innerR: number): PointsContour;
28
+
29
+ /** An annular sector. `arcDeg` must be < 360 — a full ring is a contour-with-hole. */
30
+ export function ringSectorPolygon(innerR: number, outerR: number, arcDeg: number, segs?: number): PointsContour;
31
+
32
+ /**
33
+ * A CCW circle of radius `r` centred at `center`, as a FACETED point list
34
+ * (`segs` segments). For curve-exact corners use `roundedProfile`/`pathProfile`.
35
+ */
36
+ export function circleProfile(r: number, center?: Point2, segs?: number): PointsContour;
37
+
38
+ /**
39
+ * Per-corner rounding geometry shared by `filletPolygon` and `roundedProfile`:
40
+ * the incoming/outgoing tangent points, the arc centre, the clamped radius, the
41
+ * short sweep and its start angle — or `null` for a corner that stays sharp.
42
+ */
43
+ export function cornerArc(
44
+ p0: Point2,
45
+ p1: Point2,
46
+ p2: Point2,
47
+ r: number,
48
+ ): { a: number[]; b: number[]; c: number[]; rr: number; dA: number; a0: number } | null;
49
+
50
+ /**
51
+ * Round every corner of a CCW polygon, BAKING each arc into line facets — so
52
+ * STEP corners are faceted. Use `roundedProfile` for true circular edges.
53
+ */
54
+ export function filletPolygon(points: PointsContour, r: number, opts?: { segs?: number }): PointsContour;
55
+
56
+ /**
57
+ * Round corners the same way as `filletPolygon` but keep them mathematically
58
+ * TRUE — the arc is carried symbolically, so STEP export gets real circular
59
+ * edges. A scalar `r` rounds every corner; a per-corner `r[]` (length = points)
60
+ * rounds selectively. Accepted by `prism`/`extrude`, not yet by `loft`.
61
+ */
62
+ export function roundedProfile(points: PointsContour, r: number | number[]): ArcContour;
63
+
64
+ /** The fluent builder `pathProfile` returns. `close()` snapshots the contour. */
65
+ export interface PathProfileBuilder {
66
+ lineTo(to: Point2): PathProfileBuilder;
67
+ /** A circular arc to `to` passing through `via`. */
68
+ arcTo(to: Point2, via: Point2): PathProfileBuilder;
69
+ /** A cubic Bézier to `to` with control points `c1`/`c2`. */
70
+ cubicTo(to: Point2, c1: Point2, c2: Point2): PathProfileBuilder;
71
+ /** Close the contour and return it. Needs at least one segment. */
72
+ close(): ArcContour;
73
+ }
74
+
75
+ /**
76
+ * A fluent builder for a curve-native path contour. Cubic segments become exact
77
+ * B-rep spline edges on OCCT and facet at mesh LOD on Manifold.
78
+ */
79
+ export function pathProfile(start: Point2): PathProfileBuilder;
80
+
81
+ /** Convex-corner style for `offsetPolygon`. */
82
+ export type OffsetCorners = "round" | "chamfer" | "sharp";
83
+
84
+ /**
85
+ * Offset a point-list polygon or an `{ outer, holes }` region by `delta` mm —
86
+ * positive grows material, negative insets (regions offset material-wise).
87
+ * Simple polygon in, simple polygon out: an offset that would collapse or split
88
+ * the contour THROWS. Pure, so it works in `derive()` as well as `build()`.
89
+ */
90
+ export function offsetPolygon(
91
+ profile: PointsContour,
92
+ delta: number,
93
+ opts?: { corners?: OffsetCorners; segs?: number },
94
+ ): PointsContour;
95
+ export function offsetPolygon(
96
+ profile: Region2D,
97
+ delta: number,
98
+ opts?: { corners?: OffsetCorners; segs?: number },
99
+ ): Region2D;
100
+
101
+ /** `count` copies of `solid` translated by `i * step`. Feed to `k.union` / `s.cutAll`. */
102
+ export function linearPattern(solid: Solid, count: number, step: Point3): Solid[];
103
+
104
+ /**
105
+ * `count` copies spaced `angle / count` degrees apart around `axis` through
106
+ * `center`. `rotateCopies: false` keeps each copy's original orientation.
107
+ */
108
+ export function circularPattern(
109
+ solid: Solid,
110
+ count: number,
111
+ opts?: {
112
+ center?: Point3;
113
+ axis?: "X" | "Y" | "Z" | Point3;
114
+ angle?: number;
115
+ rotateCopies?: boolean;
116
+ },
117
+ ): Solid[];