partforge 0.87.0 → 0.89.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.
@@ -278,7 +278,8 @@ button.action:disabled { opacity: .5; cursor: default; }
278
278
  /* keyboard focus ring shared across the panel's interactive controls */
279
279
  .seg button:focus-visible, select.preset:focus-visible, .dl-row button:focus-visible,
280
280
  button.action:focus-visible, .adv-toggle:focus-visible, .sec-title:focus-visible, #viewbar button:focus-visible,
281
- .pf-viewcube-toggle:focus-visible, .pf-float-rail-toggle:focus-visible {
281
+ .pf-view-select:focus-visible,
282
+ .pf-viewcube-toggle:focus-visible, .pf-float-rail-toggle:focus-visible, .pf-sketch-toolbar button:focus-visible {
282
283
  outline: none;
283
284
  box-shadow: 0 0 0 3px color-mix(in oklab, var(--pf-accent) 35%, transparent);
284
285
  }
@@ -299,14 +300,65 @@ button.action:focus-visible, .adv-toggle:focus-visible, .sec-title:focus-visible
299
300
  border-radius: var(--pf-radius-pill);
300
301
  box-shadow: var(--pf-shadow-float);
301
302
  }
302
- /* nowrap: `.seg button` is `flex: 1` (equal-width columns), and a part with
303
- two views where one label is long (e.g. import-demo's "Reference overlay")
304
- would otherwise wrap onto a second line, making this pill taller than the
305
- single fixed height debug-overlay.js positions itself below (see its
306
- comment) — the overlay would then overlap the now-two-row tabs. Letting the
307
- label force the column wider instead keeps the pill single-row regardless
308
- of label length. */
309
- #topbar .seg button { min-width: 70px; padding: 7px 10px; white-space: nowrap; }
303
+ /* flex: the panel's `.seg button` is `flex: 1 1 0%` — equal-width columns,
304
+ which is right for a radio group in the rail and wrong here. With a zero
305
+ basis every tab gets the SAME width, so the widest label does not get to
306
+ size its own column: import-demo's two tabs came out 106px each while
307
+ "Reference overlay" needs ~127, and the text simply drew outside the pill's
308
+ rounded background (measured in Chromium: scrollWidth 238 against
309
+ clientWidth 221). Sizing each tab to its own content is what the nowrap note
310
+ below always claimed happened, and is also what makes the pill's natural
311
+ width honest — view-tabs.js's collapse measures exactly this.
312
+
313
+ nowrap: with content-sized columns a long label would otherwise wrap onto a
314
+ second line, making this pill taller than the single fixed height
315
+ debug-overlay.js positions itself below (see its comment) — the overlay
316
+ would then overlap the now-two-row tabs. Letting the label force the column
317
+ wider instead keeps the pill single-row regardless of label length, and
318
+ hands the overflow to the collapse rather than to a second row.
319
+
320
+ view-tabs.js ALSO sets nowrap inline on every button it generates, and that is
321
+ the copy that actually protects the rule: a host restyling the pill from
322
+ scratch (partforge-cloud's `#viewer #part button`, with no `.seg` class)
323
+ never sees this selector, and long labels wrapped there for exactly that
324
+ reason. This one still covers hand-written markup on partforge's own pages. */
325
+ #topbar .seg button { flex: 0 0 auto; min-width: 70px; padding: 7px 10px; white-space: nowrap; }
326
+
327
+ /* The tabs' narrow-stage fallback. When view-tabs.js measures the segmented
328
+ pill as wider than the stage's top-centre slot it flips `data-pf-tabs` to
329
+ "menu", and this dropdown stands in for the buttons.
330
+
331
+ Ungated on `.seg` / `#topbar` for the same reason #viewbar's chrome below is:
332
+ partforge-cloud dresses the pill with its own `#viewer #part` rules and
333
+ neither of those selectors reaches it — and it is the host that most needs
334
+ this, since its stage is a column beside an editor rather than a full window.
335
+
336
+ 27px tall, which is what the buttons it replaces measure (11px mono + 7px of
337
+ padding top and bottom). Matched rather than approximated so the pill's own
338
+ height does not change as it swaps — it sits at the stage's top centre, where
339
+ a 1px jump on every resize past the threshold would read as a twitch — and so
340
+ the collapsed pill stays under the fixed height debug-overlay.js parks itself
341
+ below. Capped in width so a long label ellipsizes rather than pushing the
342
+ pill back out to the width that collapsed it in the first place. */
343
+ .pf-view-select {
344
+ box-sizing: border-box;
345
+ height: 27px; max-width: min(220px, 100%);
346
+ padding: 0 24px 0 9px;
347
+ border: 0; border-radius: var(--pf-radius-control);
348
+ background: transparent; color: var(--pf-text-2);
349
+ font-family: var(--pf-mono); font-size: 11px; letter-spacing: 0.02em;
350
+ cursor: pointer; appearance: none; text-overflow: ellipsis;
351
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='7'%3E%3Cpolygon points='0,0 8,0 4,7' fill='%238b8b94'/%3E%3C/svg%3E");
352
+ background-repeat: no-repeat;
353
+ background-position: right 9px center;
354
+ }
355
+ /* `hidden` is the switch view-tabs.js flips between the two representations,
356
+ and a host that gives tab buttons a `display` of their own would beat the
357
+ UA's [hidden] rule — the trap `#viewbar button[hidden]` below already guards
358
+ against. Said again here against the layout state, which lives on the
359
+ element itself and so holds whatever a host does to the pill around it. */
360
+ [data-pf-tabs="menu"] > button[data-part] { display: none; }
361
+ [data-pf-tabs="segmented"] > .pf-view-select { display: none; }
310
362
 
