partforge 0.149.0 → 0.150.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.
@@ -391,7 +391,9 @@ meta: { title: "…", units: "mm", environment: "studio" },
391
391
  treated as declaring one.
392
392
  - `color` — the base colour (`0xRRGGBB`). With a preset it is the TINT. Presets
393
393
  marked tintable are normally coloured this way (anodizing, plastic, paint);
394
- the others have an intrinsic colour (brass) but still accept one.
394
+ the others have an intrinsic colour (brass) but still accept one. (A viewer
395
+ user PICKING an untintable preset drops an authored tint for that pick — see
396
+ the runtime section below.)
395
397
  - Overrides — each clamped to its range: `roughness` (0–1), `metalness` (0–1),
396
398
  `clearcoat` (0–1), `clearcoatRoughness` (0–1), `anisotropy` (0–1), `textureScale` (0.01–1000).
397
399
  Transmission, index of refraction, sheen and iridescence come only from a preset.
@@ -3090,6 +3092,28 @@ instead of (or in addition to) the built-in `#part` bar:
3090
3092
  - `runtime.controlsFor(selection) → string[]` — the param keys relevant to a pick
3091
3093
  (`selection.subPart`): today the picked sub-part's recorded reads plus its show/hide gate
3092
3094
  (`enabled()`) params.
3095
+ - **The selection popover.** Pass `selectionPopover: { slot: true | false }` (with `onPick`)
3096
+ and a pick opens a popover at the picked spot: the sub-part's swatch, label and material as
3097
+ one button that opens its appearance card as the popover's second page, an eye that hides
3098
+ it, and ✕. With `slot: true` the popover also holds an empty region the host fills with its
3099
+ own UI (a chat box, say) floated over the frame: tell partforge how tall it must be with
3100
+ `runtime.selection.setSlotHeight(px)` and hear where it is with
3101
+ `runtime.selection.onSlotChange(({ state, rect }) => …)` — `state` is `"open"` (with a
3102
+ rect), `"collapsed"` (the card page is showing) or `"closed"`. The rect is in CSS px from
3103
+ the top-left of the border box of the stage element (`elements.viewer`), not the canvas.
3104
+ When the stage is too short for the height you asked for, the slot is given less and the
3105
+ rect says so — scroll inside your own content; a later `setSlotHeight` or a roomier stage
3106
+ goes back to what you asked for. `runtime.selection.onClose(({ reason }) => …)` says why
3107
+ partforge closed it (`"dismiss"`, `"hide"`, `"gone"`); `"gone"` also fires when anything
3108
+ releases the pick marker, `runtime.pickMarker.release()` included. Closing the popover, for
3109
+ any reason, releases every held marker — including one the host held itself.
3110
+ `runtime.selection.close()` closes it from the host. An open popover rides
3111
+ `getViewerState().selection`, so a remount reopens it, on the page it was showing; whether
3112
+ it did is settled by the time `runtime.ready` resolves, so check
3113
+ `runtime.selection.isOpen()` after `ready`, not straight after `mount()` (a first build
3114
+ that fails tries again on the first one that succeeds). `runtime.selection` is `null`
3115
+ without the option. `runtime.controlsFor(selection)` is the intended source for a future
3116
+ page of the controls that shape the picked spot.
3093
3117
 
3094
3118
  Pass `onViewChange(name)` to `mount()` to be told the active view: it fires once
3095
3119
  synchronously during mount with the initial resolved view (before `runtime.ready` settles),
@@ -3316,7 +3340,11 @@ drive the same state:
3316
3340
 
3317
3341
  - `runtime.setSubPartDisplay(name, patch)` — merges `patch` over the sub-part's
3318
3342
  override and returns the override now held (`null` when nothing is left, or
3319
- for an unknown `name`). Appearance keys (`material`, `color`, `roughness`,
3343
+ for an unknown `name`). An override `material` that names an untintable
3344
+ preset (brass, oak, …) with no override `color` beside it drops the authored
3345
+ `color` from the effective look — picking brass means brass, not red brass —
3346
+ while a tintable pick keeps the authored colour; a host saving the pick into
3347
+ source should remove `color` alongside writing `material`. Appearance keys (`material`, `color`, `roughness`,
3320
3348
  `metalness`, `clearcoat`, `clearcoatRoughness`, `anisotropy`, `textureScale`)
3321
3349
  merge over the authored `display` for every render — CAD, realistic and the
3322
3350
  `captureView` thumbnail — so a tile picked here is drawn exactly as if it had
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.149.0",
3
+ "version": "0.150.0",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -16,6 +16,8 @@ import { mount } from "./framework/index.js";
16
16
  // scripts/capture-contact-sheet.mjs can switch views and environments and call
17
17
  // runtime.renderViews — removing it breaks both.
