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.
@@ -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: eased spherical
5
- // interpolation of {position, target} pairs about the (linearly moving) orbit
6
- // target, shortest-path in azimuth, clamped off the poles so OrbitControls
7
- // never gimbal-locks on a "top"/"bottom" cue. Pure math, no clock the viewer
8
- // feeds dt seconds into update() each frame and applies the returned pose.
9
- const POLE_EPS = 0.01;
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
- const sph = new THREE.Spherical().setFromVector3(off);
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() {
@@ -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
- .pf-float-tabs { top: 12px; left: 50%; transform: translateX(-50%); }
230
- .pf-float-viewbar { bottom: 12px; right: 12px; }
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
  }
@@ -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,
@@ -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. Measuring `{ minWall:
245
- // true }` here is what makes the seed usable by any verify run, min-wall
246
- // gated or not — the result says so itself (`measuredMinWall`), so this
247
- // call and the seed cannot drift apart.
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 ?? {}, { minWall: true, built });
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
- const dist = viewer.camera.position.distanceTo(_gp);
323
- const wpp = worldPerPx(dist, viewer.camera.fov ?? 45, h);
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;
@@ -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
- const gaps = built.length > 1 ? meshGaps(built, { bvhCache }) : [];
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