311
363
  /* viewer controls. APPEARANCE is ungated: partforge-cloud re-anchors #viewbar's
312
364
  position in sandbox.css but inherits this pill chrome, so gating it on a class
@@ -318,6 +370,10 @@ button.action:focus-visible, .adv-toggle:focus-visible, .sec-title:focus-visible
318
370
  border-radius: var(--pf-radius-pill);
319
371
  box-shadow: var(--pf-shadow-float);
320
372
  }
373
+ /* Sketch mode hides the whole bar while it owns the stage (mount.js) — the
374
+ display: flex above is author-origin and beats the UA's [hidden] rule, the
375
+ same trap the buttons' own [hidden] rule below already guards against. */
376
+ #viewbar[hidden] { display: none; }
321
377
  #viewbar:not(.pf-float-viewbar) { position: fixed; top: 12px; right: 12px; z-index: 15; }
322
378
  #viewbar button {
323
379
  width: 34px; height: 34px; border: 0; border-radius: var(--pf-radius-control);
@@ -337,25 +393,68 @@ button.action:focus-visible, .adv-toggle:focus-visible, .sec-title:focus-visible
337
393
  #viewbar .pf-measure-actions { display: flex; gap: 4px; }
338
394
  #viewbar .pf-measure-actions[hidden] { display: none; }
339
395
  #viewbar .pf-measure-actions button { width: auto; min-width: 56px; padding: 0 8px; }
340
- #viewbar .pf-annotate-actions { display: flex; gap: 4px; }
341
- #viewbar .pf-annotate-actions[hidden] { display: none; }
342
- #viewbar .pf-annotate-actions button { width: auto; min-width: 56px; padding: 0 8px; }
343
- /* Annotate's actions row has three buttons (Undo/Clear/Send) against
344
- cutaway's/measure's two, so it is the first to overflow the stage's left
345
- edge as the viewport narrows — the full-size pill (5 icon buttons + this
346
- row, ~374px) already exceeds a 375-390px phone's usable width (viewport
347
- minus the stage's 12px margins on both sides) before the shared 360px
348
- rule below ever engages. Shrink only this row here; the icon buttons and
349
- the other two action rows still have room down to 360px. */
350
- @media (max-width: 430px) {
351
- #viewbar .pf-annotate-actions { gap: 3px; }
352
- #viewbar .pf-annotate-actions button { min-width: 40px; padding: 0 5px; font-size: 11px; }
353
- }
354
396
  #viewbar button:disabled { opacity: .38; cursor: not-allowed; }
355
397
  #viewbar button:disabled:hover { color: var(--pf-muted-2); background: transparent; }
356
398
  #viewbar button:hover { color: var(--pf-text); background: var(--pf-surface-2); }
357
399
  #viewbar button.on { background: var(--pf-accent); color: var(--pf-on-accent); }
358
400
 