18
18
  window.__pfRuntime = mount(materialSwatchesPart, {
19
+ onPick: (pick) => { window.__pfLastPick = pick; },
20
+ selectionPopover: { slot: false },
19
21
  createWorker: (name) =>
20
22
  new Worker(new URL("./materials-worker.js", import.meta.url), { type: "module", name }),
21
23
  onAnnotationSend: (payload) => {
@@ -862,6 +862,48 @@ button.action:focus-visible, .adv-toggle:focus-visible, .sec-title:focus-visible
862
862
  .pf-card-reset { font: inherit; font-weight: 500; color: var(--pf-accent); background: none; border: 0; padding: 0; cursor: pointer; white-space: nowrap; }
863
863
  .pf-card button:focus-visible, .pf-card input:focus-visible { outline: none; box-shadow: 0 0 0 3px color-mix(in oklab, var(--pf-accent) 35%, transparent); }
864
864
 
865
+ /* The selection popover (selection-popover.js): at the picked spot. */
866
+ .pf-selection-popover {
867
+ position: absolute; z-index: 31;
868
+ width: min(320px, calc(100% - 24px)); overflow: visible;
869
+ padding: 8px; display: flex; flex-direction: column;
870
+ background: var(--pf-surface); border: 1px solid var(--pf-border);
871
+ border-radius: 16px; box-shadow: var(--pf-shadow-float);
872
+ color: var(--pf-text); font-size: 12px;
873
+ }
874
+ .pf-selection-popover[hidden], .pf-selection-popover [hidden] { display: none; }
875
+ .pf-selection-tail {
876
+ position: absolute; width: 12px; height: 12px; transform: rotate(45deg);
877
+ background: var(--pf-surface); border: 1px solid var(--pf-border);
878
+ }
879
+ .pf-selection-popover[data-side="below"] .pf-selection-tail { top: -7px; border-right: 0; border-bottom: 0; }
880
+ .pf-selection-popover[data-side="above"] .pf-selection-tail { bottom: -7px; border-left: 0; border-top: 0; }
881
+ /* The 4px of padding (taken back by the negative margin) keeps a control's
882
+ 3px focus ring inside the scroller, which would otherwise clip it. */
883
+ .pf-selection-body { display: flex; flex-direction: column; gap: 8px; min-height: 0; overflow: auto; padding: 4px; margin: -4px; }
884
+ .pf-selection-popover:focus { outline: none; }
885
+ .pf-selection-spot { display: flex; flex-direction: column; gap: 6px; }
886
+ .pf-selection-head { display: flex; align-items: center; gap: 2px; }
887
+ .pf-selection-open {
888
+ flex: 1; min-width: 0; display: grid; grid-template-columns: 22px minmax(0, auto) minmax(0, 1fr);
889
+ align-items: center; gap: 8px; padding: 3px 6px; border: 0; border-radius: 999px;
890
+ background: none; font: inherit; color: var(--pf-text); text-align: left; cursor: pointer;
891
+ }
892
+ .pf-selection-open:hover { background: var(--pf-surface-2); }
893
+ .pf-selection-name { font-weight: 600; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
894
+ .pf-selection-mat { color: var(--pf-muted-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
895
+ .pf-selection-head .pf-parts-swatch.is-hidden { opacity: .35; }
896
+ .pf-selection-eye, .pf-selection-close {
897
+ width: 28px; height: 28px; border: 0; background: none; color: var(--pf-muted-2);
898
+ border-radius: 999px; display: grid; place-items: center; cursor: pointer; margin-left: auto;
899
+ }
900
+ .pf-selection-eye:hover, .pf-selection-close:hover { background: var(--pf-surface-2); color: var(--pf-text); }
901
+ /* The host's slot: an empty, sized region its UI floats over. */
902
+ .pf-selection-slot { flex: none; border-radius: 12px; }
903
+ .pf-selection-open:focus-visible, .pf-selection-eye:focus-visible, .pf-selection-close:focus-visible {
904
+ outline: none; box-shadow: 0 0 0 3px color-mix(in oklab, var(--pf-accent) 35%, transparent);
905
+ }
906
+
865
907
  /* At very narrow widths the full-size viewbar (5 icon buttons + cutaway's
866
908
  Flip/Reset, ~314px) is wider than a small phone's viewport minus the
867
909
  stage's 12px margins, and .pf-shell { overflow: hidden } clips the excess
@@ -12,6 +12,7 @@
12
12
  // animation opacity already uses), so a ghost can be faded further but not
13
13
  // made solid from here.
14
14
  import { DISPLAY_KEYS, OVERRIDE_RANGES } from "./resolve.js";
15
+ import { PRESETS } from "./presets.js";
15
16
 
16
17
  export const APPEARANCE_OVERRIDE_KEYS = DISPLAY_KEYS.filter((k) => k !== "opacity");
17
18
  export const VIEWING_KEYS = ["hidden", "opacity"];
@@ -65,19 +66,33 @@ export function mergeOverride(current, patch) {
65
66
  return Object.keys(next).length ? next : null;
66
67
  }
67
68
 
69
+ // A preset whose colour is its own (brass, oak): an authored `color` still
70
+ // tints it (resolve.js's contract), but a USER picking such a preset means
71
+ // "make it look like that", so the pick drops the authored tint — red PLA
72
+ // picked as brass is brass, not red brass. A tintable pick keeps the colour
73
+ // (red PLA picked as PETG stays red). An explicit override colour wins either
74
+ // way; the card only offers one for tintable presets.
75
+ const untintable = (material) => typeof material === "string" && Object.hasOwn(PRESETS, material) && PRESETS[material].tintable !== true;
76
+
68
77
  // The display every material read sees: authored, with the override's
69
78
  // APPEARANCE keys on top. Viewing keys are never in it.
70
79
  export function effectiveDisplay(authored, override) {
71
80
  const out = isObject(authored) ? { ...authored } : {};
72
81
  if (!isObject(override)) return out;
73
82
  for (const key of APPEARANCE_OVERRIDE_KEYS) if (key in override) out[key] = override[key];
83
+ if ("material" in override && untintable(override.material) && !("color" in override)) delete out.color;
74
84
  return out;
75
85
  }
76
86
 
87
+ const sameAppearance = (a, b) => APPEARANCE_OVERRIDE_KEYS.every((key) => a[key] === b[key]);
88
+
89
+ // True when the override changes nothing about how the part is drawn — judged
90
+ // on the EFFECTIVE display, not key by key, because an untintable material pick
91
+ // with no colour of its own changes the look (the authored tint goes) while
92
+ // naming the authored material.
77
93
  export function isAppearanceNoop(authored, override) {
78
94
  if (!isObject(override)) return true;
79
- const a = isObject(authored) ? authored : {};
80
- return APPEARANCE_OVERRIDE_KEYS.every((key) => !(key in override) || override[key] === a[key]);
95
+ return sameAppearance(effectiveDisplay(authored, null), effectiveDisplay(authored, override));
81
96
  }
82
97
 
83
98
  // A carried map, made safe for THIS part (spec A.4): names the part no longer
@@ -96,8 +111,17 @@ export function pruneOverrides(map, part) {
96
111
  if (!sp) continue;
97
112
  const clean = mergeOverride(null, sanitizeDisplayPatch(raw));
98
113
  if (!clean) continue;
114
+ // An appearance key goes when dropping it leaves the effective display as
115
+ // it is — which is "equals the authored value", except that an untintable
116
+ // material naming the authored material still earns its keep when it is
117
+ // what drops the authored tint.
99
118
  const authored = isObject(sp.display) ? sp.display : {};
100
- for (const key of APPEARANCE_OVERRIDE_KEYS) if (key in clean && clean[key] === authored[key]) delete clean[key];
119
+ for (const key of APPEARANCE_OVERRIDE_KEYS) {
120
+ if (!(key in clean)) continue;
121
+ const without = { ...clean };
122
+ delete without[key];
123
+ if (sameAppearance(effectiveDisplay(authored, clean), effectiveDisplay(authored, without))) delete clean[key];
124
+ }
101
125
  if (Object.keys(clean).length) out[name] = clean;
102
126
  }
103
127
  return out;
@@ -10,6 +10,7 @@ import { createTooltipPresenter, attachButtonTooltips } from "./tooltip.js";
10
10
  import { loadCamera, loadRenderMode, loadEnvironment } from "./view-state.js";
11
11
  import { isFaceAlignedState } from "./projection.js";
12
12
  import { attachViewStyleControls } from "./view-style-controls.js";
13
+ import { attachSelectionPopover } from "./selection-popover.js";
13
14
  import { ENVIRONMENTS } from "./materials/environments.js";
14
15
  import { declaresMaterials, resolveMaterial } from "./materials/resolve.js";
15
16
  import { printFrameMatrix } from "./materials/print-frame.js";
@@ -72,7 +73,7 @@ const IMPORT_MESH_BROKEN_MESSAGE = "STEP import tessellation failed to satisfy t
72
73
  // carries the worker's own error text. See the correlated "error" case below.
73
74
  const importTessellateFailedMessage = (workerMessage) => `STEP import tessellation failed — ${workerMessage}`;
74
75
 
75
- export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, listExportFormats, exportParts, warmExportKernel, setHostPane, setRailLayout, animation, getView, setView, captureView, attachTooltips, measure, annotate, projection, pickMarker, getPanelState, getPanelErrors, controlsFor, renderMode, environment, declaresMaterials, renderViews, isPreviewActive, exportPreview, pendingCutaway, listSubParts, openAppearance, setDisplaySaveState }) {
76
+ export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, listExportFormats, exportParts, warmExportKernel, setHostPane, setRailLayout, animation, getView, setView, captureView, attachTooltips, measure, annotate, projection, pickMarker, getPanelState, getPanelErrors, controlsFor, renderMode, environment, declaresMaterials, renderViews, isPreviewActive, exportPreview, pendingCutaway, listSubParts, openAppearance, setDisplaySaveState, selection, selectionState }) {
76
77
  return {
77
78
  ready, dispose, setParams,
78
79
  // Whether the `preview` GLB handed to mount() is on screen (see mount's
@@ -137,7 +138,12 @@ export function makeHandle({ ready, dispose, viewer, setParams, listExportablePa
137
138
  // Only while something is overridden: an empty map would be noise in a
138
139
  // token hosts compare and store.
139
140
  ...(() => { const d = viewer.getDisplayOverrides?.(); return d && Object.keys(d).length ? { display: d } : {}; })(),
141
+ // The open selection popover, so a remount reopens it where it was.
142
+ ...(() => { const s = selectionState?.(); return s ? { selection: s } : {}; })(),
140
143
  }),
144
+ // The selection popover (mount's `selectionPopover` option), or null:
145
+ // { setSlotHeight(px), onSlotChange(cb) → off, onClose(cb) → off, close(), isOpen() }.
146
+ selection: selection ?? null,
141
147
  // --- per-sub-part display overrides (spec 2026-10-13 part appearance controls) ---
142
148
  // Live, no rebuild: appearance keys (material, colour, finish) merge over
143
149
  // the authored `display` for every render; `hidden` and `opacity` are how
@@ -452,6 +458,14 @@ function createCleanupStack() {
452
458
  // // message — and calls runtime.annotate.send() itself.
453
459
  // // Ignored without onAnnotationSend (there is no toolbar
454
460
  // // to place it in).
461
+ // selectionPopover: { slot: boolean } // opt in to the selection popover: a pick
462
+ // // (onPick required) opens a popover at the
463
+ // // spot — the sub-part's swatch/label/material
464
+ // // (opens its appearance card), an eye, ✕ —
465
+ // // and, with slot: true, an empty slot the
466
+ // // host floats its own UI over (see
467
+ // // runtime.selection). Absent or not an
468
+ // // object: no popover, exactly as before.
455
469
  // onAssetUpload(blob, { kind, filename }) // the drop widget's (widgets/file-drop.js) upload hook:
456
470
  // // async, resolves to a source string (an https: URL or a
457
471
  // // host token) that gets written into the param. `blob` is
@@ -487,6 +501,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
487
501
  viewerState, preview,
488
502
  files, panelState,
489
503
  annotateSend = "viewbar",
504
+ selectionPopover,
490
505
  container: legacyContainer, controls: legacyControls } = {}) {
491
506
  // --- element resolution (the only getElementById calls in the framework, save the ?pickserver client's optional #viewbar lookup) ----
492
507
  const byId = (id) => document.getElementById(id);
@@ -797,21 +812,71 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
797
812
  // cube's bottom-right corner, in the stack, hiding with the cube. It is
798
813
  // a #viewbar child, so --pf-viewbar-clear below measures it for free (the
799
814
  // bar grows wider, not taller).
815
+ // The Parts seam (view-style-controls.js), shared with the selection
816
+ // popover below; an older popover ignores it.
817
+ const partsSeam = {
818
+ list: () => subPartRows(part, { view: view(), params, overrides: viewer.getDisplayOverrides?.() ?? {} }),
819
+ setDisplay: (name, patch) => viewer.setSubPartDisplay(name, patch),
820
+ clear: (opts) => viewer.clearSubPartDisplay(opts),
821
+ onChange: (cb) => viewer.onDisplayChange?.(cb) ?? (() => {}),
822
+ onViewChange: (cb) => { viewChangeListeners.add(cb); return () => viewChangeListeners.delete(cb); },
823
+ getState: (name) => viewer.getSubPartDisplayState(name),
824
+ };
800
825
  const viewStyle = attachViewStyleControls(viewer, {
801
826
  stage: els.viewer,
802
827
  toolbar: els.viewer.querySelector("#viewbar"),
803
828
  anchor: viewcube.element,
804
- // The Parts section's seam (view-style-controls.js); an older popover ignores it.
805
- parts: {
806
- list: () => subPartRows(part, { view: view(), params, overrides: viewer.getDisplayOverrides?.() ?? {} }),
807
- setDisplay: (name, patch) => viewer.setSubPartDisplay(name, patch),
808
- clear: (opts) => viewer.clearSubPartDisplay(opts),
809
- onChange: (cb) => viewer.onDisplayChange?.(cb) ?? (() => {}),
810
- onViewChange: (cb) => { viewChangeListeners.add(cb); return () => viewChangeListeners.delete(cb); },
811
- getState: (name) => viewer.getSubPartDisplayState(name),
812
- },
829
+ parts: partsSeam,
813
830
  }, { tooltip });
814
831
  cleanup.defer(() => viewStyle.detach());
832
+ // The selection popover (spec 2026-10-14): opened by the picker below,
833
+ // following the held pick marker. Only with onPick, and only for a host
834
+ // that passed the option as an object.
835
+ const selectionOpt = onPick && selectionPopover && typeof selectionPopover === "object"
836
+ ? { slot: selectionPopover.slot === true } : null;
837
+ let selPopover = null;
838
+ let pendingSelection = null; // a carried selection waiting for its marker's first anchor
839
+ // The marker's anchor is in canvas px; the popover lives in the stage.
840
+ const toStage = (a) => {
841
+ if (!a) return null;
842
+ const s = els.viewer.getBoundingClientRect();
843
+ const c = viewer.domElement.getBoundingClientRect();
844
+ return { x: a.x + c.left - s.left, y: a.y + c.top - s.top, visible: a.visible !== false };
845
+ };
846
+ if (selectionOpt) {
847
+ selPopover = attachSelectionPopover({
848
+ stage: els.viewer, canvas: viewer.domElement, slot: selectionOpt.slot, parts: partsSeam,
849
+ renderThumb: viewer.renderMaterialThumbnail ? (id, display) => viewer.renderMaterialThumbnail(display, { size: 96 }) : null,
850
+ getSaveState: () => viewStyle.getSaveState?.() ?? "screen-only",
851
+ // Every close lets the marker go. Its null anchor arrives with the
852
+ // popover already closed, so the listener below ignores it.
853
+ onClosed: () => viewer.releaseFlashPoints?.(),
854
+ // Converted at every placement, not once per anchor: the canvas can
855
+ // move within the stage (a dock inset) with no new anchor arriving.
856
+ toStage,
857
+ });
858
+ cleanup.defer(() => selPopover.detach());
859
+ cleanup.defer(viewer.onFlashAnchorChange?.((a) => {
860
+ if (pendingSelection && a) {
861
+ const carried = pendingSelection;
862
+ pendingSelection = null;
863
+ // Straight onto the carried page: one slot report, not open-then-collapsed.
864
+ // A refusal lets go of the marker restoreCarriedSelection lit and held.
865
+ if (!selPopover.open({ subPart: carried.subPart, anchor: a, page: carried.page })) viewer.releaseFlashPoints?.();
866
+ return;
867
+ }
868
+ if (!selPopover.isOpen()) return;
869
+ if (!a) { selPopover.close("gone"); return; }
870
+ selPopover.setAnchor(a);
871
+ }) ?? (() => {}));
872
+ const closeGone = () => selPopover.close("gone");
873
+ viewChangeListeners.add(closeGone);
874
+ cleanup.defer(() => viewChangeListeners.delete(closeGone));
875
+ // A mode that claims the canvas closes it (measureMode and annotateMode
876
+ // are both created above).
877
+ cleanup.defer(measureMode.onModeChange(() => { if (measureMode.isEnabled()) selPopover.close("gone"); }));
878
+ if (annotateMode) cleanup.defer(annotateMode.onModeChange(() => { if (annotateMode.isEnabled()) selPopover.close("gone"); }));
879
+ }
815
880
  // setHidden takes one boolean, and there are two independent reasons to hide
816
881
  // the cube: Sketch mode (below) and a crowded transport bar (wired into the
817
882
  // animation controls further down). Applied straight, whichever fires last
@@ -888,16 +953,26 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
888
953
  // deliberately not guarded — one is armed by an explicit dev toggle,
889
954
  // the other per agent request.
890
955
  suppressed: () => previewGate.isActive() || measureMode.isEnabled() || (annotateMode?.isEnabled() ?? false),
891
- onPick: (selection, anchor) => onPick({
892
- selection,
893
- label: selection.feature?.label ?? part.parts[selection.subPart]?.label ?? selection.subPart,
894
- prompt: formatSelection(selection, { style: "prompt" }),
895
- token: formatSelection(selection, { style: "token" }),
896
- // Where the marker is on the canvas, in CSS px from its top-left, so
897
- // a host can put its own UI beside the dot on the first frame rather
898
- // than a round trip later.
899
- anchor,
900
- }),
956
+ onPick: (selection, anchor) => {
957
+ onPick({
958
+ selection,
959
+ label: selection.feature?.label ?? part.parts[selection.subPart]?.label ?? selection.subPart,
960
+ prompt: formatSelection(selection, { style: "prompt" }),
961
+ token: formatSelection(selection, { style: "token" }),
962
+ // Where the marker is on the canvas, in CSS px from its top-left, so
963
+ // a host can put its own UI beside the dot on the first frame rather
964
+ // than a round trip later.
965
+ anchor,
966
+ });
967
+ // After the host has its chip: open at the spot, and keep the marker.
968
+ if (selPopover && selPopover.open({ subPart: selection.subPart ?? null, anchor })) {
969
+ viewer.holdFlashPoint?.();
970
+ } else if (selPopover?.isOpen()) {
971
+ // A pick it cannot show (slot-less, no listed sub-part) moves on from
972
+ // the open one rather than leaving it, and its held dot, behind.
973
+ selPopover.close("dismiss");
974
+ }
975
+ },
901
976
  });
902
977
  cleanup.defer(() => picker.detach());
903
978
  picker.setActive(true);
@@ -923,6 +998,18 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
923
998
  let framedView = null; // the view the camera was last framed to (null until first show)
924
999
  let cameraRestored = false; // saved camera applied once, on the first frame after load
925
1000
  let cutawayRestored = false; // a carried cutaway, applied once — after any preview, never on one
1001
+ let selectionRestored = false; // a carried selection, tried once, after the camera
1002
+ function restoreCarriedSelection() {
1003
+ selectionRestored = true;
1004
+ const c = viewerState?.selection;
1005
+ if (!selPopover || !c || !Array.isArray(c.point) || c.point.length !== 3 || !c.point.every(Number.isFinite)) return;
1006
+ const subPart = typeof c.subPart === "string" ? c.subPart : null;
1007
+ if (subPart !== null && !partsSeam.list().some((r) => r.name === subPart && r.inView)) return;
1008
+ pendingSelection = { subPart, page: c.page === "appearance" ? "appearance" : "spot" };
1009
+ viewer.flashPoint(c.point);
1010
+ // hold() publishes the marker's anchor synchronously; the listener opens us.
1011
+ if (!viewer.holdFlashPoint?.()) pendingSelection = null;
1012
+ }
926
1013
 
927
1014
  // Per-sub-part cache-validity tracker (Layer 1): view/version/caching change over
928
1015
  // time, so they're passed as getters; params is a stable in-place-mutated object.
@@ -1060,6 +1147,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
1060
1147
  // — not the carried one.
1061
1148
  restoreCarriedCutaway(viewer.getCameraState());
1062
1149
  }
1150
+ if (cameraRestored && !selectionRestored && !previewGate.isActive()) restoreCarriedSelection();
1063
1151
  }
1064
1152
 
1065
1153
  // Frames the realistic look reads, for the geometry just delivered:
@@ -1658,7 +1746,19 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
1658
1746
  // `opts` may be null from a host (a destructuring default covers only undefined).
1659
1747
  listSubParts: (opts) => subPartRows(part, { view: opts?.view ?? view(), params, overrides: viewer.getDisplayOverrides?.() ?? {} }),
1660
1748
  openAppearance: (name) => viewStyle.openCard?.(name) ?? false,
1661
- setDisplaySaveState: (state) => viewStyle.setSaveState?.(state),
1749
+ setDisplaySaveState: (state) => { viewStyle.setSaveState?.(state); selPopover?.refreshSaveState(); },
1750
+ selection: selPopover ? {
1751
+ setSlotHeight: (px) => selPopover.setSlotHeight(px),
1752
+ onSlotChange: (cb) => selPopover.onSlotChange(cb),
1753
+ onClose: (cb) => selPopover.onClose(cb),
1754
+ close: () => selPopover.close(),
1755
+ isOpen: () => selPopover.isOpen(),
1756
+ } : null,
1757
+ selectionState: () => {
1758
+ const st = selPopover?.getState();
1759
+ const point = st ? viewer.getHeldAnchorPoint?.() : null;
1760
+ return st && point ? { subPart: st.subPart, point, page: st.page } : null;
1761
+ },
1662
1762
  setView: (name) => tabsCtl.select(name),
1663
1763
  captureView,
1664
1764
  getPanelState: () => panelRef?.getState() ?? {},
@@ -0,0 +1,40 @@
1
+ // Where the selection popover sits relative to the pick marker. Pure: handed
2
+ // measurements, returns geometry. Moved here from partforge-cloud's
3
+ // pickBubblePlacement.js (2026-10-14) when the pick bubble became partforge's.
4
+ //
5
+ // `tailInset` must stay above the popover's corner radius, or the tail can land
6
+ // on the curve. `gutter` is the clearance kept at each stage edge.
7
+ export const SELECTION_GUTTER = 12;
8
+
9
+ export function planSelectionPlacement({ anchor, box, stage }, options = {}) {
10
+ // Destructuring defaults, not a spread: an explicit `undefined` from a caller
11
+ // forwarding options must fall back, not yield NaN.
12
+ const { gap = 10, tailInset = 28, gutter = SELECTION_GUTTER } = options;
13
+ const minLeft = stage.left + gutter;
14
+ const maxLeft = stage.left + stage.width - gutter - box.width;
15
+ const centred = anchor.x - box.width / 2;
16
+ // A box wider than the stage inverts the clamp; pin it to the left gutter.
17
+ const left = maxLeft < minLeft ? minLeft : Math.min(Math.max(centred, minLeft), maxLeft);
18
+ const belowTop = anchor.y + gap;
19
+ const roomBelow = stage.top + stage.height - gutter - belowTop;
20
+ const roomAbove = anchor.y - gap - (stage.top + gutter);
21
+ const fitsBelow = box.height <= roomBelow;
22
+ const fitsAbove = box.height <= roomAbove;
23
+ // Neither fitting takes the side with more room (ties below); the caller caps
24
+ // the box's height to `maxHeight` so it scrolls instead of leaving the stage.
25
+ const side = fitsBelow || (!fitsAbove && roomBelow >= roomAbove) ? "below" : "above";
26
+ const room = side === "below" ? roomBelow : roomAbove;
27
+ const aboveTop = anchor.y - gap - Math.min(box.height, Math.max(room, 0));
28
+ // From the ROUNDED left — the edge actually painted — or an odd width puts
29
+ // the tail a pixel off the dot.
30
+ const painted = Math.round(left);
31
+ const tail = anchor.x - painted;
32
+ const tailFits = tail >= tailInset && tail <= box.width - tailInset;
33
+ return {
34
+ left: painted,
35
+ top: Math.round(side === "below" ? belowTop : aboveTop),
36
+ side,
37
+ maxHeight: Math.max(0, Math.floor(room)),
38
+ tailOffset: anchor.visible !== false && tailFits ? Math.round(tail) : null,
39
+ };
40
+ }
@@ -0,0 +1,299 @@
1
+ // src/framework/selection-popover.js
2
+ // The popover a pick opens at the picked spot (spec 2026-10-14 selection
3
+ // popover). Two pages: "spot" — a head row (the sub-part's swatch + label +
4
+ // material as ONE button that opens the appearance page, an eye that hides the
5
+ // part, ✕) and, for a host that asked for one, an empty SLOT the host floats
6
+ // its own UI over (partforge-cloud puts its chat composer there) — and
7
+ // "appearance", the appearance card (Task 3).
8
+ //
9
+ // It never decides anything about the slot's content: the host says how tall
10
+ // it must be (setSlotHeight) and is told where it is (onSlotChange). Mount owns
11
+ // the pick marker; this module is handed the marker's position as the marker
12
+ // reports it, and `toStage` turns that into stage px at every placement (so a
13
+ // canvas that moves within the stage is followed on the next re-place).
14
+ import { attachAppearanceCard } from "./appearance-card.js";
15
+ import { hexColor } from "./parts-list.js";
16
+ import { planSelectionPlacement } from "./selection-placement.js";
17
+
18
+ const MIN_SLOT = 44;
19
+ const MAX_SLOT = 2000;
20
+ const ICONS = {
21
+ eyeOff: `<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49"/><path d="M14.084 14.158a3 3 0 0 1-4.242-4.242"/><path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143"/><path d="m2 2 20 20"/></svg>`,
22
+ close: `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"/></svg>`,
23
+ };
24
+
25
+ function el(tag, className, attrs = {}) {
26
+ const e = document.createElement(tag);
27
+ if (className) e.className = className;
28
+ for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, v);
29
+ return e;
30
+ }
31
+
32
+ export function attachSelectionPopover({ stage, canvas = null, slot = false, parts, renderThumb = null, getSaveState = () => "screen-only", onClosed = null, toStage = (a) => a } = {}) {
33
+ const pop = el("div", "pf-selection-popover", { role: "dialog", "aria-label": "Selected spot", tabindex: "-1" });
34
+ pop.hidden = true;
35
+ const tail = el("span", "pf-selection-tail", { "aria-hidden": "true" });
36
+ const spot = el("div", "pf-selection-spot");
37
+ const head = el("div", "pf-selection-head");
38
+ const openBtn = el("button", "pf-selection-open", { type: "button" });
39
+ const swatch = el("span", "pf-parts-swatch", { "aria-hidden": "true" });
40
+ const nameEl = el("span", "pf-selection-name");
41
+ const matEl = el("span", "pf-selection-mat");
42
+ openBtn.append(swatch, nameEl, matEl);
43
+ const eye = el("button", "pf-selection-eye", { type: "button" });
44
+ eye.innerHTML = ICONS.eyeOff;
45
+ const closeBtn = el("button", "pf-selection-close", { type: "button", "aria-label": "Close" });
46
+ closeBtn.innerHTML = ICONS.close;
47
+ head.append(openBtn, eye, closeBtn);
48
+ spot.append(head);
49
+ const slotEl = slot ? el("div", "pf-selection-slot") : null;
50
+ if (slotEl) { slotEl.style.height = `${MIN_SLOT}px`; spot.append(slotEl); }
51
+ // Scrolls in here, not on the popover: the tail pokes out past the popover's edge.
52
+ const body = el("div", "pf-selection-body");
53
+ body.append(spot);
54
+ pop.append(tail, body);
55
+ stage.append(pop);
56
+
57
+ let current = null; // { subPart, anchor } while open; anchor as handed in, before toStage
58
+ let requestedSlot = MIN_SLOT; // what the host asked for; place() may apply less
59
+ let page = "spot";
60
+ let card = null; // built on first use of the appearance page
61
+ let lastSlotKey = null;
62
+ let detached = false;
63
+ const slotCbs = new Set();
64
+ const closeCbs = new Set();
65
+
66
+ const isOpen = () => !pop.hidden;
67
+ const rowFor = (n) => (typeof n === "string" ? parts.list().find((r) => r.name === n && r.inView) ?? null : null);
68
+
69
+ function renderHead() {
70
+ const row = rowFor(current?.subPart);
71
+ openBtn.hidden = !row;
72
+ eye.hidden = !row;
73
+ if (!row) { pop.setAttribute("aria-label", "Selected spot"); return null; }
74
+ swatch.style.setProperty("--pf-swatch", hexColor(row.color));
75
+ swatch.classList.toggle("is-hidden", !!row.hidden);
76
+ nameEl.textContent = row.label;
77
+ matEl.textContent = row.materialLabel ?? "No material";
78
+ openBtn.setAttribute("aria-label", `Change appearance of ${row.label}`);
79
+ eye.setAttribute("aria-label", `Hide ${row.label}`);
80
+ pop.setAttribute("aria-label", row.label);
81
+ return row;
82
+ }
83
+
84
+ function emitSlot() {
85
+ if (!slotEl || detached) return;
86
+ let report;
87
+ if (!isOpen()) report = { state: "closed" };
88
+ else if (page !== "spot") report = { state: "collapsed" };
89
+ else {
90
+ const s = stage.getBoundingClientRect();
91
+ const r = slotEl.getBoundingClientRect();
92
+ report = { state: "open", rect: {
93
+ x: Math.round(r.left - s.left), y: Math.round(r.top - s.top),
94
+ width: Math.round(r.width), height: Math.round(r.height),
95
+ } };
96
+ }
97
+ const key = JSON.stringify(report);
98
+ if (key === lastSlotKey) return;
99
+ lastSlotKey = key;
100
+ for (const cb of [...slotCbs]) {
101
+ try { cb(report); } catch (e) { console.warn("partforge: selection slot listener failed", e); }
102
+ }
103
+ }
104
+
105
+ function place() {
106
+ if (!isOpen() || !current?.anchor) return;
107
+ const anchor = toStage(current.anchor);
108
+ if (!anchor) return;
109
+ const s = stage.getBoundingClientRect();
110
+ const planFor = () => planSelectionPlacement({
111
+ anchor,
112
+ box: { width: pop.offsetWidth, height: pop.offsetHeight },
113
+ stage: { left: 0, top: 0, width: s.width, height: s.height },
114
+ });
115
+ // Clearing the cap below stops the body overflowing for a moment, and the
116
+ // browser answers by resetting its scroll to the top — so every slider
117
+ // write from the card (each re-places the popover) jumped back up. Keep
118
+ // the scroll across the measurement.
119
+ const scrolled = body.scrollTop;
120
+ // Measure the natural height at the host's REQUESTED slot height, not last
121
+ // frame's cap — a taller stage or a new request starts from the request.
122
+ pop.style.maxHeight = "";
123
+ if (slotEl) slotEl.style.height = `${requestedSlot}px`;
124
+ let plan = planFor();
125
+ // Short of room, the slot gives up the difference so the whole popover fits
126
+ // and the rect reported is the slot's real one: a host UI floated at it then
127
+ // scrolls inside itself rather than spilling past the popover. The floor
128
+ // still holds, and only then does the body scroll.
129
+ const natural = pop.offsetHeight;
130
+ if (slotEl && page === "spot" && natural > plan.maxHeight) {
131
+ const laidOut = slotEl.getBoundingClientRect().height;
132
+ const applied = Math.max(MIN_SLOT, Math.min(requestedSlot, Math.floor(plan.maxHeight - (natural - laidOut))));
133
+ if (applied < requestedSlot) {
134
+ slotEl.style.height = `${applied}px`;
135
+ plan = planFor(); // the smaller box may now fit on a side it did not before
136
+ }
137
+ }
138
+ pop.style.maxHeight = `${plan.maxHeight}px`;
139
+ pop.style.left = `${plan.left}px`;
140
+ pop.style.top = `${plan.top}px`;
141
+ pop.dataset.side = plan.side;
142
+ tail.hidden = plan.tailOffset === null;
143
+ if (plan.tailOffset !== null) tail.style.left = `${plan.tailOffset - 6}px`;
144
+ body.scrollTop = scrolled;
145
+ emitSlot();
146
+ }
147
+
148
+ // Built on first use, so a popover that never shows a card holds none of its
149
+ // ~40 controls (the view style popover's rule).
150
+ function ensureCard() {
151
+ if (card) return card;
152
+ card = attachAppearanceCard(parts, {
153
+ onBack: () => showPage("spot"),
154
+ onClose: () => close("dismiss"),
155
+ // A rebuild dropped the part this card was for: the spot it pointed at
156
+ // is gone too.
157
+ onGone: () => { if (isOpen() && page === "appearance") close("gone"); },
158
+ getSaveState,
159
+ renderThumb,
160
+ });
161
+ card.element.hidden = true;
162
+ body.append(card.element);
163
+ return card;
164
+ }
165
+
166
+ function showPage(next) {
167
+ if (!isOpen()) return false;
168
+ if (next === "appearance") {
169
+ const n = current?.subPart;
170
+ if (!rowFor(n)) return false;
171
+ if (!ensureCard().show(n)) return false;
172
+ } else if (next !== "spot") {
173
+ return false;
174
+ }
175
+ // A new page starts at its top; place() below keeps whatever this leaves.
176
+ if (next !== page) body.scrollTop = 0;
177
+ page = next;
178
+ pop.dataset.page = page;
179
+ spot.hidden = page !== "spot";
180
+ if (card) {
181
+ card.element.hidden = page !== "appearance";
182
+ if (page !== "appearance") card.hide();
183
+ }
184
+ // Focus the popover itself, never one of its buttons: a pick (or a click
185
+ // on the head button) is a pointer action, and a button focused from
186
+ // script draws its keyboard ring in Chromium. On the popover, Escape still
187
+ // closes it and Tab reaches the first control. A host slot keeps focus on
188
+ // the spot page (the host's own UI owns it there).
189
+ if (page === "appearance" || !slot) pop.focus({ preventScroll: true });
190
+ place();
191
+ return true;
192
+ }
193
+
194
+ const onOutside = (e) => {
195
+ const t = e.target;
196
+ if (pop.contains(t)) return;
197
+ // A canvas press is a pick (which re-points us) or an orbit (which we
198
+ // follow) — never a dismiss.
199
+ if (canvas && canvas.contains(t)) return;
200
+ if (!stage.contains(t)) return;
201
+ close("dismiss");
202
+ };
203
+ const onKey = (e) => {
204
+ if (e.key !== "Escape" || !isOpen()) return;
205
+ e.stopPropagation();
206
+ e.preventDefault();
207
+ close("dismiss");
208
+ };
209
+ pop.addEventListener("keydown", onKey);
210
+ openBtn.addEventListener("click", () => showPage("appearance"));
211
+ eye.addEventListener("click", () => {
212
+ const n = current?.subPart;
213
+ if (typeof n === "string") parts.setDisplay(n, { hidden: true });
214
+ close("hide");
215
+ });
216
+ closeBtn.addEventListener("click", () => close("dismiss"));
217
+
218
+ // `page: "appearance"` opens straight onto the card (a carried selection
219
+ // reopening where it was) with one slot report, not an "open" then a
220
+ // "collapsed"; with no head row to open a card for, it is the spot page.
221
+ function open({ subPart = null, anchor, page: startPage = "spot" } = {}) {
222
+ if (detached || !anchor) return false;
223
+ const n = typeof subPart === "string" ? subPart : null;
224
+ if (!slot && !rowFor(n)) return false;
225
+ const wasOpen = isOpen();
226
+ current = { subPart: n, anchor };
227
+ pop.hidden = false;
228
+ renderHead();
229
+ if (!wasOpen) document.addEventListener("pointerdown", onOutside, true);
230
+ if (startPage !== "appearance" || !showPage("appearance")) showPage("spot");
231
+ return true;
232
+ }
233
+
234
+ function setAnchor(anchor) {
235
+ if (!isOpen() || !anchor) return;
236
+ current.anchor = anchor;
237
+ place();
238
+ }
239
+
240
+ function setSlotHeight(px) {
241
+ if (!slotEl || typeof px !== "number" || !Number.isFinite(px) || px < 0) return;
242
+ requestedSlot = Math.min(MAX_SLOT, Math.max(MIN_SLOT, Math.round(px)));
243
+ slotEl.style.height = `${requestedSlot}px`;
244
+ place();
245
+ }
246
+
247
+ function close(reason) {
248
+ if (!isOpen()) return;
249
+ pop.hidden = true;
250
+ card?.hide?.();
251
+ if (card) card.element.hidden = true;
252
+ page = "spot";
253
+ body.scrollTop = 0; // the next open starts at the top
254
+ spot.hidden = false;
255
+ current = null;
256
+ document.removeEventListener("pointerdown", onOutside, true);
257
+ emitSlot();
258
+ onClosed?.();
259
+ if (reason) for (const cb of [...closeCbs]) {
260
+ try { cb({ reason }); } catch (e) { console.warn("partforge: selection close listener failed", e); }
261
+ }
262
+ }
263
+
264
+ const offParts = parts.onChange?.(() => {
265
+ if (!isOpen()) return;
266
+ // Without a slot there is nothing to show for a sub-part that left the view.
267
+ if (!renderHead() && !slot) { close("gone"); return; }
268
+ place();
269
+ }) ?? (() => {});
270
+ const ro = typeof ResizeObserver === "function" ? new ResizeObserver(() => place()) : null;
271
+ ro?.observe(stage);
272
+ ro?.observe(pop);
273
+
274
+ return {
275
+ element: pop,
276
+ open,
277
+ setAnchor,
278
+ setSlotHeight,
279
+ showPage,
280
+ close: (reason) => close(reason === "dismiss" || reason === "hide" || reason === "gone" ? reason : undefined),
281
+ isOpen,
282
+ getState: () => (isOpen() ? { subPart: current.subPart, page } : null),
283
+ onSlotChange: (cb) => { if (typeof cb !== "function") return () => {}; slotCbs.add(cb); return () => slotCbs.delete(cb); },
284
+ onClose: (cb) => { if (typeof cb !== "function") return () => {}; closeCbs.add(cb); return () => closeCbs.delete(cb); },
285
+ refreshSaveState: () => { if (isOpen() && page === "appearance") card?.render(); },
286
+ detach() {
287
+ if (detached) return;
288
+ detached = true;
289
+ document.removeEventListener("pointerdown", onOutside, true);
290
+ pop.removeEventListener("keydown", onKey);
291
+ offParts();
292
+ ro?.disconnect();
293
+ card?.detach?.();
294
+ slotCbs.clear();
295
+ closeCbs.clear();
296
+ pop.remove();
297
+ },
298
+ };
299
+ }
@@ -2947,6 +2947,12 @@ export function createViewer(container, part) {
2947
2947
  publishAnchor(null);
2948
2948
  }
2949
2949
 
2950
+ // The world position of the held marker the anchor stream follows, so a host
2951
+ // (mount's selection carry) can put the same marker back after a remount.
2952
+ function getHeldAnchorPoint() {
2953
+ return anchorDot ? [anchorDot.position.x, anchorDot.position.y, anchorDot.position.z] : null;
2954
+ }
2955
+
2950
2956
  function onFlashAnchorChange(cb) {
2951
2957
  // The `disposed` half is the same guard cutaway's onHandleHoverChange takes,
2952
2958
  // and for the same two reasons: a subscribe racing teardown (effect-cleanup
@@ -3092,6 +3098,7 @@ export function createViewer(container, part) {
3092
3098
  projectPoint,
3093
3099
  holdFlashPoint,
3094
3100
  releaseFlashPoints,
3101
+ getHeldAnchorPoint,
3095
3102
  onFlashAnchorChange,
3096
3103
  cutawaySupported: () => cutaway.isSupported,
3097
3104
  cutawayEnabled: () => cutaway.isEnabled,
package/types/index.d.ts CHANGED
@@ -212,6 +212,8 @@ export interface MountOptions {
212
212
  * and takes precedence over the `?pick` and `?pickserver` URL modes.
213
213
  */
214
214
  onPick?: (event: PickEvent) => void;
215
+ /** Opt in to the selection popover (requires `onPick`): a pick opens a popover at the spot with the sub-part's appearance controls, and with `slot: true` an empty region the host floats its own UI over (`runtime.selection`). */
216
+ selectionPopover?: { slot: boolean };
215
217
  /** Receive exported bytes instead of partforge's own DOM download. */
216
218
  onDownload?: (file: DownloadPayload) => void;
217
219
  /** The active view (tab) name — emitted once on mount, then on every change. */
@@ -316,6 +318,8 @@ export interface CutawayState {
316
318
  * `MountOptions.viewerState`.
317
319
  */
318
320
  export interface ViewerState {
321
+ /** The open selection popover, reopened by the next mount if the sub-part is still in the view. */
322
+ selection?: { subPart: string | null; point: [number, number, number]; page: "spot" | "appearance" };
319
323
  /** The live camera pose, or `null` when the viewer could not report one. */
320
324
  camera: { pos: [number, number, number]; target: [number, number, number] } | null;
321
325
  /**
@@ -514,6 +518,43 @@ export interface AnnotateRuntime {
514
518
  onModeChange(cb: () => void): () => void;
515
519
  }
516
520
 
521
+ /**
522
+ * Where the selection popover's host slot is, or why it has none. `rect` is in
523
+ * CSS px from the top-left of the border box of the stage element
524
+ * (`elements.viewer`), not the canvas. On a stage too short for the requested
525
+ * height the slot is given less, and `rect.height` is what it was given.
526
+ */
527
+ export type SelectionSlotReport =
528
+ | { state: "open"; rect: { x: number; y: number; width: number; height: number } }
529
+ | { state: "collapsed" }
530
+ | { state: "closed" };
531
+
532
+ /**
533
+ * The selection popover's host-facing controls (`runtime.selection`). A carried
534
+ * selection (`viewerState.selection`) has reopened, or not, by the time
535
+ * `runtime.ready` resolves — check `isOpen()` after `ready`, not right after
536
+ * `mount()`. (A failed first build tries again on the first successful one.)
537
+ */
538
+ export interface SelectionRuntime {
539
+ /**
540
+ * The height (CSS px, integer, 44–2000) the host's slot content needs. A stage
541
+ * too short for it applies less (never below 44); the request is kept, so a
542
+ * roomier stage restores it.
543
+ */
544
+ setSlotHeight(px: number): void;
545
+ /** Where the slot is, relative to the stage element's border box; fires on every change. Never fires for `slot: false`. */
546
+ onSlotChange(cb: (report: SelectionSlotReport) => void): () => void;
547
+ /**
548
+ * Why partforge closed it. A host's own `close()` does not fire this.
549
+ * `"gone"` also fires when anything releases the pick marker
550
+ * (`runtime.pickMarker.release()` included). Any close releases every held
551
+ * marker, including one the host held itself.
552
+ */
553
+ onClose(cb: (event: { reason: "dismiss" | "hide" | "gone" }) => void): () => void;
554
+ close(): void;
555
+ isOpen(): boolean;
556
+ }
557
+
517
558
  /**
518
559
  * The yellow marker a pick flashes, as a thing with a lifetime — for a host
519
560
  * that hangs its own UI (a chat bubble, a callout) off the dot. A marker fades
@@ -703,6 +744,8 @@ export interface PartRuntime {
703
744
  annotate: AnnotateRuntime;
704
745
  /** The pick marker's lifetime — hold it on screen and follow it across the canvas. Always present (a no-op stand-in outside `makeHandle` tests). */
705
746
  pickMarker: PickMarkerRuntime;
747
+ /** The selection popover, or `null` without the `selectionPopover` option. */
748
+ selection: SelectionRuntime | null;
706
749
  /** Realistic render mode (docs "Materials and appearance"). set() resolves to the mode actually in effect — "cad" if assets failed to load. */
707
750
  renderMode: {
708
751
  get(): RenderMode;