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.
- package/README.md +46 -6
- package/bin/cli.js +103 -27
- package/docs/AUTHORING-PARTS.md +143 -15
- package/docs/ERROR-PATTERNS.md +6 -0
- package/package.json +48 -7
- package/skills/partforge/SKILL.md +17 -3
- package/src/app-embed-test.js +1 -1
- package/src/app-hinged-box.js +12 -0
- package/src/framework/animation-controls.js +243 -0
- package/src/framework/animation.js +217 -0
- package/src/framework/app.css +32 -0
- package/src/framework/assembly.js +1 -1
- package/src/framework/backend-select.js +25 -0
- package/src/framework/camera-tween.js +58 -0
- package/src/framework/chrome.css +16 -0
- package/src/framework/controls.js +13 -3
- package/src/framework/cutaway-gizmo-scene.js +244 -0
- package/src/framework/cutaway-gizmo.js +80 -243
- package/src/framework/default-view.js +46 -0
- package/src/framework/download.js +7 -2
- package/src/framework/export-controller.js +13 -2
- package/src/framework/geometry/probe.js +3 -22
- package/src/framework/jobs.js +23 -42
- package/src/framework/lint/finding.js +4 -0
- package/src/framework/lint/index.js +7 -3
- package/src/framework/lint/rules-animations.js +404 -0
- package/src/framework/lint/rules-place.js +76 -0
- package/src/framework/lint/rules-shape.js +12 -0
- package/src/framework/lint/rules-verify.js +2 -2
- package/src/framework/mount.js +113 -18
- package/src/{testing → framework/oracle}/build.js +1 -1
- package/src/framework/oracle/bvh.js +463 -0
- package/src/{testing → framework/oracle}/gaps.js +6 -3
- package/src/{testing → framework/oracle}/measure.js +34 -4
- package/src/framework/oracle/min-wall.js +98 -0
- package/src/{testing → framework/oracle}/verify.js +61 -5
- package/src/framework/param-deps.js +1 -1
- package/src/framework/part-model.js +48 -0
- package/src/framework/pick-request/client.js +11 -3
- package/src/framework/pick-request/endpoint.js +60 -0
- package/src/framework/pick-request/index.js +6 -0
- package/src/framework/pick-request/server.js +222 -34
- package/src/framework/pick-request/token-store.js +31 -0
- package/src/framework/pose-fast-path.js +12 -1
- package/src/framework/pose-probe-core.js +129 -0
- package/src/framework/pose-probe.js +7 -123
- package/src/framework/regen-loop.js +10 -3
- package/src/framework/safe-name.js +26 -0
- package/src/framework/verify-metrics.js +19 -6
- package/src/framework/view-state.js +25 -21
- package/src/framework/view-tabs.js +22 -7
- package/src/framework/viewer-controls.js +5 -26
- package/src/framework/viewer.js +126 -16
- package/src/hinged-box-worker.js +3 -0
- package/src/index.js +1 -1
- package/src/parts/hinged-box.js +94 -0
- package/src/testing/render.js +19 -8
- package/src/testing.js +15 -8
- package/types/derive.d.ts +14 -0
- package/types/geometry.d.ts +117 -0
- package/types/index.d.ts +240 -0
- package/types/kernel.d.ts +409 -0
- package/types/lint.d.ts +85 -0
- package/types/part.d.ts +381 -0
- package/types/testing.d.ts +362 -0
- package/types/worker.d.ts +21 -0
- package/src/testing/bvh.js +0 -273
- package/src/testing/min-wall.js +0 -38
- /package/src/{testing → framework/oracle}/assert-dsl.js +0 -0
- /package/src/{testing → framework/oracle}/cases.js +0 -0
- /package/src/{testing → framework/oracle}/dfm-profiles.js +0 -0
- /package/src/{testing → framework/oracle}/mesh.js +0 -0
package/src/framework/viewer.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
665
|
+
onFrame,
|
|
666
|
+
tweenCameraTo,
|
|
667
|
+
cancelCameraTween,
|
|
668
|
+
onCameraStart,
|
|
669
|
+
setActive,
|
|
670
|
+
onContextLost,
|
|
561
671
|
setTheme,
|
|
562
672
|
getCameraState,
|
|
563
673
|
setCameraState,
|
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/
|
|
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
|
+
};
|
package/src/testing/render.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { writeFileSync, mkdirSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
|
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 "./
|
|
17
|
+
export { assemblyGaps, meshGaps } from "./framework/oracle/gaps.js";
|
|
11
18
|
export { bootOcctKernel } from "./testing/occt.js";
|
|
12
|
-
export { meshVolume, bboxSize } from "./
|
|
13
|
-
export { buildView } from "./
|
|
14
|
-
export { measure } from "./
|
|
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 "./
|
|
17
|
-
export { buildBVH } from "./
|
|
18
|
-
export { minWall } from "./
|
|
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[];
|