401
+ /* Sketch-mode toolbar: the #viewbar pill idiom, top-centre, owning the mode
402
+ while the viewbar itself is hidden (mount toggles both).
403
+
404
+ `left: 50%` is a shrink-to-fit trap for an absolutely positioned element:
405
+ its auto width shrink-wraps against the space between `left` and the
406
+ containing block's right edge, which here is only HALF the stage, not the
407
+ whole stage — so the row wraps (stranding the close button on its own
408
+ centred second line, on top of .pf-sketch-hint) well before it actually
409
+ needs to. `width: max-content` opts back into sizing to the row's
410
+ preferred (unwrapped) width; `max-width` below still caps that on stages
411
+ too narrow for the full row, so genuine wrapping on narrow stages still
412
+ works. */
413
+ .pf-sketch-toolbar {
414
+ position: absolute; top: 12px; left: 50%; transform: translateX(-50%);
415
+ display: flex; align-items: center; gap: 4px; padding: 4px;
416
+ background: var(--pf-surface); border: 1px solid var(--pf-border);
417
+ border-radius: var(--pf-radius-pill); box-shadow: var(--pf-shadow-float);
418
+ z-index: 20; width: max-content; max-width: calc(100% - 16px); flex-wrap: wrap; justify-content: center;
419
+ }
420
+ .pf-sketch-toolbar[hidden] { display: none; }
421
+ .pf-sketch-toolbar .sep { width: 1px; align-self: stretch; margin: 4px 2px; background: var(--pf-border); }
422
+ .pf-sketch-toolbar button {
423
+ width: 34px; height: 34px; border: 0; border-radius: var(--pf-radius-control);
424
+ background: transparent; color: var(--pf-muted-2); cursor: pointer;
425
+ display: flex; align-items: center; justify-content: center;
426
+ }
427
+ .pf-sketch-toolbar button[data-action="send"] { width: auto; min-width: 56px; padding: 0 10px; }
428
+ .pf-sketch-toolbar button:hover { color: var(--pf-text-2); background: var(--pf-surface-2); }
429
+ .pf-sketch-toolbar button:disabled { opacity: .35; cursor: default; background: transparent; color: var(--pf-muted); }
430
+ .pf-sketch-toolbar button.on { background: var(--pf-accent); color: var(--pf-on-accent); }
431
+ .pf-sketch-toolbar .pf-swatch { width: 26px; height: 26px; margin: 4px 1px; border-radius: 50%; }
432
+ .pf-sketch-toolbar .pf-swatch::before {
433
+ content: ""; width: 14px; height: 14px; border-radius: 50%;
434
+ background: var(--sw); box-shadow: 0 0 0 2px color-mix(in oklab, var(--sw) 25%, transparent);
435
+ }
436
+ .pf-sketch-toolbar .pf-swatch.on { background: var(--pf-surface-2); }
437
+ .pf-sketch-toolbar .pf-swatch.on::before { box-shadow: 0 0 0 2.5px var(--pf-bg), 0 0 0 4.5px var(--sw); }
438
+ .pf-sketch-hint {
439
+ position: absolute; top: 58px; left: 50%; transform: translateX(-50%);
440
+ font-family: var(--pf-mono); font-size: 10px; letter-spacing: .04em;
441
+ color: var(--pf-hint); z-index: 19; pointer-events: none; white-space: nowrap;
442
+ }
443
+ .pf-sketch-hint[hidden] { display: none; }
444
+ /* hand-tool cursors on the ink canvas */
445
+ .pf-ink-canvas.hand { cursor: default; }
446
+ .pf-ink-canvas.hand.over { cursor: grab; }
447
+ .pf-ink-canvas.hand.handle { cursor: crosshair; }
448
+ /* Pointer-followers are CSS cursors, not canvas drawings: anything that must
449
+ track the pointer per-mousemove would force a full-canvas redraw per event
450
+ (annotate-mode used to do exactly that for the rotate glyph and eraser
451
+ ring, and it read as lag). The compositor renders cursors for free. */
452
+ .pf-ink-canvas.hand.rotate { cursor: url("data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2224%22 height=%2224%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cg stroke=%22%23000%22 stroke-width=%224.5%22 opacity=%22.55%22%3E%3Cpath d=%22M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8%22/%3E%3Cpath d=%22M21 3v5h-5%22/%3E%3C/g%3E%3Cg stroke=%22%23fff%22 stroke-width=%222%22%3E%3Cpath d=%22M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8%22/%3E%3Cpath d=%22M21 3v5h-5%22/%3E%3C/g%3E%3C/svg%3E") 12 12, alias; }
453
+ .pf-ink-canvas.hand.dragging { cursor: grabbing; }
454
+ /* The ring's 16px radius mirrors annotate-mode.js's ERASER_PX — keep them
455
+ in step so the cursor shows the true brush footprint. */
456
+ .pf-ink-canvas.erasing { cursor: url("data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2236%22 height=%2236%22 viewBox=%220 0 36 36%22 fill=%22none%22%3E%3Ccircle cx=%2218%22 cy=%2218%22 r=%2216%22 stroke=%22%23000%22 stroke-width=%223.5%22 opacity=%22.55%22/%3E%3Ccircle cx=%2218%22 cy=%2218%22 r=%2216%22 stroke=%22%23fff%22 stroke-width=%221.5%22/%3E%3C/svg%3E") 18 18, crosshair; }
457
+
359
458
  /* ---- the rail toggle: a bare floating icon at the stage's top right --------
360
459
  APPEARANCE only (placement lives in chrome.css, per the split at the top of
361
460
  this file). Through 2026-08-20 this was the last button in #viewbar's pill;
@@ -482,12 +581,8 @@ button.action:focus-visible, .adv-toggle:focus-visible, .sec-title:focus-visible
482
581
  @media (max-width: 360px) {
483
582
  #viewbar { gap: 3px; }
484
583
  #viewbar button { width: 30px; height: 30px; font-size: 13px; }
485
- #viewbar .pf-cutaway-actions, #viewbar .pf-measure-actions, #viewbar .pf-annotate-actions { gap: 3px; }
486
- #viewbar .pf-cutaway-actions button, #viewbar .pf-measure-actions button, #viewbar .pf-annotate-actions button { min-width: 44px; padding: 0 6px; }
487
- /* Annotate's three-button row (Undo/Clear/Send) is wider than cutaway's or
488
- measure's two-button rows at the shared size above, so it still clips the
489
- bar's left edge at 320px — shrink it further than the shared rule. */
490
- #viewbar .pf-annotate-actions button { min-width: 38px; padding: 0 4px; font-size: 10px; }
584
+ #viewbar .pf-cutaway-actions, #viewbar .pf-measure-actions { gap: 3px; }
585
+ #viewbar .pf-cutaway-actions button, #viewbar .pf-measure-actions button { min-width: 44px; padding: 0 6px; }
491
586
  }
