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.
- package/docs/AUTHORING-PARTS.md +39 -3
- package/package.json +1 -1
- package/src/framework/annotate/annotate-controls.js +15 -57
- package/src/framework/annotate/annotate-mode.js +547 -68
- package/src/framework/annotate/elements.js +464 -0
- package/src/framework/annotate/ink-canvas.js +217 -60
- package/src/framework/annotate/sketch-toolbar.js +202 -0
- package/src/framework/app.css +124 -29
- package/src/framework/chrome.css +4 -2
- package/src/framework/mount.js +30 -7
- package/src/framework/oracle/annotation-ray.js +92 -0
- package/src/framework/view-tabs.js +176 -12
- package/src/oracle.js +4 -0
- package/types/oracle.d.ts +3 -0
- package/types/testing.d.ts +15 -0
- package/src/framework/annotate/ink.js +0 -124
package/src/framework/app.css
CHANGED
|
@@ -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-
|
|
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
|
-
/*
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
|
486
|
-
#viewbar .pf-cutaway-actions button, #viewbar .pf-measure-actions button
|
|
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 -----------------------------------------------------
|
package/src/framework/chrome.css
CHANGED
|
@@ -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.
|
|
473
|
-
the
|
|
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;
|
package/src/framework/mount.js
CHANGED
|
@@ -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
|
|
249
|
-
// // "host" drops it
|
|
250
|
-
// //
|
|
251
|
-
// //
|
|
252
|
-
// //
|
|
253
|
-
// //
|
|
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
|
|
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
|
-
|
|
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
|
|
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 ?
|
|
134
|
+
const savedBtn = saved ? tabs.find((b) => b.dataset.part === saved) : null;
|
|
36
135
|
let view = savedBtn ? saved : defaultView;
|
|
37
|
-
|
|
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
|
|
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 =
|
|
216
|
+
const btn = buttons().find((b) => b.dataset.part === name);
|
|
57
217
|
if (!btn) return false;
|
|
58
|
-
|
|
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";
|
package/types/testing.d.ts
CHANGED
|
@@ -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;
|