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.
- package/docs/AUTHORING-PARTS.md +30 -2
- package/package.json +1 -1
- package/src/app-materials.js +2 -0
- package/src/framework/app.css +42 -0
- package/src/framework/materials/overrides.js +27 -3
- package/src/framework/mount.js +121 -21
- package/src/framework/selection-placement.js +40 -0
- package/src/framework/selection-popover.js +299 -0
- package/src/framework/viewer.js +7 -0
- package/types/index.d.ts +43 -0
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -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`).
|
|
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
package/src/app-materials.js
CHANGED
|
@@ -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) => {
|
package/src/framework/app.css
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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;
|
package/src/framework/mount.js
CHANGED
|
@@ -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
|
-
|
|
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) =>
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
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
|
+
}
|
package/src/framework/viewer.js
CHANGED
|
@@ -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;
|