492
587
 
493
588
  /* ---- measurement mode -----------------------------------------------------
@@ -469,8 +469,10 @@
469
469
 
470
470
  /* ---- annotation ink layer: a transparent 2D canvas over the viewer --------
471
471
  Shown only while annotation mode is on. It deliberately owns pointer events
472
- while visible — that is what freezes orbit/pan/zoom during drawing. Below
473
- the viewbar (z 15) so Undo/Clear/Send stay clickable. */
472
+ while visible — that is what freezes orbit/pan/zoom during drawing. #viewbar
473
+ itself is hidden for the duration (mount.js toggles both), replaced by
474
+ .pf-sketch-toolbar (app.css, z 20) which now holds Undo/Clear/Send; this
475
+ layer sits below that at z 10 so the toolbar stays clickable over it. */
474
476
  .pf-ink-canvas {
475
477
  position: absolute;
476
478
  inset: 0;
@@ -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 { attachSketchToolbar } from "./annotate/sketch-toolbar.js";
33
34
  import { attachViewcubeControls } from "./viewcube/viewcube-controls.js";
34
35
 
35
36
  // The mount handle, factored out so its shape is unit-testable without booting
@@ -245,12 +246,12 @@ function createCleanupStack() {
245
246
  // // KB of base64 apiece, so a host should not assume this
246
247
  // // payload is small, only that it is bounded.
247
248
  // annotateSend: "viewbar" | "host" // who owns the Send affordance. "viewbar" (default) puts
248
- // // Send beside Undo/Clear in the annotate actions row.
249
- // // "host" drops it and leaves Undo/Clear: the host draws
250
- // // its own send control — e.g. a composer that pairs the
251
- // // sketch with a typed message — and calls
252
- // // runtime.annotate.send() itself. Ignored without
253
- // // onAnnotationSend (there is no button to place).
249
+ // // Send in the sketch toolbar alongside the other tools.
250
+ // // "host" drops it: the host draws its own send control —
251
+ // // e.g. a composer that pairs the sketch with a typed
252
+ // // message — and calls runtime.annotate.send() itself.
253
+ // // Ignored without onAnnotationSend (there is no toolbar
254
+ // // to place it in).
254
255
  // Every `elements` entry defaults to the legacy global-ID lookup (below), resolved
255
256
  // exactly once here — submodules take element refs and never query the document.
256
257
  // `container`/`controls` remain as deprecated aliases for elements.viewer/.controls.
@@ -431,8 +432,30 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
431
432
  }
432
433
  const annotateChrome = attachAnnotateControls(viewer, annotateMode, {
433
434
  annotate: els.chrome.annotate,
434
- }, { tooltip, escapeScope: els.viewer, send: annotateSend });
435
+ }, { tooltip, escapeScope: els.viewer });
435
436
  cleanup.defer(() => annotateChrome.detach());
437
+ // Sketch owns the top of the stage: the toolbar replaces the viewbar while
438
+ // the mode is on (spec 2026-08-27). Restore honors whatever hidden state
439
+ // the host had set before entering. Attached only when annotateMode
440
+ // exists — a mode-less mount (no onAnnotationSend) has nothing for the
441
+ // toolbar to drive.
442
+ if (annotateMode) {
443
+ const sketchToolbar = attachSketchToolbar(annotateMode, {
444
+ stage: els.viewer, tooltip, send: annotateSend,
445
+ });
446
+ cleanup.defer(() => sketchToolbar.detach());
447
+ const viewbarForSketch = els.viewer.querySelector("#viewbar");
448
+ let viewbarWasHidden = false;
449
+ cleanup.defer(annotateMode.onModeChange(() => {
450
+ if (!viewbarForSketch) return;
451
+ if (annotateMode.isEnabled()) {
452
+ viewbarWasHidden = viewbarForSketch.hidden;
453
+ viewbarForSketch.hidden = true;
454
+ } else {
455
+ viewbarForSketch.hidden = viewbarWasHidden;
456
+ }
457
+ }));
458
+ }
436
459
  // Orientation cube + projection toggle. Generated chrome — no host markup
437
460
  // declares it, so an embedder gets it for free. Restored BEFORE any framing
438
461
  // happens so a reload into ortho frames once instead of framing in
