partforge 0.41.0 → 0.45.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 (74) hide show
  1. package/README.md +31 -10
  2. package/bin/cli.js +138 -27
  3. package/docs/AUTHORING-PARTS.md +164 -17
  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 +254 -0
  10. package/src/framework/animation.js +271 -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/capture-build.js +59 -0
  16. package/src/framework/chrome.css +16 -0
  17. package/src/framework/controls.js +13 -3
  18. package/src/framework/cutaway-gizmo-scene.js +244 -0
  19. package/src/framework/cutaway-gizmo.js +80 -243
  20. package/src/framework/default-view.js +46 -0
  21. package/src/framework/download.js +7 -2
  22. package/src/framework/export-controller.js +13 -2
  23. package/src/framework/geometry/probe.js +3 -22
  24. package/src/framework/jobs.js +30 -40
  25. package/src/framework/lint/finding.js +4 -0
  26. package/src/framework/lint/index.js +7 -3
  27. package/src/framework/lint/rules-animations.js +441 -0
  28. package/src/framework/lint/rules-place.js +76 -0
  29. package/src/framework/lint/rules-schema.js +22 -0
  30. package/src/framework/lint/rules-shape.js +12 -0
  31. package/src/framework/lint/rules-verify.js +2 -2
  32. package/src/framework/mount.js +147 -20
  33. package/src/{testing → framework/oracle}/build.js +1 -1
  34. package/src/{testing → framework/oracle}/bvh.js +1 -1
  35. package/src/{testing → framework/oracle}/measure.js +1 -1
  36. package/src/{testing → framework/oracle}/min-wall.js +1 -1
  37. package/src/{testing → framework/oracle}/verify.js +3 -3
  38. package/src/framework/param-deps.js +1 -1
  39. package/src/framework/part-model.js +48 -0
  40. package/src/framework/pick-request/client.js +11 -3
  41. package/src/framework/pick-request/endpoint.js +60 -0
  42. package/src/framework/pick-request/index.js +6 -0
  43. package/src/framework/pick-request/server.js +222 -34
  44. package/src/framework/pick-request/token-store.js +31 -0
  45. package/src/framework/pose-fast-path.js +12 -1
  46. package/src/framework/pose-probe-core.js +129 -0
  47. package/src/framework/pose-probe.js +7 -123
  48. package/src/framework/regen-loop.js +10 -3
  49. package/src/framework/safe-name.js +26 -0
  50. package/src/framework/verify-metrics.js +4 -4
  51. package/src/framework/view-state.js +25 -21
  52. package/src/framework/view-tabs.js +35 -7
  53. package/src/framework/viewer-controls.js +5 -26
  54. package/src/framework/viewer-lighting.js +8 -1
  55. package/src/framework/viewer.js +139 -20
  56. package/src/framework/worker.js +5 -1
  57. package/src/hinged-box-worker.js +3 -0
  58. package/src/index.js +1 -1
  59. package/src/parts/hinged-box.js +94 -0
  60. package/src/testing/render.js +19 -8
  61. package/src/testing.js +15 -8
  62. package/types/derive.d.ts +14 -0
  63. package/types/geometry.d.ts +117 -0
  64. package/types/index.d.ts +259 -0
  65. package/types/kernel.d.ts +409 -0
  66. package/types/lint.d.ts +85 -0
  67. package/types/part.d.ts +409 -0
  68. package/types/testing.d.ts +362 -0
  69. package/types/worker.d.ts +21 -0
  70. /package/src/{testing → framework/oracle}/assert-dsl.js +0 -0
  71. /package/src/{testing → framework/oracle}/cases.js +0 -0
  72. /package/src/{testing → framework/oracle}/dfm-profiles.js +0 -0
  73. /package/src/{testing → framework/oracle}/gaps.js +0 -0
  74. /package/src/{testing → framework/oracle}/mesh.js +0 -0
