partforge 0.39.0 → 0.41.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 +19 -0
- package/bin/cli.js +3 -0
- package/docs/AUTHORING-PARTS.md +17 -1
- package/package.json +1 -1
- package/src/framework/cutaway-gizmo.js +16 -2
- package/src/framework/cutaway-math.js +50 -1
- package/src/framework/cutaway-outline.js +233 -0
- package/src/framework/cutaway-render.js +20 -0
- package/src/framework/cutaway.js +47 -1
- package/src/framework/jobs.js +14 -2
- package/src/framework/mount.js +21 -1
- package/src/framework/verify-metrics.js +15 -2
- package/src/framework/viewer.js +84 -5
- package/src/testing/bvh.js +296 -106
- package/src/testing/gaps.js +6 -3
- package/src/testing/measure.js +33 -3
- package/src/testing/min-wall.js +80 -20
- package/src/testing/verify.js +58 -2
package/src/framework/mount.js
CHANGED
|
@@ -31,6 +31,15 @@ export function makeHandle({ ready, dispose, viewer, setParams, listExportablePa
|
|
|
31
31
|
ready, dispose, setParams,
|
|
32
32
|
captureViews: (viewNames) => viewer.captureCanonicalViews(viewNames),
|
|
33
33
|
captureCurrent: (opts) => viewer.captureCurrent(opts),
|
|
34
|
+
// Park/unpark the viewer: stops the render loop and frees the drawing
|
|
35
|
+
// buffer and the cached capture target. For an embedder that hides the
|
|
36
|
+
// canvas without unmounting it — `visibility: hidden`, an off-screen tab —
|
|
37
|
+
// where nothing collapses the container and the loop would otherwise run
|
|
38
|
+
// forever. See the setActive comment in viewer.js for what it costs.
|
|
39
|
+
setActive: (active) => viewer.setActive(active),
|
|
40
|
+
// Subscribe to WebGL context loss (returns an unsubscribe), so a host can
|
|
41
|
+
// say "the 3D view ran out of memory" instead of showing a dead canvas.
|
|
42
|
+
onContextLost: (listener) => viewer.onContextLost(listener),
|
|
34
43
|
listExportableParts,
|
|
35
44
|
exportParts,
|
|
36
45
|
// Narrow-layout pane selection, for a host that draws its own tab bar
|
|
@@ -69,7 +78,7 @@ function createCleanupStack() {
|
|
|
69
78
|
// mesh-validity cache, and the geometry workers. The app supplies `createWorker(name)`
|
|
70
79
|
// so Vite can bundle the worker (see geometry-service.js).
|
|
71
80
|
//
|
|
72
|
-
// Embedding contract (0.
|
|
81
|
+
// Embedding contract (0.41.0):
|
|
73
82
|
// const runtime = mount(part, { createWorker, elements, onBuild, onPick, onDownload });
|
|
74
83
|
// await runtime.ready; // first successful build of the default view
|
|
75
84
|
// runtime.setParams({ openAngle: 45 }); // programmatic edit; pose-only changes apply instantly
|
|
@@ -86,6 +95,17 @@ function createCleanupStack() {
|
|
|
86
95
|
// runtime.setHostPane("rail"); // narrow layout only: show just the controls
|
|
87
96
|
// // rail ('stage' | 'rail'), suppressing the
|
|
88
97
|
// // built-in tab bar. null hands selection back.
|
|
98
|
+
// runtime.setActive(false); // park the viewer: stop the render loop and release
|
|
99
|
+
// // both large GPU allocations (the drawing buffer and
|
|
100
|
+
// // the cached capture target). For a host that hides the
|
|
101
|
+
// // canvas WITHOUT unmounting it (`visibility: hidden`, an
|
|
102
|
+
// // inactive tab) — nothing else can detect that, and the
|
|
103
|
+
// // loop would otherwise render a hidden pane forever.
|
|
104
|
+
// // Captures still work while parked (they re-allocate).
|
|
105
|
+
// // setActive(true) restores it. Safe after dispose().
|
|
106
|
+
// const off = runtime.onContextLost(() => …); // WebGL context loss, i.e. the GPU or the
|
|
107
|
+
// // OS gave up — surface it rather than showing a dead
|
|
108
|
+
// // canvas. Returns an unsubscribe.
|
|
89
109
|
// runtime.dispose(); // full teardown
|
|
90
110
|
// onBuild fires per completed build, so it does NOT fire for a pose-only edit —
|
|
91
111
|
// those are repaired in the viewer and produce no build at all.
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
// is a hard gate or a warning, and the diagnostics attached to a non-pass check:
|
|
3
3
|
// `hint` (required — the report contract promises one on every fail/warn),
|
|
4
4
|
// `pattern` (optional stable ERROR-PATTERNS.md#<id>), `locate` (optional
|
|
5
|
-
// [x,y,z] source)
|
|
5
|
+
// [x,y,z] source), `note` (optional caveat about HOW the value was measured,
|
|
6
|
+
// attached whatever the status — a passing-but-sampled reading is exactly the
|
|
7
|
+
// case a reader needs told about). `manifoldOnly` facts are null on OCCT parts.
|
|
6
8
|
//
|
|
7
9
|
// This lives in framework/ rather than testing/ deliberately: the set of legal
|
|
8
10
|
// `verify.expect` metrics is part of the PartDefinition CONTRACT, which both the
|
|
@@ -32,7 +34,18 @@ export const SUBPART_METRICS = {
|
|
|
32
34
|
minWall: { kind: "warn", extract: (s) => s.minWall,
|
|
33
35
|
hint: "thinnest wall is at the reported location — increase the governing wall/thickness parameter or reduce the intersecting feature's depth",
|
|
34
36
|
pattern: "minwall-sliver-triangles",
|
|
35
|
-
locate: (s) => s.minWallAt
|
|
37
|
+
locate: (s) => s.minWallAt,
|
|
38
|
+
// Two sampled outcomes, and the second is the one that most needs saying: a
|
|
39
|
+
// sampled run that found NO wall reports minWall null, which without this note
|
|
40
|
+
// is indistinguishable from a part nobody measured. `sampled` counts triangles
|
|
41
|
+
// the walk selected, not rays cast — degenerate triangles are skipped.
|
|
42
|
+
note: (s) => {
|
|
43
|
+
if (!s.minWallSampled || !s.minWallSamples) return null;
|
|
44
|
+
const { sampled, total } = s.minWallSamples;
|
|
45
|
+
return s.minWall == null
|
|
46
|
+
? `no reading from the ${sampled} of ${total} triangles sampled — not a clean bill of health; a thin spot may exist between samples`
|
|
47
|
+
: `sampled ${sampled} of ${total} triangles — an upper bound; a thinner spot may exist between samples`;
|
|
48
|
+
} },
|
|
36
49
|
};
|
|
37
50
|
export const VIEW_METRICS = {
|
|
38
51
|
bbox: { kind: "gate", extract: (r) => r.aggregate.bbox,
|
package/src/framework/viewer.js
CHANGED
|
@@ -94,6 +94,10 @@ export function createViewer(container, part) {
|
|
|
94
94
|
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
|
95
95
|
container.appendChild(renderer.domElement);
|
|
96
96
|
|
|
97
|
+
// Declared up here, not beside setActive() below, because the initial resize()
|
|
98
|
+
// runs during construction and reads it.
|
|
99
|
+
let active = true;
|
|
100
|
+
|
|
97
101
|
const scene = new THREE.Scene();
|
|
98
102
|
|
|
99
103
|
// Light/dark scene palettes (the page chrome is themed separately, via CSS on the
|
|
@@ -175,14 +179,24 @@ export function createViewer(container, part) {
|
|
|
175
179
|
}
|
|
176
180
|
|
|
177
181
|
// The cutaway plane lives in world space, so its initial/reset bounds must
|
|
178
|
-
// include the pivot rotation and the per-view recentering transform
|
|
182
|
+
// include the pivot rotation and the per-view recentering transform —
|
|
183
|
+
// mesh.matrixWorld carries both. Union each visible mesh's own
|
|
184
|
+
// geometry.boundingBox rather than `Box3.expandByObject`, which recurses into
|
|
185
|
+
// children: the two stencil-pass meshes share `mesh.geometry` so that
|
|
186
|
+
// recursion is harmless for them, but the cut-face outline child carries its
|
|
187
|
+
// own independent geometry that only re-slices while the cutaway is enabled
|
|
188
|
+
// and visible — while hidden it can keep segments from an older, larger part
|
|
189
|
+
// and inflate these bounds. A subpart's initial placeholder BufferGeometry
|
|
190
|
+
// has no boundingBox computed (only buildGeometry computes one), so skip it.
|
|
179
191
|
const _worldBounds = new THREE.Box3();
|
|
192
|
+
const _meshBounds = new THREE.Box3();
|
|
180
193
|
function getVisibleWorldBounds() {
|
|
181
194
|
_worldBounds.makeEmpty();
|
|
182
195
|
for (const mesh of Object.values(subMesh)) {
|
|
183
|
-
if (!mesh.visible || !mesh.geometry) continue;
|
|
196
|
+
if (!mesh.visible || !mesh.geometry?.boundingBox) continue;
|
|
184
197
|
mesh.updateWorldMatrix(true, false);
|
|
185
|
-
|
|
198
|
+
_meshBounds.copy(mesh.geometry.boundingBox).applyMatrix4(mesh.matrixWorld);
|
|
199
|
+
_worldBounds.union(_meshBounds);
|
|
186
200
|
}
|
|
187
201
|
return _worldBounds;
|
|
188
202
|
}
|
|
@@ -351,6 +365,10 @@ export function createViewer(container, part) {
|
|
|
351
365
|
// --- resize ---------------------------------------------------------------
|
|
352
366
|
// Size from the host container (not the window) so embedders control the pane.
|
|
353
367
|
function resize() {
|
|
368
|
+
// Parked (see setActive): the buffer is deliberately 1x1 and must stay that
|
|
369
|
+
// way. iOS fires resizes constantly as the URL bar collapses, and every one
|
|
370
|
+
// of them would otherwise re-allocate a full MSAA buffer for a hidden pane.
|
|
371
|
+
if (!active) return;
|
|
354
372
|
const w = container.clientWidth || 300, h = container.clientHeight || 150;
|
|
355
373
|
renderer.setSize(w, h);
|
|
356
374
|
camera.aspect = w / h;
|
|
@@ -471,12 +489,66 @@ export function createViewer(container, part) {
|
|
|
471
489
|
}
|
|
472
490
|
|
|
473
491
|
// --- render loop ----------------------------------------------------------
|
|
474
|
-
|
|
492
|
+
function renderFrame() {
|
|
475
493
|
controls.update();
|
|
476
494
|
if (cutaway.isEnabled) cutaway.updateForCamera();
|
|
477
495
|
renderer.render(scene, camera);
|
|
478
496
|
cutaway.renderOverlay(renderer, camera);
|
|
479
|
-
}
|
|
497
|
+
}
|
|
498
|
+
renderer.setAnimationLoop(renderFrame);
|
|
499
|
+
|
|
500
|
+
// --- active / parked ------------------------------------------------------
|
|
501
|
+
// For a host that HIDES the viewer without unmounting it. partforge's own
|
|
502
|
+
// narrow layout uses `display: none` on the stage, which zeroes clientWidth
|
|
503
|
+
// and lets the ResizeObserver above collapse the buffer for free. An embedder
|
|
504
|
+
// that cannot do that — partforge-cloud's phone tab bar uses
|
|
505
|
+
// `visibility: hidden`, because the canvas has to keep its size for build
|
|
506
|
+
// 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
|
|
509
|
+
// continuous GPU work nobody can see, so the host has to say so explicitly.
|
|
510
|
+
//
|
|
511
|
+
// Parking stops the loop and releases the drawing buffer. `setSize(1, 1,
|
|
512
|
+
// false)` leaves the canvas element's CSS box alone, so the host's layout
|
|
513
|
+
// does not move and the pane can be revealed again without a reflow.
|
|
514
|
+
function setActive(next) {
|
|
515
|
+
const want = next !== false;
|
|
516
|
+
if (disposed || want === active) return;
|
|
517
|
+
active = want;
|
|
518
|
+
if (!active) {
|
|
519
|
+
renderer.setAnimationLoop(null);
|
|
520
|
+
renderer.setSize(1, 1, false);
|
|
521
|
+
// The cached 1024² 4x-MSAA + stencil capture target is the other large
|
|
522
|
+
// allocation here — on a phone it is comparable to the canvas itself, so
|
|
523
|
+
// parking that kept it would leave half the memory behind. Dropping it
|
|
524
|
+
// costs one re-allocation on the next capture, which a parked viewer
|
|
525
|
+
// barely notices: the cache only ever hits on an exactly-square request,
|
|
526
|
+
// and a phone's capture aspect is not square, so those captures were
|
|
527
|
+
// allocating per call regardless.
|
|
528
|
+
_rt?.dispose();
|
|
529
|
+
_rt = null;
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
resize(); // rebuild the buffer at whatever size the container is now
|
|
533
|
+
renderer.setAnimationLoop(renderFrame);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// --- context loss ---------------------------------------------------------
|
|
537
|
+
// Losing the WebGL context is how a memory-starved phone tells you it gave
|
|
538
|
+
// up. With no handler the canvas just freezes, indistinguishable from a hang,
|
|
539
|
+
// and three never re-initialises. preventDefault() is what makes the loss
|
|
540
|
+
// recoverable (three's own listener re-uploads on restore); the subscribers
|
|
541
|
+
// let an embedder surface it instead of showing a dead rectangle.
|
|
542
|
+
const contextLostListeners = new Set();
|
|
543
|
+
const onContextLostEvent = (event) => {
|
|
544
|
+
event.preventDefault();
|
|
545
|
+
for (const listener of [...contextLostListeners]) listener();
|
|
546
|
+
};
|
|
547
|
+
renderer.domElement.addEventListener("webglcontextlost", onContextLostEvent);
|
|
548
|
+
function onContextLost(listener) {
|
|
549
|
+
contextLostListeners.add(listener);
|
|
550
|
+
return () => contextLostListeners.delete(listener);
|
|
551
|
+
}
|
|
480
552
|
|
|
481
553
|
// --- camera state (read/write for persistence; mount.js owns storage) -------
|
|
482
554
|
function getCameraState() {
|
|
@@ -518,6 +590,11 @@ export function createViewer(container, part) {
|
|
|
518
590
|
disposed = true;
|
|
519
591
|
ro.disconnect();
|
|
520
592
|
renderer.setAnimationLoop(null);
|
|
593
|
+
// Embedder callbacks must not outlive teardown — a disposed viewer has no
|
|
594
|
+
// context left to lose, and a surviving listener would keep the embedder's
|
|
595
|
+
// closure (and whatever it captured) alive.
|
|
596
|
+
renderer.domElement.removeEventListener("webglcontextlost", onContextLostEvent);
|
|
597
|
+
contextLostListeners.clear();
|
|
521
598
|
controls.dispose();
|
|
522
599
|
for (const t of flashTimers) clearTimeout(t);
|
|
523
600
|
flashTimers.clear();
|
|
@@ -548,6 +625,8 @@ export function createViewer(container, part) {
|
|
|
548
625
|
captureCanonicalViews,
|
|
549
626
|
captureCurrent,
|
|
550
627
|
setAutoRotate,
|
|
628
|
+
setActive,
|
|
629
|
+
onContextLost,
|
|
551
630
|
setTheme,
|
|
552
631
|
getCameraState,
|
|
553
632
|
setCameraState,
|