@@ -0,0 +1,92 @@
1
+ // Reconstruct pick rays from a sketch annotation payload (ANNOTATION_VERSION 3)
2
+ // and intersect them with planes — the consumer-side half of the payload's
3
+ // camera block (spec: docs/superpowers/specs/2026-08-28-annotation-ray-design.md).
4
+ //
5
+ // Pure vector math on arrays: no three, no DOM, no node:, no imports at all
6
+ // (worker-layering holds this folder to that). The math mirrors
7
+ // THREE.Raycaster.setFromCamera exactly, with one deliberate normalization:
8
+ // an orthographic ray's origin sits on the plane through the camera POSITION
9
+ // (three puts it on the near plane) — the same canonicalization annotate-mode
10
+ // applies to the rays it embeds per anchor, so embedded and reconstructed rays
11
+ // are definitionally identical. Two stated caveats: perspective assumes
12
+ // camera zoom 1 (the viewer dollies perspective cameras, never zooms them;
13
+ // orthoHeight already folds zoom in at send time), and payload numbers are
14
+ // rounded to 4 decimals, so reconstruction agrees with the live raycaster to
15
+ // ~1e-4 relative — sub-micrometre at part scale.
16
+
17
+ const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
18
+ const add3 = (a, b, c) => [a[0] + b[0] + c[0], a[1] + b[1] + c[1], a[2] + b[2] + c[2]];
19
+ const scale = (a, s) => [a[0] * s, a[1] * s, a[2] * s];
20
+ const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
21
+ const cross = (a, b) => [
22
+ a[1] * b[2] - a[2] * b[1],
23
+ a[2] * b[0] - a[0] * b[2],
24
+ a[0] * b[1] - a[1] * b[0],
25
+ ];
26
+ const norm = (a) => scale(a, 1 / Math.hypot(a[0], a[1], a[2]));
27
+
28
+ // screen: [sx, sy] in the payload's anchor screen frame (nominally 0..1, y
29
+ // down), or any object carrying such a `screen` array (an anchor passes
30
+ // directly). Off-viewport values (e.g. x = 1.03) are legal: the hand tool can
31
+ // move a committed shape partly off-stage, and the projection math is
32
+ // well-defined outside [0, 1] (three's Raycaster extrapolates the same way).
33
+ export function annotationRay(payload, screen, { frame = "parts" } = {}) {
34
+ if (frame !== "parts" && frame !== "world") {
35
+ throw new Error('annotationRay: frame must be "parts" or "world"');
36
+ }
37
+ const s = Array.isArray(screen) ? screen : screen?.screen;
38
+ if (!Array.isArray(s) || s.length !== 2 || !s.every((v) => Number.isFinite(v))) {
39
+ throw new Error("annotationRay: screen must be [x, y] finite numbers");
40
+ }
41
+ if (frame === "parts" && payload?.camera?.parts === null) {
42
+ throw new Error("annotationRay: payload.camera.parts is null — the sketch was sent with no meshes (use { frame: \"world\" })");
43
+ }
44
+ const cam = payload?.camera?.[frame];
45
+ const aspect = payload?.viewport?.aspect;
46
+ if (!cam?.pos || !cam.target || !cam.up || !Number.isFinite(aspect)) {
47
+ throw new Error("annotationRay: payload has no camera/viewport block");
48
+ }
49
+ // Basis orthonormalized the way three's lookAt does it: `up` is a hint, not
50
+ // trusted to be orthogonal to forward.
51
+ const forward = norm(sub(cam.target, cam.pos));
52
+ const right = norm(cross(forward, cam.up));
53
+ const trueUp = cross(right, forward);
54
+ const nx = 2 * s[0] - 1;
55
+ const ny = 1 - 2 * s[1];
56
+ if (cam.projection === "orthographic") {
57
+ const halfH = cam.orthoHeight / 2;
58
+ return {
59
+ origin: add3(cam.pos, scale(right, nx * halfH * aspect), scale(trueUp, ny * halfH)),
60
+ dir: forward,
61
+ };
62
+ }
63
+ const t = Math.tan((cam.fov * Math.PI) / 360); // vertical fov, degrees
64
+ return {
65
+ origin: [cam.pos[0], cam.pos[1], cam.pos[2]],
66
+ dir: norm(add3(forward, scale(right, nx * t * aspect), scale(trueUp, ny * t))),
67
+ };
68
+ }
69
+
70
+ const PLANES = {
71
+ xy: { point: [0, 0, 0], normal: [0, 0, 1] },
72
+ yz: { point: [0, 0, 0], normal: [1, 0, 0] },
73
+ zx: { point: [0, 0, 0], normal: [0, 1, 0] },
74
+ };
75
+
76
+ // Miss semantics match the payload's `hit: null`: parallel rays and
77
+ // intersections at/behind the origin return null rather than throwing. `t` is
78
+ // in units of |dir| (unit for payload/annotationRay rays).
79
+ export function rayPlane(ray, plane) {
80
+ if (!Array.isArray(ray?.origin) || !Array.isArray(ray?.dir)) {
81
+ throw new Error("rayPlane: ray must be {origin, dir}");
82
+ }
83
+ const p = typeof plane === "string" ? PLANES[plane] : plane;
84
+ if (!Array.isArray(p?.point) || !Array.isArray(p?.normal)) {
85
+ throw new Error('rayPlane: plane must be {point, normal} or "xy"|"yz"|"zx"');
86
+ }
87
+ const denom = dot(ray.dir, p.normal);
88
+ if (Math.abs(denom) < 1e-9) return null;
89
+ const t = dot(sub(p.point, ray.origin), p.normal) / denom;
90
+ if (t <= 1e-6) return null;
91
+ return { point: add3(ray.origin, scale(ray.dir, t), [0, 0, 0]), t };
92
+ }
@@ -7,10 +7,89 @@ import { loadView, saveView } from "./view-state.js";
7
7
  // hand-wrote. Which tab opens is resolveDefaultView's call, not key order. The
