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.
- package/README.md +31 -10
- package/bin/cli.js +138 -27
- package/docs/AUTHORING-PARTS.md +164 -17
- 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 +254 -0
- package/src/framework/animation.js +271 -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/capture-build.js +59 -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 +30 -40
- package/src/framework/lint/finding.js +4 -0
- package/src/framework/lint/index.js +7 -3
- package/src/framework/lint/rules-animations.js +441 -0
- package/src/framework/lint/rules-place.js +76 -0
- package/src/framework/lint/rules-schema.js +22 -0
- package/src/framework/lint/rules-shape.js +12 -0
- package/src/framework/lint/rules-verify.js +2 -2
- package/src/framework/mount.js +147 -20
- package/src/{testing → framework/oracle}/build.js +1 -1
- package/src/{testing → framework/oracle}/bvh.js +1 -1
- package/src/{testing → framework/oracle}/measure.js +1 -1
- package/src/{testing → framework/oracle}/min-wall.js +1 -1
- package/src/{testing → framework/oracle}/verify.js +3 -3
- 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 +4 -4
- package/src/framework/view-state.js +25 -21
- package/src/framework/view-tabs.js +35 -7
- package/src/framework/viewer-controls.js +5 -26
- package/src/framework/viewer-lighting.js +8 -1
- package/src/framework/viewer.js +139 -20
- package/src/framework/worker.js +5 -1
- 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 +259 -0
- package/types/kernel.d.ts +409 -0
- package/types/lint.d.ts +85 -0
- package/types/part.d.ts +409 -0
- package/types/testing.d.ts +362 -0
- package/types/worker.d.ts +21 -0
- /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}/gaps.js +0 -0
- /package/src/{testing → framework/oracle}/mesh.js +0 -0
package/src/framework/viewer.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
508
|
-
//
|
|
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
|
-
|
|
742
|
+
renderMeshPayloads,
|
|
743
|
+
onFrame,
|
|
744
|
+
tweenCameraTo,
|
|
745
|
+
cancelCameraTween,
|
|
746
|
+
onCameraStart,
|
|
628
747
|
setActive,
|
|
629
748
|
onContextLost,
|
|
630
749
|
setTheme,
|
package/src/framework/worker.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
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[];
|