@@ -5,7 +5,8 @@ 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 { addViewerLights, captureLightPoses, createCaptureLights } from "./viewer-lighting.js";
8
+ import { createCameraTween } from "./camera-tween.js";
9
+ import { addViewerLights, captureLightPoses, createCaptureLights, createHemisphereLight } from "./viewer-lighting.js";
9
10
  import { CANONICAL_VIEWS, cameraPoseForView } from "./view-angles.js";
10
11
 
11
12
  // three renders into a render target in the LINEAR working colour space: as of r184
@@ -113,8 +114,6 @@ export function createViewer(container, part) {
113
114
 
114
115
  const controls = new OrbitControls(camera, renderer.domElement);
115
116
  controls.enableDamping = true;
116
- controls.autoRotate = true;
117
- controls.autoRotateSpeed = 1.6;
118
117
 
119
118
  // --- lights + grid --------------------------------------------------------
120
119
  const liveLights = addViewerLights(scene);
@@ -214,6 +213,39 @@ export function createViewer(container, part) {
214
213
  cutaway.setSubpart(name, subMesh[name], subLines[name]);
215
214
  }
216
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
+
217
249
  // Smooth shading within CREASE_ANGLE of a shared edge, hard edge past it — so the
218
250
  // round body and helical groove read smooth while bore rims, drum faces, and
219
251
  // groove walls stay crisp. Lower = more hard edges; raise toward Math.PI/3 for
@@ -330,18 +362,8 @@ export function createViewer(container, part) {
330
362
  frameTo(names.filter((n) => subMesh[n].visible && subCache[n]));
331
363
  }
332
364
 
333
- let autoRotateRequested = true;
334
- function syncAutoRotate() {
335
- controls.autoRotate = autoRotateRequested && !cutaway.isEnabled;
336
- }
337
- function setAutoRotate(on) {
338
- autoRotateRequested = !!on;
339
- syncAutoRotate();
340
- }
341
365
  function setCutawayEnabled(on) {
342
- const changed = cutaway.setEnabled(on);
343
- syncAutoRotate();
344
- return changed;
366
+ return cutaway.setEnabled(on);
345
367
  }
346
368
 
347
369
  // Swap the scene background, grid, and edge-line colors for the given theme.
@@ -405,7 +427,8 @@ export function createViewer(container, part) {
405
427
  // no error, live view unaffected, wrong only in the capture.
406
428
  const RT_OPTIONS = { samples: 4, stencilBuffer: true };
407
429
  function renderOffscreen({ position, up, target },
408
- { width = _rtSize, height = _rtSize, fov = 45, quality = 0.9 } = {}) {
430
+ { width = _rtSize, height = _rtSize, fov = 45, quality = 0.9 } = {},
431
+ renderScene = scene) {
409
432
  const cachedSize = width === _rtSize && height === _rtSize;
410
433
  const rt = cachedSize
411
434
  ? (_rt = _rt ?? new THREE.WebGLRenderTarget(_rtSize, _rtSize, RT_OPTIONS))
@@ -429,7 +452,7 @@ export function createViewer(container, part) {
429
452
  scene.add(capKey, capKey.target, capFill, capFill.target);
430
453
  try {
431
454
  renderer.setRenderTarget(rt);
432
- renderer.render(scene, cam);
455
+ renderer.render(renderScene, cam);
433
456
  // render() resolves the multisample renderbuffer into the target texture, so this
434
457
  // reads antialiased pixels.
435
458
  renderer.readRenderTargetPixels(rt, 0, 0, width, height, buf);
@@ -488,9 +511,96 @@ export function createViewer(container, part) {
488
511
  });
489
512
  }
490
513
 
514
+ // Offscreen render of an arbitrary mesh set (a non-active view), for thumbnails.
515
+ // Assembles a THROWAWAY scene mirroring the live pivot convention, frames it from a
516
+ // canonical angle, renders through the parameterized renderOffscreen, and disposes
517
+ // everything. Never touches the live scene, camera, subMesh, or subCache. `payloads`
518
+ // is the worker's [{name, positions, normals, indices, …}] array — placement is
519
+ // already baked into shared-frame coords, so meshes are NOT recentred.
520
+ function renderMeshPayloads(payloads, { angle = "iso", size = 640, quality = 0.8 } = {}) {
521
+ if (disposed) return null; // same guard as captureCurrent/captureCanonicalViews — never touch a torn-down renderer
522
+ const tmpScene = new THREE.Scene();
523
+ const tmpPivot = new THREE.Group();
524
+ tmpPivot.rotation.x = -Math.PI / 2; // model Z (CAD up) -> vertical, same as live pivot
525
+ tmpScene.add(tmpPivot);
526
+
527
+ const built = [];
528
+ for (const payload of payloads) {
529
+ const geo = buildGeometry(payload); // shared-frame coords, NOT recentred
530
+ const mesh = new THREE.Mesh(geo, materialFor(payload.name));
531
+ tmpPivot.add(mesh);
532
+ built.push(mesh);
533
+ }
534
+
535
+ // Frame in WORLD space, AFTER the pivot rotation. The meshes are built in model
536
+ // coords but rendered rotated by tmpPivot, so a model-space bbox centre would aim
537
+ // the camera at the wrong point — an off-origin part would render off-centre or blank.
538
+ tmpPivot.updateMatrixWorld(true);
539
+ const box = new THREE.Box3().setFromObject(tmpPivot);
540
+ const center = box.getCenter(new THREE.Vector3()).toArray();
541
+ const radius = box.getSize(new THREE.Vector3()).length() / 2 || 1;
542
+ const pose = cameraPoseForView(angle, { center, radius });
543
+
544
+ // Light the throwaway scene ourselves: renderOffscreen's own key/fill (and the
545
+ // persistent hemisphere) live in the LIVE scene, which is never rendered here — so
546
+ // without our own ambient + camera-relative key/fill it comes back near-black.
547
+ const hemi = createHemisphereLight();
548
+ const capLights = createCaptureLights();
549
+ const poses = captureLightPoses(pose);
550
+ capLights.key.position.set(poses.key[0], poses.key[1], poses.key[2]);
551
+ capLights.fill.position.set(poses.fill[0], poses.fill[1], poses.fill[2]);
552
+ for (const light of [capLights.key, capLights.fill]) {
553
+ light.target.position.set(pose.target[0], pose.target[1], pose.target[2]);
554
+ }
555
+ tmpScene.add(hemi, capLights.key, capLights.key.target, capLights.fill, capLights.fill.target);
556
+
557
+ // Feature-edge lines, so the thumbnail carries the same hole/seam/chamfer outlines the
558
+ // live viewer shows. A dedicated LineMaterial at the render resolution (the live one is
559
+ // sized to the on-screen canvas); added after framing so it can't perturb the bbox.
560
+ const lineMat = new LineMaterial({ color: THEME.dark.line, linewidth: 1.0 });
561
+ lineMat.resolution.set(size, size);
562
+ for (const mesh of built) {
563
+ const edges = mesh.geometry.userData.edges;
564
+ if (edges) tmpPivot.add(new LineSegments2(edges, lineMat));
565
+ }
566
+
567
+ try {
568
+ // fov matches the live camera (and captureViews/captureCurrent) — cameraPoseForView's
569
+ // distance is tuned to it, so a narrower fov would crop long, thin parts.
570
+ return renderOffscreen(pose, { width: size, height: size, fov: camera.fov, quality }, tmpScene);
571
+ } finally {
572
+ for (const mesh of built) {
573
+ mesh.geometry.userData.edges?.dispose();
574
+ mesh.geometry.dispose();
575
+ if (mesh.material !== material) mesh.material.dispose(); // clone only — never the shared singleton
576
+ }
577
+ lineMat.dispose();
578
+ hemi.dispose?.();
579
+ capLights.key.dispose?.();
580
+ capLights.fill.dispose?.();
581
+ }
582
+ }
583
+
491
584
  // --- render loop ----------------------------------------------------------
492
- function renderFrame() {
585
+ // The tween is applied after controls.update() so the cue wins the frame, and
586
+ // the frame listeners run before render so a playback frame draws its own pose.
587
+ let lastFrameTime = null;
588
+ function renderFrame(time) {
589
+ const dt = lastFrameTime == null ? 0 : Math.min(0.1, (time - lastFrameTime) / 1000);
590
+ lastFrameTime = time;
493
591
  controls.update();
592
+ const tw = camTween.update(dt);
593
+ if (tw) {
594
+ camera.position.fromArray(tw.position);
595
+ controls.target.fromArray(tw.target);
596
+ }
597
+ // Per-listener guard, because three re-arms requestAnimationFrame only AFTER
598
+ // this callback returns (WebGLAnimation.onAnimationFrame): a listener that
599
+ // throws would stop the rAF chain outright and freeze the viewer for good, not
600
+ // just skip a frame. Containment belongs here rather than in every subscriber.
601
+ for (const cb of [...frameListeners]) {
602
+ try { cb(dt); } catch (e) { console.warn("partforge: frame listener failed", e); }
603
+ }
494
604
  if (cutaway.isEnabled) cutaway.updateForCamera();
495
605
  renderer.render(scene, camera);
496
606
  cutaway.renderOverlay(renderer, camera);
@@ -504,8 +614,8 @@ export function createViewer(container, part) {
504
614
  // that cannot do that — partforge-cloud's phone tab bar uses
505
615
  // `visibility: hidden`, because the canvas has to keep its size for build
506
616
  // screenshots — gets no such signal: the full-resolution MSAA drawing buffer
507
- // stays resident and this loop keeps rendering an auto-rotating scene at
508
- // 60fps behind an invisible pane. On an iPhone that is tens of megabytes and
617
+ // stays resident and this loop keeps rendering the scene at 60fps behind an
618
+ // invisible pane. On an iPhone that is tens of megabytes and
509
619
  // continuous GPU work nobody can see, so the host has to say so explicitly.
510
620
  //
511
621
  // Parking stops the loop and releases the drawing buffer. `setSize(1, 1,
@@ -530,6 +640,7 @@ export function createViewer(container, part) {
530
640
  return;
531
641
  }
532
642
  resize(); // rebuild the buffer at whatever size the container is now
643
+ lastFrameTime = null; // parked time is not elapsed time — no dt jump on unpark
533
644
  renderer.setAnimationLoop(renderFrame);
534
645
  }
535
646
 
@@ -595,6 +706,10 @@ export function createViewer(container, part) {
595
706
  // closure (and whatever it captured) alive.
596
707
  renderer.domElement.removeEventListener("webglcontextlost", onContextLostEvent);
597
708
  contextLostListeners.clear();
709
+ controls.removeEventListener("start", onControlsStart);
710
+ cameraStartListeners.clear();
711
+ frameListeners.clear();
712
+ camTween.cancel();
598
713
  controls.dispose();
599
714
  for (const t of flashTimers) clearTimeout(t);
600
715
  flashTimers.clear();
@@ -624,7 +739,11 @@ export function createViewer(container, part) {
624
739
  frame,
625
740
  captureCanonicalViews,
626
741
  captureCurrent,
627
- setAutoRotate,
742
+ renderMeshPayloads,
743
+ onFrame,
744
+ tweenCameraTo,
745
+ cancelCameraTween,
746
+ onCameraStart,
628
747
  setActive,
629
748
  onContextLost,
630
749
  setTheme,
@@ -110,7 +110,11 @@ export function runWorker(part) {
110
110
  await handle(kernel, job.part, job.data, gated, { isStale });
111
111
  } catch (err) {
112
112
  // Same shape jobs.js posts for a failed build, so hosts need no new branch.
113
- postMessage({ type: "error", message: String(err?.message || err) });
113
+ // Carry the job's jobId when it has one (capture/export are correlated by it):
114
+ // a boot failure hitting kernelFor here must reach the right controller, or a
115
+ // correlated caller (captureView, exportParts) would hang instead of settling.
116
+ const jobId = job.data?.jobId;
117
+ postMessage({ type: "error", message: String(err?.message || err), ...(jobId != null ? { jobId } : {}) });
114
118
  }
115
119
  }
116
120
  } finally {
@@ -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[];