8
8
  // active tab then persists per part for the rest of the browser session, so a
9
9
  // Vite dev reload doesn't throw you back mid-edit.
10
- export function createViewTabs(el, part, { onChange }) {
10
+ //
11
+ // The bar has TWO representations of the same choice, and both exist from the
12
+ // start: the segmented buttons, and a dropdown that takes over when the pill no
13
+ // longer fits the stage's top-centre slot. Which one shows is `data-pf-tabs` on
14
+ // the element, kept current by the measurement below. They are built together
15
+ // and switched together rather than rebuilt on each swap, so a click and a
16
+ // dropdown change are the same commit path and neither can drift from the other.
17
+
18
+ // The slot must clear the pill by this much before the buttons come back. Purely
19
+ // an anti-flap margin: a pill whose natural width lands within a subpixel of the
20
+ // slot would otherwise swap layouts on every ResizeObserver notification.
21
+ const EXPAND_MARGIN = 2;
22
+ // chrome.css's `.pf-float-tabs { top: 12px; left: 50% }` inset — the margin the
23
+ // bar keeps from the stage edge when nothing else competes for the corner.
24
+ const STAGE_MARGIN = 12;
25
+ // Breathing room between the pill's edge and the rail toggle it must not reach.
26
+ const TOGGLE_GAP = 8;
27
+
28
+ // Which representation to show. Pure, and deliberately a function of the pill's
29
+ // NATURAL width rather than its current one — see `natural` below for why that
30
+ // distinction is what stops the swap oscillating.
31
+ //
32
+ // A non-positive measurement is the ABSENCE of a reading, not a claim that
33
+ // nothing fits: happy-dom reports zeros for every box, and so does a real
34
+ // browser before first layout. Segmented is the honest fallback — it is what
35
+ // the bar looked like before this measurement existed.
36
+ export function pickLayout({ natural, available, collapsed }) {
37
+ if (!(natural > 0) || !(available > 0)) return "segmented";
38
+ if (collapsed) return available >= natural + EXPAND_MARGIN ? "segmented" : "menu";
39
+ return natural > available ? "menu" : "segmented";
40
+ }
41
+
42
+ // How much of each stage edge is spoken for. Doubled by the caller because the
43
+ // pill is centre-anchored: the tighter side governs both halves. Derived from
44
+ // the rail toggle's measured box rather than from a second copy of its offsets,
45
+ // the same way animation-controls.js derives the view cube's claim from the
46
+ // viewbar's — retune chrome.css and this follows. The toggle is `[hidden]`
47
+ // below the narrow breakpoint, where the stage margin stands alone.
48
+ function sideClearance(stage, stageRect) {
49
+ const toggle = stage?.querySelector?.(".pf-float-rail-toggle");
50
+ if (!toggle || toggle.hidden || !stageRect) return STAGE_MARGIN;
51
+ const rect = toggle.getBoundingClientRect?.();
52
+ if (!(rect?.width > 0)) return STAGE_MARGIN;
53
+ return Math.max(STAGE_MARGIN, stageRect.right - rect.left + TOGGLE_GAP);
54
+ }
55
+
56
+ // `segWidth` is what the pill measures RIGHT NOW, which is near nothing once the
57
+ // buttons are hidden. Reporting it raw rather than smoothing it here keeps this
58
+ // a plain reading; remembering the last meaningful one is the orchestrator's job.
59
+ function domMeasure(el) {
60
+ return () => {
61
+ const stage = el.closest?.(".pf-stage") ?? el.offsetParent ?? el.ownerDocument?.documentElement;
62
+ const stageRect = stage?.getBoundingClientRect?.() ?? null;
63
+ const stageWidth = stageRect?.width || stage?.clientWidth || 0;
64
+ return {
65
+ segWidth: el.scrollWidth || 0,
66
+ available: stageWidth ? stageWidth - 2 * sideClearance(stage, stageRect) : 0,
67
+ };
68
+ };
69
+ }
70
+
71
+ // Built from the buttons rather than from part.views so a page with hand-written
72
+ // markup gets a working dropdown too — the buttons are the one description of
73
+ // the choice that both paths share. Option text goes through textContent for the
74
+ // same reason the buttons do: view labels are untrusted data.
75
+ function buildSelect(doc, tabs) {
76
+ const select = doc.createElement("select");
77
+ select.className = "pf-view-select";
78
+ select.setAttribute("aria-label", "View");
79
+ for (const btn of tabs) {
80
+ const option = doc.createElement("option");
81
+ option.value = btn.dataset.part;
82
+ option.textContent = btn.textContent;
83
+ select.append(option);
84
+ }
85
+ return select;
86
+ }
87
+
88
+ export function createViewTabs(el, part, { onChange, measure } = {}) {
11
89
  const generated = !!(el && part.views);
12
90
  const partKey = part?.meta?.title ?? "";
13
91
  const resolved = resolveDefaultView(part);
92
+ const doc = el.ownerDocument ?? document;
14
93
  if (generated) {
15
94
  // Built node-by-node with textContent/dataset rather than an innerHTML
16
95
  // template — view keys and labels come from the part, which is untrusted
@@ -20,32 +99,113 @@ export function createViewTabs(el, part, { onChange }) {
20
99
  const btn = document.createElement("button");
21
100
  btn.dataset.part = key;
22
101
  btn.textContent = v?.label ?? key;
102
+ // Inline, not a stylesheet rule, because this one is load-bearing rather
103
+ // than decorative and a host can drop a stylesheet rule by accident.
104
+ // partforge-cloud restyles the pill from scratch (`#viewer #part button`,
105
+ // no `.seg` class), which is exactly how app.css's `white-space: nowrap`
106
+ // stopped applying there and let long labels wrap onto a second line. A
107
+ // wrapping label makes the pill NARROWER, not wider, so the collapse
108
+ // below would never trip — the overflow it is meant to catch would show
109
+ // up as a two-row pill instead. On the element, it is out of reach.
110
+ btn.style.whiteSpace = "nowrap";
23
111
  if (key === resolved) btn.classList.add("on");
24
112
  return btn;
25
113
  }));
26
114
  }
