partforge 0.71.0 → 0.73.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/docs/AUTHORING-PARTS.md +71 -3
- package/package.json +1 -1
- package/src/framework/animation-controls.js +171 -16
- package/src/framework/annotate/annotate-mode.js +31 -3
- package/src/framework/app.css +122 -4
- package/src/framework/camera-orbit.js +84 -0
- package/src/framework/camera-tween.js +22 -10
- package/src/framework/chrome.css +113 -3
- package/src/framework/cutaway-gizmo.js +6 -1
- package/src/framework/cutaway.js +9 -1
- package/src/framework/jobs.js +22 -5
- package/src/framework/measure/dim3-scene.js +15 -2
- package/src/framework/mount.js +79 -2
- package/src/framework/oracle/gates.js +50 -0
- package/src/framework/oracle/measure.js +21 -4
- package/src/framework/oracle/min-wall.js +17 -0
- package/src/framework/oracle/verify.js +63 -22
- package/src/framework/projection.js +19 -0
- package/src/framework/view-angles.js +69 -1
- package/src/framework/view-state.js +9 -0
- package/src/framework/viewcube/cube-canvas.js +410 -0
- package/src/framework/viewcube/cube-geom.js +367 -0
- package/src/framework/viewcube/viewcube-controls.js +157 -0
- package/src/framework/viewcube/viewcube-mode.js +201 -0
- package/src/framework/viewer.js +289 -23
|
@@ -1,18 +1,30 @@
|
|
|
1
1
|
import * as THREE from "three";
|
|
2
2
|
import { EASINGS } from "./animation.js";
|
|
3
3
|
|
|
4
|
-
// Retargetable orbit-camera tween for animation camera cues
|
|
5
|
-
// interpolation of {position, target} pairs about the
|
|
6
|
-
// target, shortest-path in azimuth
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
|
|
4
|
+
// Retargetable orbit-camera tween for animation camera cues and view-cube
|
|
5
|
+
// clicks: eased spherical interpolation of {position, target} pairs about the
|
|
6
|
+
// (linearly moving) orbit target, shortest-path in azimuth. Pure math, no clock
|
|
7
|
+
// — the viewer feeds dt seconds into update() each frame and applies the
|
|
8
|
+
// returned pose.
|
|
9
|
+
//
|
|
10
|
+
// NOT clamped off the poles. It used to be, by 0.01 rad, to keep OrbitControls
|
|
11
|
+
// off its gimbal — but the clamp applied to the DESTINATION too, so a "top" or
|
|
12
|
+
// "bottom" cue landed 0.573° short of the axis every single time, in both
|
|
13
|
+
// projections. That is the whole of the "clicking top doesn't view from the top"
|
|
14
|
+
// bug: a spacer kept a sliver of side wall visible instead of reading as a flat
|
|
15
|
+
// outline.
|
|
16
|
+
//
|
|
17
|
+
// Landing exactly on the pole is safe, because OrbitControls' own update() is
|
|
18
|
+
// the backstop: Spherical.makeSafe() holds phi off 0 and PI by 1e-6 rad (a
|
|
19
|
+
// 5.7e-5° tilt, three orders of magnitude under what the eye or a frustum can
|
|
20
|
+
// resolve), so the camera is never left with a degenerate lookAt basis and the
|
|
21
|
+
// roll stays deterministic. Azimuth at the pole is a no-op either way, and
|
|
22
|
+
// atan2(0, 0) is 0 rather than NaN — which is also the azimuth the canonical
|
|
23
|
+
// top/bottom poses ask for, so the roll OrbitControls derives there is exactly
|
|
24
|
+
// the `up` view-angles.js names for them.
|
|
11
25
|
function toSpherical(position, target) {
|
|
12
26
|
const off = new THREE.Vector3().fromArray(position).sub(new THREE.Vector3().fromArray(target));
|
|
13
|
-
|
|
14
|
-
sph.phi = Math.min(Math.PI - POLE_EPS, Math.max(POLE_EPS, sph.phi));
|
|
15
|
-
return sph;
|
|
27
|
+
return new THREE.Spherical().setFromVector3(off);
|
|
16
28
|
}
|
|
17
29
|
|
|
18
30
|
export function createCameraTween() {
|
package/src/framework/chrome.css
CHANGED
|
@@ -225,9 +225,119 @@
|
|
|
225
225
|
(background/border/radius/shadow) from app.css, so that chrome must live in
|
|
226
226
|
app.css ungated — not be duplicated into a class the cloud never sets. This
|
|
227
227
|
file owns where things sit; app.css owns what they look like. */
|
|
228
|
-
.pf-float-tabs, .pf-float-viewbar { position: absolute; z-index: 15; }
|
|
229
|
-
|
|
230
|
-
|
|
228
|
+
.pf-float-tabs, .pf-float-viewbar, .pf-float-rail-toggle { position: absolute; z-index: 15; }
|
|
229
|
+
/* Every inset below is SHARED with whichever other float sits on that edge, so
|
|
230
|
+
the stage's margin is stated once per edge and not once per element. The rail
|
|
231
|
+
toggle (2026-08-20: out of #viewbar's pill, floating on its own at the top
|
|
232
|
+
right) is the tab strip's `top` with the viewbar's `right` — a corner
|
|
233
|
+
symmetric with the viewbar's, by construction rather than by two numbers that
|
|
234
|
+
happen to agree today. Retune the margin and all three corners follow.
|
|
235
|
+
Appearance, including the `[hidden]` guard that lets rail.js hide the toggle
|
|
236
|
+
below the narrow breakpoint, is in app.css. */
|
|
237
|
+
.pf-float-tabs, .pf-float-rail-toggle { top: 12px; }
|
|
238
|
+
.pf-float-viewbar, .pf-float-rail-toggle { right: 12px; }
|
|
239
|
+
.pf-float-tabs { left: 50%; transform: translateX(-50%); }
|
|
240
|
+
.pf-float-viewbar { bottom: 12px; }
|
|
241
|
+
|
|
242
|
+
/* ---- view cube stack: PLACEMENT ONLY (see the rule above) ----------------
|
|
243
|
+
Bottom-right, stacked above #viewbar. The offset is measured, not
|
|
244
|
+
hardcoded: mount.js publishes --pf-viewbar-clear on the stage from a
|
|
245
|
+
ResizeObserver on #viewbar, mirroring --pf-anim-clear. It sits there rather
|
|
246
|
+
than in viewcube-controls.js (the precedent being animation-controls.js,
|
|
247
|
+
which publishes its own --pf-anim-clear) because the value describes the
|
|
248
|
+
VIEWBAR's vertical claim, not the cube's, and mount owns both elements. The
|
|
249
|
+
56px fallback is the standard viewbar's 12px bottom + 44px height, so the
|
|
250
|
+
stack still sits right if the observer never fires.
|
|
251
|
+
|
|
252
|
+
The projection toggle has moved twice. Through 2026-08-19 it sat in its own
|
|
253
|
+
`.pf-viewcube-pill` card BELOW the cube, making the stack a column. Earlier
|
|
254
|
+
on 2026-08-20 it became a bare circle BESIDE the cube, making the stack a
|
|
255
|
+
`row-reverse` flex row (`canvas + gap + button`, 167px wide at the full
|
|
256
|
+
135px cube). It now sits OVER the cube's bottom-right corner instead:
|
|
257
|
+
absolutely positioned within the stack, right edge on the stack's right edge
|
|
258
|
+
and bottom edge on its bottom baseline, so the stack's width collapses back
|
|
259
|
+
to the canvas alone (135px, or 101 below the narrow breakpoint).
|
|
260
|
+
|
|
261
|
+
That is also what satisfies "aligned with the right of the toolbar" without a
|
|
262
|
+
second offset: `.pf-float-viewbar` above is `right: 12px` too, so making the
|
|
263
|
+
button's right edge the STACK's right edge lines it up with the viewbar's by
|
|
264
|
+
construction — and it keeps doing so if that margin is ever retuned.
|
|
265
|
+
|
|
266
|
+
The button is a later sibling than the cube's wrapper, so it paints over the
|
|
267
|
+
canvas with no z-index of its own; 24px over the corner cell — the least
|
|
268
|
+
informative part of the drawing — is small enough not to hide anything worth
|
|
269
|
+
clicking, and the canvas keeps receiving pointer events everywhere else.
|
|
270
|
+
|
|
271
|
+
The 8px that used to sit between the stack and the viewbar is now 3px
|
|
272
|
+
(2026-08-20: "lower the whole box so it's closer to the bottom bar"). 3
|
|
273
|
+
rather than 0 because the stack's own box MUST NOT overlap #viewbar: the
|
|
274
|
+
stack is a later sibling at the same z-index, and its canvas is
|
|
275
|
+
pointer-events:auto across its whole footprint, so any vertical overlap
|
|
276
|
+
would silently swallow clicks on the viewbar's rightmost buttons (both are
|
|
277
|
+
right: 12px). --pf-viewbar-clear is published rounded to whole px, so a
|
|
278
|
+
fractional viewbar height can move this by half a pixel either way; 3px
|
|
279
|
+
absorbs that and still reads as a deliberate gap rather than a collision.
|
|
280
|
+
The other ~5px of the lowering comes from inside the canvas — see
|
|
281
|
+
cube-geom.js's CUBE_DOWN_BIAS_PX; that is where the rest of the perceived
|
|
282
|
+
gap actually lives, and it is capped by the axis labels' clearance, so
|
|
283
|
+
this pair (3 + 5) is the whole slack that exists without either overlapping
|
|
284
|
+
the viewbar or shrinking the cube.
|
|
285
|
+
|
|
286
|
+
The stack itself is pointer-transparent. With the button back inside the
|
|
287
|
+
canvas's footprint there is no dead gap left at all: the stack's box IS the
|
|
288
|
+
canvas's box, and both the canvas and the button opt back in below. The
|
|
289
|
+
declaration stays because the stack's own box is still an element over the
|
|
290
|
+
viewer, and a stray hit on it (its padding-free edges, or a future child
|
|
291
|
+
before it opts in) should reach the model behind rather than be swallowed. */
|
|
292
|
+
.pf-viewcube-stack {
|
|
293
|
+
position: absolute;
|
|
294
|
+
right: 12px;
|
|
295
|
+
bottom: calc(var(--pf-viewbar-clear, 56px) + 3px);
|
|
296
|
+
z-index: 15;
|
|
297
|
+
display: flex;
|
|
298
|
+
pointer-events: none;
|
|
299
|
+
}
|
|
300
|
+
.pf-viewcube-stack[hidden] { display: none; }
|
|
301
|
+
.pf-viewcube-canvas, .pf-viewcube-toggle { pointer-events: auto; }
|
|
302
|
+
/* Over the cube's bottom-right corner. Absolute against the stack, which is
|
|
303
|
+
itself absolute and so already the containing block. */
|
|
304
|
+
.pf-viewcube-toggle { position: absolute; right: 0; bottom: 0; }
|
|
305
|
+
|
|
306
|
+
/* The keyboard surface: six per-view buttons standing in for the DOM focus a
|
|
307
|
+
canvas cannot give us. Visually hidden rather than display:none — the latter
|
|
308
|
+
takes them out of the tab order, which is the whole point of them.
|
|
309
|
+
|
|
310
|
+
The hiding properties are on the BUTTONS, not on their wrapper, so that
|
|
311
|
+
:focus-visible can undo them. A non-`none` clip-path clips the element's
|
|
312
|
+
whole SUBTREE and makes the element a containing block for fixed-position
|
|
313
|
+
descendants, so with the clip on the wrapper no rule on a focused child
|
|
314
|
+
could escape it — the reveal below was dead CSS, and six buttons sat in the
|
|
315
|
+
tab order with no visible focus indicator at all. On the buttons themselves
|
|
316
|
+
there is no clipping ancestor to get out of.
|
|
317
|
+
|
|
318
|
+
The wrapper keeps only `position: absolute`, which takes it out of the
|
|
319
|
+
stack's flex flow. That mattered when the stack had a gap (a zero-height
|
|
320
|
+
flex item still earned it, pushing the cube out of place); it still matters
|
|
321
|
+
now that it does not, because an in-flow second item would widen the stack
|
|
322
|
+
past the canvas — and the stack's width is what the crowding rule reads. */
|
|
323
|
+
.pf-viewcube-key { position: absolute; }
|
|
324
|
+
.pf-viewcube-key button {
|
|
325
|
+
position: absolute;
|
|
326
|
+
width: 1px; height: 1px;
|
|
327
|
+
margin: -1px; padding: 0;
|
|
328
|
+
overflow: hidden;
|
|
329
|
+
clip-path: inset(50%);
|
|
330
|
+
white-space: nowrap;
|
|
331
|
+
border: 0;
|
|
332
|
+
}
|
|
333
|
+
.pf-viewcube-key button:focus-visible {
|
|
334
|
+
position: fixed;
|
|
335
|
+
width: auto; height: auto;
|
|
336
|
+
margin: 0; padding: 2px 6px;
|
|
337
|
+
overflow: visible;
|
|
338
|
+
clip-path: none;
|
|
339
|
+
pointer-events: auto; /* the stack is pointer-transparent; a revealed control is not */
|
|
340
|
+
}
|
|
231
341
|
|
|
232
342
|
/* --- animation transport bar (generated by animation-controls.js) ----------
|
|
233
343
|
PLACEMENT ONLY, per the rule above; appearance lives in app.css next to
|
|
@@ -44,7 +44,7 @@ const WHITE = new THREE.Color(0xffffff);
|
|
|
44
44
|
export function createCutawayGizmo({
|
|
45
45
|
scene,
|
|
46
46
|
overlayScene,
|
|
47
|
-
camera,
|
|
47
|
+
camera: initialCamera,
|
|
48
48
|
domElement,
|
|
49
49
|
orbitControls,
|
|
50
50
|
onPoseChange = () => {},
|
|
@@ -53,6 +53,10 @@ export function createCutawayGizmo({
|
|
|
53
53
|
onDragChange = () => {},
|
|
54
54
|
pickHandle,
|
|
55
55
|
}) {
|
|
56
|
+
// Reassignable: the viewer swaps cameras when the projection toggle flips,
|
|
57
|
+
// and this module holds fifteen references to it. One binding to move beats
|
|
58
|
+
// threading a getter through all of them.
|
|
59
|
+
let camera = initialCamera;
|
|
56
60
|
const sceneGraph = buildGizmoScene(THEMES.dark);
|
|
57
61
|
const {
|
|
58
62
|
group,
|
|
@@ -535,6 +539,7 @@ export function createCutawayGizmo({
|
|
|
535
539
|
setActiveAppearance,
|
|
536
540
|
setTheme,
|
|
537
541
|
updateForCamera,
|
|
542
|
+
setCamera(next) { if (next) camera = next; },
|
|
538
543
|
dispose,
|
|
539
544
|
};
|
|
540
545
|
}
|
package/src/framework/cutaway.js
CHANGED
|
@@ -32,7 +32,7 @@ function validBounds(getBounds) {
|
|
|
32
32
|
export function createCutaway({
|
|
33
33
|
renderer,
|
|
34
34
|
scene,
|
|
35
|
-
camera,
|
|
35
|
+
camera: initialCamera,
|
|
36
36
|
orbitControls,
|
|
37
37
|
domElement,
|
|
38
38
|
getBounds,
|
|
@@ -40,6 +40,9 @@ export function createCutaway({
|
|
|
40
40
|
schedule = defaultSchedule,
|
|
41
41
|
now,
|
|
42
42
|
}) {
|
|
43
|
+
// Reassignable: a projection swap (perspective <-> ortho) hands the cutaway
|
|
44
|
+
// a new camera after construction, and the gizmo must follow it too.
|
|
45
|
+
let camera = initialCamera;
|
|
43
46
|
let supported = false;
|
|
44
47
|
try {
|
|
45
48
|
supported = Boolean(
|
|
@@ -522,6 +525,11 @@ export function createCutaway({
|
|
|
522
525
|
updateForCamera,
|
|
523
526
|
renderOverlay,
|
|
524
527
|
onHandleHoverChange,
|
|
528
|
+
setCamera(next) {
|
|
529
|
+
if (!next) return;
|
|
530
|
+
camera = next;
|
|
531
|
+
gizmo?.setCamera(next);
|
|
532
|
+
},
|
|
525
533
|
dispose,
|
|
526
534
|
_renderSetFor: (name) => renderSets.get(name)?.renderSet ?? null,
|
|
527
535
|
_setDragging: setDragging,
|
package/src/framework/jobs.js
CHANGED
|
@@ -241,10 +241,11 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
241
241
|
// an unparameterized inspect that case IS this measurement. Seeding it in
|
|
242
242
|
// (see verify.js's seeding block for the min-wall superset rule that makes
|
|
243
243
|
// the reuse sound) stops the oracle from rebuilding the same geometry and
|
|
244
|
-
// re-casting the same min-wall rays a second time.
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
244
|
+
// re-casting the same min-wall rays a second time. On a full lap the seed is
|
|
245
|
+
// usable by any verify run, min-wall gated or not, because the pass ran — and
|
|
246
|
+
// the result says so ITSELF (`measuredMinWall`/`measuredGaps`), never a claim
|
|
247
|
+
// by this caller, so the two cannot drift apart. On a quick lap the passes did
|
|
248
|
+
// not run, the stamps say false, and verify reports what it could not check.
|
|
248
249
|
//
|
|
249
250
|
// The view is built HERE rather than inside measure, and handed down through
|
|
250
251
|
// `opts.built`, because optional match scoring needs the same meshes: one build
|
|
@@ -259,15 +260,31 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
259
260
|
// measure built internally and its own signature default hid this. Found by a
|
|
260
261
|
// live browser check, not by tests: this suite passes explicit views, and the
|
|
261
262
|
// cloud's unit tests fake the worker.
|
|
263
|
+
//
|
|
264
|
+
// `checks: "quick"` is the agent's fast lap. Min wall and pair distances are
|
|
265
|
+
// the oracle's two ray-casting passes and, profiled on a 460k-triangle
|
|
266
|
+
// assembly, 79% of its cost — and they SHARE the BVH those rays need, so
|
|
267
|
+
// skipping one leaves the index build standing and saves about half of what
|
|
268
|
+
// skipping both does. Everything else is derived from the build this job
|
|
269
|
+
// already paid for and stays: triangles, bbox, volume, genus, watertight,
|
|
270
|
+
// and the assembly overlap check. verify still runs — the gates that read
|
|
271
|
+
// those facts are free — and reports what it could not evaluate rather than
|
|
272
|
+
// passing it, which is why `quick` can be honoured on a gated part instead
|
|
273
|
+
// of refused. Anything other than the literal "quick" is the full lap: an
|
|
274
|
+
// unrecognized value must never quietly buy less checking than the caller
|
|
275
|
+
// asked for.
|
|
276
|
+
const quick = msg.checks === "quick";
|
|
262
277
|
const view = msg.view ?? Object.keys(part.views)[0];
|
|
263
278
|
const built = buildView(kernel, part, view, msg.params ?? {});
|
|
264
|
-
const measured = measure(kernel, part, view, msg.params ?? {},
|
|
279
|
+
const measured = measure(kernel, part, view, msg.params ?? {},
|
|
280
|
+
{ minWall: !quick, gaps: !quick, built });
|
|
265
281
|
const report = {
|
|
266
282
|
measure: measured,
|
|
267
283
|
verify: verify(kernel, part, {
|
|
268
284
|
// The defaulted view, not msg.view: the seed below was measured on it, and
|
|
269
285
|
// verify's seed reuse is only sound when both name the same view.
|
|
270
286
|
view,
|
|
287
|
+
quick,
|
|
271
288
|
seed: { params: msg.params ?? {}, result: measured },
|
|
272
289
|
}),
|
|
273
290
|
};
|
|
@@ -58,6 +58,13 @@ export function worldPerPx(dist, fovDeg, viewportPx) {
|
|
|
58
58
|
return (2 * dist * Math.tan((fovDeg * Math.PI) / 360)) / viewportPx;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
// The orthographic twin of worldPerPx. An ortho camera's scale is a property of
|
|
62
|
+
// its frustum and zoom alone — distance does not enter — which is exactly why
|
|
63
|
+
// the perspective formula cannot be reused with a substituted fov.
|
|
64
|
+
export function orthoWorldPerPx(top, bottom, zoom, viewportPx) {
|
|
65
|
+
return Math.abs(top - bottom) / Math.max(zoom, 1e-6) / Math.max(viewportPx, 1);
|
|
66
|
+
}
|
|
67
|
+
|
|
61
68
|
// Kept for compatibility with earlier callers/tests: the world height that
|
|
62
69
|
// renders as `targetPx` on screen.
|
|
63
70
|
export function labelWorldHeight(dist, fovDeg, viewportPx, targetPx = LABEL_SCREEN_PX) {
|
|
@@ -319,8 +326,14 @@ export function createDimScene(viewer, { paintLabel = defaultPaintLabel } = {})
|
|
|
319
326
|
// One shared reference distance — camera to the dim group's origin (the
|
|
320
327
|
// recentred model centre) — sizes the whole drawing.
|
|
321
328
|
group.getWorldPosition(_gp);
|
|
322
|
-
|
|
323
|
-
|
|
329
|
+
// `viewer.camera.fov ?? 45` was the bug this branch removes: under an ortho
|
|
330
|
+
// camera fov is undefined, so the fallback produced a plausible-but-wrong
|
|
331
|
+
// scale and every label, arrow and standoff drifted as the user dollied.
|
|
332
|
+
// The ortho formula matches cutaway-gizmo.js:485's worldUnitsPerPixelAt.
|
|
333
|
+
const cam = viewer.camera;
|
|
334
|
+
const wpp = cam.isOrthographicCamera
|
|
335
|
+
? orthoWorldPerPx(cam.top, cam.bottom, cam.zoom, h)
|
|
336
|
+
: worldPerPx(cam.position.distanceTo(_gp), cam.fov ?? 45, h);
|
|
324
337
|
if (wpp > 0) {
|
|
325
338
|
const hStar = LABEL_SCREEN_PX * wpp;
|
|
326
339
|
const aw = ARROW_SCREEN_PX * wpp;
|
package/src/framework/mount.js
CHANGED
|
@@ -6,7 +6,7 @@ import { attachCutawayControls } from "./cutaway-controls.js";
|
|
|
6
6
|
import { attachRail } from "./rail.js";
|
|
7
7
|
import { attachMobileTabs } from "./mobile-tabs.js";
|
|
8
8
|
import { createTooltipPresenter, attachButtonTooltips } from "./tooltip.js";
|
|
9
|
-
import { loadCamera } from "./view-state.js";
|
|
9
|
+
import { loadCamera, loadProjection, saveProjection } from "./view-state.js";
|
|
10
10
|
import { buildControls } from "./controls.js";
|
|
11
11
|
import { relevantParamKeys } from "./param-deps.js";
|
|
12
12
|
import { createMeshCache } from "./mesh-cache.js";
|
|
@@ -30,6 +30,7 @@ import { createMeasureMode } from "./measure/measure-mode.js";
|
|
|
30
30
|
import { attachMeasureControls } from "./measure/measure-controls.js";
|
|
31
31
|
import { createAnnotateMode } from "./annotate/annotate-mode.js";
|
|
32
32
|
import { attachAnnotateControls } from "./annotate/annotate-controls.js";
|
|
33
|
+
import { attachViewcubeControls } from "./viewcube/viewcube-controls.js";
|
|
33
34
|
|
|
34
35
|
// The mount handle, factored out so its shape is unit-testable without booting
|
|
35
36
|
// the full mount() pipeline (WASM + workers + DOM).
|
|
@@ -59,7 +60,7 @@ const IMPORT_MESH_BROKEN_MESSAGE = "STEP import tessellation failed to satisfy t
|
|
|
59
60
|
// carries the worker's own error text. See the correlated "error" case below.
|
|
60
61
|
const importTessellateFailedMessage = (workerMessage) => `STEP import tessellation failed — ${workerMessage}`;
|
|
61
62
|
|
|
62
|
-
export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, setHostPane, animation, getView, setView, captureView, attachTooltips, measure, annotate }) {
|
|
63
|
+
export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, setHostPane, animation, getView, setView, captureView, attachTooltips, measure, annotate, projection }) {
|
|
63
64
|
return {
|
|
64
65
|
ready, dispose, setParams,
|
|
65
66
|
// Part-declared animation playback (spec 2026-08-02): animations are
|
|
@@ -106,6 +107,13 @@ export function makeHandle({ ready, dispose, viewer, setParams, listExportablePa
|
|
|
106
107
|
// the built-in pencil button. send() delivers to onAnnotationSend and
|
|
107
108
|
// returns false when there is no ink or the capture failed.
|
|
108
109
|
annotate: annotate ?? NOOP_ANNOTATE,
|
|
110
|
+
// Projection is a viewer-wide display mode, not a part property — same
|
|
111
|
+
// shape as `measure` and `annotate` so a host reads one convention.
|
|
112
|
+
projection: projection ?? {
|
|
113
|
+
get: () => "perspective",
|
|
114
|
+
set: () => {},
|
|
115
|
+
onChange: () => () => {},
|
|
116
|
+
},
|
|
109
117
|
};
|
|
110
118
|
}
|
|
111
119
|
|
|
@@ -203,6 +211,10 @@ function createCleanupStack() {
|
|
|
203
211
|
// // subscribes return an unsubscribe; onInkChange fires on
|
|
204
212
|
// // every stroke/undo/clear, which is what a host driving its
|
|
205
213
|
// // own Send button gates that button on (strokeCount() > 0).
|
|
214
|
+
// runtime.projection: { get, set, onChange }
|
|
215
|
+
// // "perspective" | "orthographic". Drives the LIVE view
|
|
216
|
+
// // and captureCurrent only — captureCanonicalViews,
|
|
217
|
+
// // renderMeshPayloads and the CLI stay perspective.
|
|
206
218
|
// runtime.dispose(); // full teardown
|
|
207
219
|
// onBuild fires per completed build, so it does NOT fire for a pose-only edit —
|
|
208
220
|
// those are repaired in the viewer and produce no build at all.
|
|
@@ -412,6 +424,55 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
412
424
|
annotate: els.chrome.annotate,
|
|
413
425
|
}, { tooltip, escapeScope: els.viewer, send: annotateSend });
|
|
414
426
|
cleanup.defer(() => annotateChrome.detach());
|
|
427
|
+
// Orientation cube + projection toggle. Generated chrome — no host markup
|
|
428
|
+
// declares it, so an embedder gets it for free. Restored BEFORE any framing
|
|
429
|
+
// happens so a reload into ortho frames once instead of framing in
|
|
430
|
+
// perspective and then visibly re-framing.
|
|
431
|
+
viewer.setProjection(loadProjection());
|
|
432
|
+
const viewcube = attachViewcubeControls(viewer, { stage: els.viewer }, { tooltip });
|
|
433
|
+
cleanup.defer(() => viewcube.detach());
|
|
434
|
+
cleanup.defer(viewer.onProjectionChange((mode) => saveProjection(mode)));
|
|
435
|
+
// setHidden takes one boolean, and there are two independent reasons to hide
|
|
436
|
+
// the cube: Sketch mode (below) and a crowded transport bar (wired into the
|
|
437
|
+
// animation controls further down). Applied straight, whichever fires last
|
|
438
|
+
// would win — leaving Sketch would reveal a cube that crowding still wants
|
|
439
|
+
// gone. Track a flag per reason and OR them through one place, the
|
|
440
|
+
// syncHoverSuppression precedent below.
|
|
441
|
+
let cubeHiddenForSketch = false;
|
|
442
|
+
let cubeHiddenForCrowding = false;
|
|
443
|
+
const syncViewcubeHidden = () =>
|
|
444
|
+
viewcube.setHidden(cubeHiddenForSketch || cubeHiddenForCrowding);
|
|
445
|
+
// Sketch freezes the view on purpose: ink is stored in screen space and is
|
|
446
|
+
// meaningful only against the pose it was drawn over. A live camera control
|
|
447
|
+
// on top of that — orbit OR a projection swap — invalidates the drawing.
|
|
448
|
+
if (annotateMode) {
|
|
449
|
+
cleanup.defer(annotateMode.onModeChange(() => {
|
|
450
|
+
cubeHiddenForSketch = annotateMode.isEnabled();
|
|
451
|
+
syncViewcubeHidden();
|
|
452
|
+
}));
|
|
453
|
+
}
|
|
454
|
+
// Publish the viewbar's vertical claim so chrome.css can stack the cube on
|
|
455
|
+
// top of it without hardcoding a height that cutaway/measure/annotate
|
|
456
|
+
// action rows can change.
|
|
457
|
+
const viewbarEl = els.viewer.querySelector("#viewbar");
|
|
458
|
+
if (viewbarEl && typeof ResizeObserver === "function") {
|
|
459
|
+
const publishViewbarClear = () => {
|
|
460
|
+
const stageRect = els.viewer.getBoundingClientRect();
|
|
461
|
+
const barRect = viewbarEl.getBoundingClientRect();
|
|
462
|
+
els.viewer.style.setProperty(
|
|
463
|
+
"--pf-viewbar-clear",
|
|
464
|
+
`${Math.max(0, Math.round(stageRect.bottom - barRect.top))}px`,
|
|
465
|
+
);
|
|
466
|
+
};
|
|
467
|
+
const viewbarObserver = new ResizeObserver(publishViewbarClear);
|
|
468
|
+
viewbarObserver.observe(viewbarEl);
|
|
469
|
+
viewbarObserver.observe(els.viewer);
|
|
470
|
+
publishViewbarClear();
|
|
471
|
+
cleanup.defer(() => {
|
|
472
|
+
viewbarObserver.disconnect();
|
|
473
|
+
els.viewer.style.removeProperty("--pf-viewbar-clear");
|
|
474
|
+
});
|
|
475
|
+
}
|
|
415
476
|
// escapeScope: cutaway's Flip/Reset buttons are canvas SIBLINGS inside
|
|
416
477
|
// #viewbar, not descendants of the canvas — attaching Escape to
|
|
417
478
|
// viewer.domElement alone would leave a guarded Escape from those buttons
|
|
@@ -836,6 +897,17 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
836
897
|
applyValues: applyAnimationValues,
|
|
837
898
|
getParamValues: (keys) => Object.fromEntries(keys.map((k) => [k, params[k]])),
|
|
838
899
|
getView: view,
|
|
900
|
+
// On a narrow stage the transport bar has to cap its own width to stay
|
|
901
|
+
// clear of the bottom-right cluster, and under that cap its controls fall
|
|
902
|
+
// below the 44px tap target. The cube gives way instead — it is the
|
|
903
|
+
// reclaimable half of that cluster. Nothing here keys on the viewport
|
|
904
|
+
// width: a part with no animations, or one whose bar fits, keeps its cube
|
|
905
|
+
// at every size. syncViewcubeHidden and the viewcube itself are both
|
|
906
|
+
// declared above this point, so this can never fire into a hole.
|
|
907
|
+
onCrowded: (crowded) => {
|
|
908
|
+
cubeHiddenForCrowding = crowded;
|
|
909
|
+
syncViewcubeHidden();
|
|
910
|
+
},
|
|
839
911
|
});
|
|
840
912
|
if (animCtl) cleanup.defer(() => animCtl.detach());
|
|
841
913
|
|
|
@@ -944,6 +1016,11 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
944
1016
|
onInkChange: annotateMode.onInkChange,
|
|
945
1017
|
onModeChange: annotateMode.onModeChange,
|
|
946
1018
|
} : null,
|
|
1019
|
+
projection: {
|
|
1020
|
+
get: () => viewer.getProjection(),
|
|
1021
|
+
set: (mode) => viewer.setProjection(mode),
|
|
1022
|
+
onChange: (cb) => viewer.onProjectionChange(cb),
|
|
1023
|
+
},
|
|
947
1024
|
});
|
|
948
1025
|
} catch (error) {
|
|
949
1026
|
try {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Which declared gates a part actually has — the questions the oracle asks before
|
|
2
|
+
// deciding how hard to work. Kept out of verify.js so measure() can ask the same
|
|
3
|
+
// question without importing verify (which imports measure), and so there is ONE
|
|
4
|
+
// definition of "does this part gate on min wall": measure sizes its sample budget
|
|
5
|
+
// by it and verify decides whether to measure it at all. Two derivations that drift
|
|
6
|
+
// would put a coarse reading behind a real gate, which is exactly the trap verify's
|
|
7
|
+
// seeding block guards against.
|
|
8
|
+
//
|
|
9
|
+
// Every function here is TOTAL. A part whose profile name is unknown, or whose
|
|
10
|
+
// `expect` function throws, is reported as GATED — the conservative direction, since
|
|
11
|
+
// that is the full-resolution behaviour every part had before budgets existed. The
|
|
12
|
+
// real error surfaces from verify, which is where a reader can act on it.
|
|
13
|
+
import { resolveProfile } from "./dfm-profiles.js";
|
|
14
|
+
import { expandCases } from "./cases.js";
|
|
15
|
+
import { resolveParams } from "../part-model.js";
|
|
16
|
+
|
|
17
|
+
// `expect` may be a function of a case's resolved params, so the answer is a property
|
|
18
|
+
// of the EXPANDED cases, not of the raw spec — which means answering it costs a call
|
|
19
|
+
// to the PART'S OWN code, once per case. Both callers here are on hot paths (verify
|
|
20
|
+
// expands for its case loop; measure asks the gate question on every call, and verify
|
|
21
|
+
// calls measure once per case), so an unmemoized expansion would invoke a part's
|
|
22
|
+
// `expect` O(cases²) times per report. Memoized by part identity, and re-derived if
|
|
23
|
+
// the spec object behind that identity was swapped — the realistic mutation, and the
|
|
24
|
+
// one a bare WeakMap would serve staleness for.
|
|
25
|
+
const expansions = new WeakMap();
|
|
26
|
+
|
|
27
|
+
export function expandExpectations(part) {
|
|
28
|
+
const spec = part?.verify?.expect ?? {};
|
|
29
|
+
const hit = expansions.get(part);
|
|
30
|
+
if (hit && hit.spec === spec) return hit.expanded;
|
|
31
|
+
const expanded = typeof spec !== "function"
|
|
32
|
+
? expandCases(part).map((c) => ({ ...c, expect: spec }))
|
|
33
|
+
: expandCases(part).map((c) => {
|
|
34
|
+
const { p, d } = resolveParams(part, c.params);
|
|
35
|
+
return { ...c, expect: spec(p, d) ?? {} };
|
|
36
|
+
});
|
|
37
|
+
if (part && typeof part === "object") expansions.set(part, { spec, expanded });
|
|
38
|
+
return expanded;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function partGatesMinWall(part, { process, expanded } = {}) {
|
|
42
|
+
try {
|
|
43
|
+
const spec = process ?? part?.verify?.process;
|
|
44
|
+
if (spec && resolveProfile(spec)?.minWall != null) return true;
|
|
45
|
+
return (expanded ?? expandExpectations(part)).some(({ expect }) =>
|
|
46
|
+
Object.values(expect ?? {}).some((o) => o && typeof o === "object" && "minWall" in o));
|
|
47
|
+
} catch {
|
|
48
|
+
return true; // unresolvable → measure it properly and let verify report the error
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -3,7 +3,8 @@ import { cachedBVH } from "./bvh.js";
|
|
|
3
3
|
import { assemblyOverlaps } from "../assembly.js";
|
|
4
4
|
import { meshGaps, pairKey, CONTACT_EPS, GAP_THRESHOLD } from "./gaps.js";
|
|
5
5
|
import { bounds, meshArea, meshCentroid } from "./mesh.js";
|
|
6
|
-
import { minWall } from "./min-wall.js";
|
|
6
|
+
import { minWall, DIAGNOSTIC_SAMPLES } from "./min-wall.js";
|
|
7
|
+
import { partGatesMinWall } from "./gates.js";
|
|
7
8
|
|
|
8
9
|
const size = ({ min, max }) => [max[0] - min[0], max[1] - min[1], max[2] - min[2]];
|
|
9
10
|
const unionBounds = (list) => list.reduce(
|
|
@@ -40,13 +41,20 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
|
|
|
40
41
|
// the cache just fills it earlier. min-wall indexes exactly one mesh, so it is
|
|
41
42
|
// handed the resolved BVH rather than the Map.
|
|
42
43
|
const bvhCache = new Map();
|
|
44
|
+
// Sample budget for the min-wall pass. A part that declares a min-wall gate (a
|
|
45
|
+
// process profile or an `expect` mentioning it) gets the full resolution, because
|
|
46
|
+
// a gate's verdict rides on the reading. Everything else gets the diagnostic
|
|
47
|
+
// budget: min wall is the single most expensive thing the oracle does — one
|
|
48
|
+
// inward ray per sampled triangle plus the BVH those rays need — and on an
|
|
49
|
+
// ungated part it buys a fact nobody checks, at full price, on every agent edit.
|
|
50
|
+
const minWallSamples = partGatesMinWall(part) ? undefined : DIAGNOSTIC_SAMPLES;
|
|
43
51
|
const subBounds = [];
|
|
44
52
|
const subparts = built.map(({ name, solid, mesh }) => {
|
|
45
53
|
const b = bounds(mesh.positions);
|
|
46
54
|
subBounds.push(b);
|
|
47
55
|
// Resolved lazily and only when asked for: without min-wall, a single-sub-part
|
|
48
56
|
// view (no meshGaps) must still build no index at all.
|
|
49
|
-
const mw = opts.minWall ? minWall(mesh, { bvh: cachedBVH(mesh, bvhCache) }) : null;
|
|
57
|
+
const mw = opts.minWall ? minWall(mesh, { bvh: cachedBVH(mesh, bvhCache), maxSamples: minWallSamples }) : null;
|
|
50
58
|
const vol = solid.volume();
|
|
51
59
|
// Deviation-from-reference: only for a sub-part that declares `reference:
|
|
52
60
|
// "<import name>"` (Task 12 — the gate that holds a parametric rebuild to
|
|
@@ -96,7 +104,13 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
|
|
|
96
104
|
// so this reads on OCCT too. nearMisses = the issue-#29 signal: pairs that
|
|
97
105
|
// *almost* touch; overlapping pairs are excluded by name (a fully-contained
|
|
98
106
|
// sub-part has surface distance > 0 but is the overlap gate's business).
|
|
99
|
-
|
|
107
|
+
// `opts.gaps: false` is the quick lap's second half (see jobs.js): pair distances
|
|
108
|
+
// are the other ray-casting pass, and they and min-wall share the BVH, so skipping
|
|
109
|
+
// only one leaves the index build standing. The result is `undefined`, NEVER `[]`:
|
|
110
|
+
// pairGapChecks reads an empty table as "measured, and this pair has no distance"
|
|
111
|
+
// and fails a declared gate on it, while an absent table reads as no reading.
|
|
112
|
+
const measuredGaps = opts.gaps !== false;
|
|
113
|
+
const gaps = measuredGaps ? (built.length > 1 ? meshGaps(built, { bvhCache }) : []) : undefined;
|
|
100
114
|
|
|
101
115
|
// Rebuilds with the same kernel and cleans up at its end — every solid fact
|
|
102
116
|
// above is already read, so this is safe.
|
|
@@ -106,7 +120,7 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
|
|
|
106
120
|
|
|
107
121
|
const overlapping = new Set(overlaps.map((o) => pairKey(o.a, o.b)));
|
|
108
122
|
const gapThreshold = opts.gapThreshold ?? GAP_THRESHOLD;
|
|
109
|
-
const nearMisses = gaps.filter(
|
|
123
|
+
const nearMisses = (gaps ?? []).filter(
|
|
110
124
|
(g) => g.distance > CONTACT_EPS && g.distance < gapThreshold && !overlapping.has(pairKey(g.a, g.b)),
|
|
111
125
|
);
|
|
112
126
|
|
|
@@ -133,6 +147,9 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
|
|
|
133
147
|
// nothing measured it, which reads identically to "no reading available";
|
|
134
148
|
// verify's seeding rule turns on exactly this distinction (see verify.js).
|
|
135
149
|
measuredMinWall: !!opts.minWall,
|
|
150
|
+
// Companion stamp to measuredMinWall, and read the same way: whether the pass
|
|
151
|
+
// ran, said by the pass itself rather than claimed by whoever holds the result.
|
|
152
|
+
measuredGaps,
|
|
136
153
|
subparts,
|
|
137
154
|
aggregate,
|
|
138
155
|
overlaps,
|
|
@@ -33,6 +33,23 @@ import { buildBVH, readTriangleInto } from "./bvh.js";
|
|
|
33
33
|
// are the only ones that engage it. Override per call with `{ maxSamples }`.
|
|
34
34
|
const MAX_SAMPLES = 50_000;
|
|
35
35
|
|
|
36
|
+
// The budget for a min-wall reading NOTHING GATES ON — a fact the report carries so
|
|
37
|
+
// the model can notice a thin feature, rather than a number a declared minimum is
|
|
38
|
+
// checked against. A tenth the rays, and the reading is stamped `sampled` either way,
|
|
39
|
+
// so no consumer can mistake it for exact. measure() picks between the two by asking
|
|
40
|
+
// gates.js whether the part declares a min-wall gate; a part that does gets
|
|
41
|
+
// MAX_SAMPLES, unchanged.
|
|
42
|
+
//
|
|
43
|
+
// The win is real but bounded, and the bound is worth knowing before tuning this
|
|
44
|
+
// number: the RAYS get ten times cheaper, the BVH they cast into does not, and it is
|
|
45
|
+
// roughly half the pass. Measured on src/parts/screw.js (210k triangles, same code
|
|
46
|
+
// path, gated vs not): measure() 837 ms → 458 ms, reporting the identical 0.008 mm.
|
|
47
|
+
// Dropping the index too means not measuring min wall at all, which is the quick
|
|
48
|
+
// lap's business (see jobs.js), not this constant's.
|
|
49
|
+
const DIAGNOSTIC_SAMPLES = 5_000;
|
|
50
|
+
|
|
51
|
+
export { MAX_SAMPLES, DIAGNOSTIC_SAMPLES };
|
|
52
|
+
|
|
36
53
|
const gcd = (a, b) => { while (b) { const t = a % b; a = b; b = t; } return a; };
|
|
37
54
|
|
|
38
55
|
// Stride for the sampling walk: near n/φ and coprime to n, so stepping by it visits
|