27
115
 
28
- const setActive = (btn) => { for (const b of el.children) b.classList.toggle("on", b === btn); };
116
+ const buttons = () => [...el.querySelectorAll("button[data-part]")];
117
+ const tabs = buttons();
118
+ // Below two tabs there is nothing to choose, and an empty pill must stay
119
+ // empty: partforge-cloud hides the bar with `#viewer #part:empty`, so a part
120
+ // with no views would otherwise gain a one-option dropdown and a visible pill.
121
+ const select = tabs.length >= 2 ? buildSelect(doc, tabs) : null;
122
+ if (select) el.append(select);
123
+
124
+ const setActive = (btn) => {
125
+ for (const b of buttons()) b.classList.toggle("on", b === btn);
126
+ if (select) select.value = btn.dataset.part;
127
+ };
29
128
 
30
129
  // Initial view: the session-saved one if it still matches a tab, else the active
31
130
  // button — the resolved default for a generated bar, or whatever the page's own
32
131
  // markup marked `on` for a hand-written one.
33
132
  const defaultView = el.querySelector("button.on")?.dataset.part ?? el.querySelector("button")?.dataset.part;
34
133
  const saved = loadView(partKey);
35
- const savedBtn = saved ? [...el.querySelectorAll("button[data-part]")].find((b) => b.dataset.part === saved) : null;
134
+ const savedBtn = saved ? tabs.find((b) => b.dataset.part === saved) : null;
36
135
  let view = savedBtn ? saved : defaultView;
37
- if (savedBtn) setActive(savedBtn);
136
+ // Unconditional now (it used to run only for a restored view): the dropdown
137
+ // has to open showing the same tab the buttons do, and for a generated bar
138
+ // the button half of this is the no-op it always was.
139
+ const activeBtn = savedBtn ?? tabs.find((b) => b.dataset.part === view);
140
+ if (activeBtn) setActive(activeBtn);
38
141
 
39
- const onClick = (e) => {
40
- const btn = e.target.closest("button[data-part]");
41
- if (!btn) return;
142
+ const commit = (btn) => {
42
143
  view = btn.dataset.part;
43
144
  saveView(partKey, view);
44
145
  setActive(btn);
45
146
  onChange(view);
46
147
  };
148
+
149
+ const onClick = (e) => {
150
+ const btn = e.target.closest("button[data-part]");
151
+ if (!btn) return;
152
+ commit(btn);
153
+ };
47
154
  el.addEventListener("click", onClick);
48
155
 
156
+ const onSelectChange = () => {
157
+ const btn = buttons().find((b) => b.dataset.part === select.value);
158
+ if (btn) commit(btn);
159
+ };
160
+ select?.addEventListener("change", onSelectChange);
161
+
162
+ // ---- layout: which representation is on screen --------------------------
163
+ let detached = false;
164
+ let mode = null;
165
+ // The pill's width AS IF THE BUTTONS WERE SHOWING — the fixed point the swap
166
+ // turns on, and the same trick animation-controls.js's nominalClusterRect
167
+ // plays for the view cube. Re-reading the live pill while collapsed would
168
+ // measure the dropdown instead, decide the buttons fit, expand, overflow, and
169
+ // collapse again: two frames per cycle, on screen as a flickering bar. This
170
+ // value cannot change while collapsed, so the collapsed state is stable.
171
+ // Caching is exact rather than approximate here because the buttons are
172
+ // content-sized (`min-width` + padding + nowrap, no percentages), so their
173
+ // natural width does not depend on the viewport at all.
174
+ let natural = 0;
175
+ const read = measure ?? domMeasure(el);
176
+
177
+ const setMode = (next) => {
178
+ if (next === mode) return;
179
+ mode = next;
180
+ el.dataset.pfTabs = next;
181
+ const menu = next === "menu";
182
+ for (const b of buttons()) b.hidden = menu;
183
+ if (select) select.hidden = !menu;
184
+ };
185
+
186
+ const relayout = () => {
187
+ if (detached) return;
188
+ if (!select) { setMode("segmented"); return; }
189
+ const { segWidth, available } = read();
190
+ if (mode !== "menu" && segWidth > 0) natural = segWidth;
191
+ setMode(pickLayout({ natural, available, collapsed: mode === "menu" }));
192
+ };
193
+ setMode("segmented"); // measure from the state the cache is only valid in
194
+ relayout();
195
+
196
+ // ResizeObserver is the precise trigger; the resize listener is the belt that
197
+ // also fires where it is absent, mirroring animation-controls.js's placement
198
+ // pair. Both land on the same idempotent relayout, so a doubled notification
199
+ // costs one measurement and no DOM write.
200
+ const win = doc.defaultView ?? globalThis;
201
+ win.addEventListener?.("resize", relayout);
202
+ const stage = el.closest?.(".pf-stage") ?? el.parentElement;
203
+ const observer = typeof win.ResizeObserver === "function" ? new win.ResizeObserver(relayout) : null;
204
+ if (observer && stage) observer.observe(stage);
205
+ // A webfont swap changes every label's width, and so the pill's natural width,
206
+ // after the first layout the measurement above ran against.
207
+ doc.fonts?.ready?.then(relayout).catch(() => {});
208
+
49
209
  return {
50
210
  current: () => view,
51
211
  // Programmatic switch — the click path without the click. Used by an
@@ -53,17 +213,21 @@ export function createViewTabs(el, part, { onChange }) {
53
213
  // Returns false for a name that isn't a tab so callers can validate.
54
214
  select: (name) => {
55
215
  if (name === view) return true; // already active — nothing to do
56
- const btn = [...el.querySelectorAll("button[data-part]")].find((b) => b.dataset.part === name);
216
+ const btn = buttons().find((b) => b.dataset.part === name);
57
217
  if (!btn) return false;
58
- view = name;
59
- saveView(partKey, view);
60
- setActive(btn);
61
- onChange(view);
218
+ commit(btn);
62
219
  return true;
63
220
  },
64
221
  detach: () => {
222
+ detached = true;
65
223
  el.removeEventListener("click", onClick);
224
+ select?.removeEventListener("change", onSelectChange);
225
+ win.removeEventListener?.("resize", relayout);
226
+ observer?.disconnect();
227
+ select?.remove(); // ours in both paths — hand-written markup never has one
228
+ delete el.dataset.pfTabs;
66
229
  if (generated) el.innerHTML = ""; // we generated these buttons; hand-written markup stays
230
+ else for (const b of buttons()) b.hidden = false; // leave the page's own buttons as we found them
67
231
  },
68
232
  };
69
233
  }
package/src/oracle.js CHANGED
@@ -29,3 +29,7 @@ export { parse3MF } from "./framework/geometry/threemf-parse.js";
29
29
  // these, re-exported so a downstream harness can reproduce a score outside the job loop.
30
30
  export { MATCH_VIEWS, rasterizeMeshMask, rasterizeRingsMask } from "./framework/oracle/silhouette.js";
31
31
  export { matchMasks, matchViews } from "./framework/oracle/match.js";
32
+ // Sketch-annotation ray reconstruction — the consumer-side half of the
33
+ // annotation payload's camera block: rebuild the pick ray for any screen
34
+ // point, intersect it with a plane in parts-frame millimetres.
35
+ export { annotationRay, rayPlane } from "./framework/oracle/annotation-ray.js";
package/types/oracle.d.ts CHANGED
@@ -19,4 +19,7 @@ export {
19
19
  // silhouette match scoring
20
20
  MATCH_VIEWS, rasterizeMeshMask, rasterizeRingsMask, matchMasks, matchViews,
21
21
  type SilhouetteMask, type MatchScores, type MatchDelta,
22
+ // sketch-annotation rays
23
+ annotationRay, rayPlane,
24
+ type AnnotationRay, type RayPlaneHit, type PlaneSpec,
22
25
  } from "./testing.js";
@@ -499,3 +499,18 @@ export function renderViews(
499
499
  opacity?: Record<string, number>;
500
500
  },
501
501
  ): Promise<string[]>;
502
+
503
+ // --- sketch-annotation rays --------------------------------------------------
504
+ export interface AnnotationRay { origin: [number, number, number]; dir: [number, number, number] }
505
+ export interface RayPlaneHit { point: [number, number, number]; t: number }
506
+ export type PlaneSpec =
507
+ | { point: [number, number, number]; normal: [number, number, number] }
508
+ | "xy" | "yz" | "zx";
509
+ /** Rebuild the pick ray for a screen point of an ANNOTATION_VERSION 3 payload. */
510
+ export function annotationRay(
511
+ payload: { camera: unknown; viewport: { aspect: number } },
512
+ screen: [number, number] | { screen: [number, number] },
513
+ opts?: { frame?: "parts" | "world" },
514
+ ): AnnotationRay;
515
+ /** Intersect a ray with a plane; null on parallel / behind-origin misses. */
516
+ export function rayPlane(ray: AnnotationRay, plane: PlaneSpec): RayPlaneHit | null;