partforge 0.146.4 → 0.149.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/README.md +25 -0
- package/docs/AUTHORING-PARTS.md +49 -0
- package/package.json +12 -1
- package/src/framework/app.css +66 -1
- package/src/framework/appearance-card.js +331 -0
- package/src/framework/default-view.js +6 -3
- package/src/framework/gltf/decode-preview.js +89 -0
- package/src/framework/gltf/encode-preview.js +142 -0
- package/src/framework/materials/overrides.js +120 -0
- package/src/framework/materials/thumb-cache.js +33 -0
- package/src/framework/mount-preview.js +157 -0
- package/src/framework/mount.js +247 -52
- package/src/framework/parts-list.js +116 -0
- package/src/framework/preview-gate.js +37 -0
- package/src/framework/sub-part-list.js +44 -0
- package/src/framework/view-style-controls.js +110 -5
- package/src/framework/viewbar-clear.js +23 -0
- package/src/framework/viewer.js +377 -49
- package/src/gltf.js +6 -0
- package/src/index.js +5 -0
- package/src/testing.js +1 -0
- package/types/gltf.d.ts +40 -0
- package/types/index.d.ts +80 -0
- package/types/testing.d.ts +3 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// src/framework/parts-list.js
|
|
2
|
+
// The View style popover's Parts section: the sub-parts in the active view,
|
|
3
|
+
// one row each with an eye (hide / show) and a swatch + name (opens the appearance
|
|
4
|
+
// card), plus Show all. DOM only; the row model is sub-part-list.js's and the
|
|
5
|
+
// state is the viewer's — this module renders `parts.list()` and calls
|
|
6
|
+
// `parts.setDisplay` / `parts.clear`, nothing more.
|
|
7
|
+
const EYE = `<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="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></svg>`;
|
|
8
|
+
const EYE_OFF = `<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>`;
|
|
9
|
+
|
|
10
|
+
export const hexColor = (int) => `#${(int >>> 0).toString(16).padStart(6, "0").slice(-6)}`;
|
|
11
|
+
|
|
12
|
+
function el(tag, className, attrs = {}) {
|
|
13
|
+
const e = document.createElement(tag);
|
|
14
|
+
if (className) e.className = className;
|
|
15
|
+
for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, v);
|
|
16
|
+
return e;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// `onSwatch(name)` opens the card. Returns { element, render, detach }.
|
|
20
|
+
export function attachPartsList(parts, { onSwatch } = {}) {
|
|
21
|
+
const section = el("div", "pf-parts");
|
|
22
|
+
const head = el("div", "pf-parts-head");
|
|
23
|
+
const heading = el("p", "pf-view-style-heading pf-parts-heading");
|
|
24
|
+
heading.textContent = "Parts";
|
|
25
|
+
const showAll = el("button", "pf-parts-showall", { type: "button" });
|
|
26
|
+
showAll.textContent = "Show all";
|
|
27
|
+
showAll.hidden = true;
|
|
28
|
+
showAll.addEventListener("click", () => { parts.clear({ viewing: true }); render(); });
|
|
29
|
+
head.append(heading, showAll);
|
|
30
|
+
const list = el("div", "pf-parts-list", { role: "list" });
|
|
31
|
+
section.append(head, list);
|
|
32
|
+
|
|
33
|
+
// Every render rebuilds the rows, so the control that had focus (an eye
|
|
34
|
+
// just clicked from the keyboard) and the scroll position would be lost
|
|
35
|
+
// with the old nodes. Both are remembered here and put back afterwards: the
|
|
36
|
+
// focus by row name and control kind, the scroll on the list and on its
|
|
37
|
+
// scrolling container (the popover, which is what actually scrolls).
|
|
38
|
+
function focusedControl() {
|
|
39
|
+
const a = document.activeElement;
|
|
40
|
+
if (!a || !list.contains(a)) return null;
|
|
41
|
+
const row = a.closest(".pf-parts-row");
|
|
42
|
+
if (!row) return null;
|
|
43
|
+
return { name: row.dataset.name, kind: a.classList.contains("pf-parts-open") ? "open" : "eye" };
|
|
44
|
+
}
|
|
45
|
+
function restoreFocus(was) {
|
|
46
|
+
if (!was) return;
|
|
47
|
+
const row = [...list.children].find((r) => r.dataset.name === was.name);
|
|
48
|
+
row?.querySelector(was.kind === "open" ? ".pf-parts-open" : ".pf-parts-eye")?.focus({ preventScroll: true });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// One render path. Clicks render synchronously (a host whose `onChange`
|
|
52
|
+
// never fires still updates); a display change from anywhere schedules a
|
|
53
|
+
// render for the next microtask, and a synchronous render in between
|
|
54
|
+
// cancels it — so a click whose setDisplay also emits renders once.
|
|
55
|
+
let pending = false;
|
|
56
|
+
function schedule() {
|
|
57
|
+
if (pending) return;
|
|
58
|
+
pending = true;
|
|
59
|
+
queueMicrotask(() => { if (pending) render(); });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// What the list last drew, as one string over every field a row or the
|
|
63
|
+
// Show all button reads. A render whose rows read the same leaves the DOM
|
|
64
|
+
// alone: a playing animation re-shows the assembly every frame, and each
|
|
65
|
+
// one reaches here through the popover's assembly hook, so rebuilding the
|
|
66
|
+
// rows unconditionally replaced the node under the pointer sixty times a
|
|
67
|
+
// second — a press landed on a row that was gone before the release, and no
|
|
68
|
+
// click ever fired (seen on hinged-box.html).
|
|
69
|
+
let drawn = null;
|
|
70
|
+
const keyOf = (all) => JSON.stringify(all.map((r) => [r.name, r.label, r.inView, r.hidden, r.opacity, r.color, r.materialLabel]));
|
|
71
|
+
|
|
72
|
+
function render() {
|
|
73
|
+
pending = false;
|
|
74
|
+
const all = parts.list();
|
|
75
|
+
const key = keyOf(all);
|
|
76
|
+
if (key === drawn) return;
|
|
77
|
+
drawn = key;
|
|
78
|
+
const rows = all.filter((r) => r.inView);
|
|
79
|
+
const was = focusedControl();
|
|
80
|
+
const scroller = section.parentElement;
|
|
81
|
+
const listTop = list.scrollTop, outerTop = scroller?.scrollTop ?? 0;
|
|
82
|
+
list.replaceChildren(...rows.map((r) => {
|
|
83
|
+
const row = el("div", "pf-parts-row", { role: "listitem", "data-name": r.name });
|
|
84
|
+
if (r.hidden) row.classList.add("hidden");
|
|
85
|
+
else if (r.opacity < 1) row.classList.add("faded");
|
|
86
|
+
const eye = el("button", "pf-parts-eye", { type: "button", "aria-pressed": String(r.hidden), "aria-label": `${r.hidden ? "Show" : "Hide"} ${r.label}` });
|
|
87
|
+
eye.title = eye.getAttribute("aria-label");
|
|
88
|
+
eye.innerHTML = r.hidden ? EYE_OFF : EYE;
|
|
89
|
+
eye.addEventListener("click", () => { parts.setDisplay(r.name, { hidden: !r.hidden }); render(); });
|
|
90
|
+
// Swatch, name and material are ONE button: the whole row past the eye
|
|
91
|
+
// opens the card, not just the 22px disc.
|
|
92
|
+
const open = el("button", "pf-parts-open", { type: "button", "aria-label": `Change appearance of ${r.label}` });
|
|
93
|
+
open.title = open.getAttribute("aria-label");
|
|
94
|
+
const swatch = el("span", "pf-parts-swatch");
|
|
95
|
+
swatch.style.setProperty("--pf-swatch", hexColor(r.color));
|
|
96
|
+
const name = el("span", "pf-parts-name"); name.textContent = r.label;
|
|
97
|
+
const mat = el("span", "pf-parts-mat"); mat.textContent = r.materialLabel ?? "";
|
|
98
|
+
open.append(swatch, name, mat);
|
|
99
|
+
open.addEventListener("click", () => onSwatch?.(r.name));
|
|
100
|
+
row.append(eye, open);
|
|
101
|
+
return row;
|
|
102
|
+
}));
|
|
103
|
+
list.scrollTop = listTop;
|
|
104
|
+
if (scroller) scroller.scrollTop = outerTop;
|
|
105
|
+
restoreFocus(was);
|
|
106
|
+
// Show all hides itself once it has done its job; if it held focus, hand
|
|
107
|
+
// focus to the first row's eye rather than dropping it on the page.
|
|
108
|
+
const showAllHadFocus = document.activeElement === showAll;
|
|
109
|
+
showAll.hidden = !all.some((r) => r.hidden || r.opacity < 1);
|
|
110
|
+
if (showAllHadFocus && showAll.hidden) list.querySelector(".pf-parts-eye")?.focus({ preventScroll: true });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const offs = [parts.onChange(() => schedule()), parts.onViewChange?.(() => render()) ?? (() => {})];
|
|
114
|
+
render();
|
|
115
|
+
return { element: section, render, detach() { for (const off of offs) { try { off(); } catch { /* gone */ } } section.remove(); } };
|
|
116
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Whether a cached preview (mount's `preview` option) is on screen, and the
|
|
2
|
+
// chrome it holds off meanwhile. A preview mesh has no feature ids and no live
|
|
3
|
+
// solid behind it, so the modes that read those — cutaway, measure, sketch —
|
|
4
|
+
// wait for the live build; picking and hover ask isActive() per event. Each
|
|
5
|
+
// button gets back exactly the `disabled` it had (a host may disable one for its
|
|
6
|
+
// own reasons, and that must survive the round trip).
|
|
7
|
+
export function createPreviewGate({ buttons = [] } = {}) {
|
|
8
|
+
let active = false;
|
|
9
|
+
const held = []; // { el, wasDisabled }
|
|
10
|
+
const listeners = new Set();
|
|
11
|
+
const announce = () => {
|
|
12
|
+
for (const cb of [...listeners]) cb(active);
|
|
13
|
+
};
|
|
14
|
+
return {
|
|
15
|
+
isActive: () => active,
|
|
16
|
+
start() {
|
|
17
|
+
if (active) return;
|
|
18
|
+
active = true;
|
|
19
|
+
for (const el of buttons) {
|
|
20
|
+
if (!el) continue;
|
|
21
|
+
held.push({ el, wasDisabled: el.disabled });
|
|
22
|
+
el.disabled = true;
|
|
23
|
+
}
|
|
24
|
+
announce();
|
|
25
|
+
},
|
|
26
|
+
end() {
|
|
27
|
+
if (!active) return;
|
|
28
|
+
active = false;
|
|
29
|
+
for (const { el, wasDisabled } of held.splice(0)) el.disabled = wasDisabled;
|
|
30
|
+
announce();
|
|
31
|
+
},
|
|
32
|
+
onChange(cb) {
|
|
33
|
+
listeners.add(cb);
|
|
34
|
+
return () => listeners.delete(cb);
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// src/framework/sub-part-list.js
|
|
2
|
+
// The row model behind the popover's Parts section and the runtime's
|
|
3
|
+
// listSubParts(): one row per sub-part, in definition order, saying whether it
|
|
4
|
+
// is in the view, how it is overridden, and what its swatch should look like.
|
|
5
|
+
// Pure (no DOM, no three) so the popover, a host and the tests read one model.
|
|
6
|
+
import { viewSubParts } from "./part-model.js";
|
|
7
|
+
import { partLabel } from "./export-select.js";
|
|
8
|
+
import { resolveMaterial, NO_MATERIAL_COLOR } from "./materials/resolve.js";
|
|
9
|
+
import { PRESETS } from "./materials/presets.js";
|
|
10
|
+
import { effectiveDisplay, APPEARANCE_OVERRIDE_KEYS } from "./materials/overrides.js";
|
|
11
|
+
|
|
12
|
+
const known = (m) => typeof m === "string" && Object.hasOwn(PRESETS, m);
|
|
13
|
+
const isColor = (v) => Number.isInteger(v) && v >= 0 && v <= 0xffffff;
|
|
14
|
+
|
|
15
|
+
// The swatch colour: the explicit colour wins (it tints any preset, resolve.js),
|
|
16
|
+
// else a known preset's own colour, else the drafting blue-grey.
|
|
17
|
+
function swatchColor(effective) {
|
|
18
|
+
if (isColor(effective.color)) return effective.color;
|
|
19
|
+
if (known(effective.material)) return resolveMaterial(effective).params.color;
|
|
20
|
+
return NO_MATERIAL_COLOR;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function subPartRows(part, { view, params, overrides = {} } = {}) {
|
|
24
|
+
const inViewNames = new Set(viewSubParts(part, view, params ?? part.defaults ?? {}));
|
|
25
|
+
return Object.keys(part.parts).map((name) => {
|
|
26
|
+
const sp = part.parts[name];
|
|
27
|
+
const override = overrides?.[name] ?? null;
|
|
28
|
+
const effective = effectiveDisplay(sp.display, override);
|
|
29
|
+
const m = effective.material;
|
|
30
|
+
const materialLabel = m == null ? null : known(m) ? PRESETS[m].label : "unknown";
|
|
31
|
+
return {
|
|
32
|
+
name,
|
|
33
|
+
label: partLabel(part, name),
|
|
34
|
+
inView: inViewNames.has(name),
|
|
35
|
+
hidden: override?.hidden === true,
|
|
36
|
+
opacity: typeof override?.opacity === "number" ? override.opacity : 1,
|
|
37
|
+
effective,
|
|
38
|
+
color: swatchColor(effective),
|
|
39
|
+
materialLabel,
|
|
40
|
+
tintable: m == null || !known(m) || PRESETS[m].tintable === true,
|
|
41
|
+
overridden: !!override && APPEARANCE_OVERRIDE_KEYS.some((k) => k in override),
|
|
42
|
+
};
|
|
43
|
+
});
|
|
44
|
+
}
|
|
@@ -31,13 +31,19 @@
|
|
|
31
31
|
// Orthographic segmented control). It went when the projection became
|
|
32
32
|
// AUTOMATIC — orthographic on a view cube face view, perspective everywhere
|
|
33
33
|
// else (Fusion 360's "Perspective with Ortho Faces"; viewer.js's
|
|
34
|
-
// tweenCameraTo)
|
|
34
|
+
// tweenCameraTo). Since 2026-10-13 a host that passes a `parts` seam also
|
|
35
|
+
// gets a Parts section under the grid (parts-list.js), and a swatch there opens
|
|
36
|
+
// that sub-part's appearance card as the popover's second page
|
|
37
|
+
// (appearance-card.js); without one the popover is the style grid alone,
|
|
38
|
+
// exactly as before.
|
|
35
39
|
//
|
|
36
40
|
// The state shown is always the viewer's: a tile is pressed once the viewer
|
|
37
41
|
// reports that style, and a runtime change made elsewhere shows up here.
|
|
38
42
|
import { STYLES, styleFor, createThumbnailCache } from "./view-style-state.js";
|
|
39
43
|
import { saveRenderMode, saveEnvironment } from "./view-state.js";
|
|
40
44
|
import { attachButtonTooltips } from "./tooltip.js";
|
|
45
|
+
import { attachPartsList } from "./parts-list.js";
|
|
46
|
+
import { attachAppearanceCard } from "./appearance-card.js";
|
|
41
47
|
|
|
42
48
|
// An eye (lucide "eye"): the button is about how the part is SEEN — a
|
|
43
49
|
// palette read as a paint/colour picker, and a movie camera (its icon until
|
|
@@ -53,7 +59,7 @@ function el(tag, className, attrs = {}) {
|
|
|
53
59
|
return e;
|
|
54
60
|
}
|
|
55
61
|
|
|
56
|
-
export function attachViewStyleControls(viewer, { stage, toolbar = null, anchor = null } = {}, { tooltip } = {}) {
|
|
62
|
+
export function attachViewStyleControls(viewer, { stage, toolbar = null, anchor = null, parts = null } = {}, { tooltip } = {}) {
|
|
57
63
|
const button = el("button", "pf-view-style-button", {
|
|
58
64
|
type: "button", id: "view-style", "aria-label": LABEL,
|
|
59
65
|
"aria-haspopup": "dialog", "aria-expanded": "false", "aria-controls": "pf-view-style-popover",
|
|
@@ -78,8 +84,8 @@ export function attachViewStyleControls(viewer, { stage, toolbar = null, anchor
|
|
|
78
84
|
pop.hidden = true;
|
|
79
85
|
|
|
80
86
|
const styleHead = el("p", "pf-view-style-heading");
|
|
81
|
-
styleHead.textContent = "
|
|
82
|
-
const grid = el("div", "pf-view-style-grid", { role: "group", "aria-label": "
|
|
87
|
+
styleHead.textContent = "Environment";
|
|
88
|
+
const grid = el("div", "pf-view-style-grid", { role: "group", "aria-label": "Environment" });
|
|
83
89
|
const tiles = new Map();
|
|
84
90
|
for (const s of STYLES) {
|
|
85
91
|
const t = el("button", "pf-view-style-tile", { type: "button", "aria-pressed": "false" });
|
|
@@ -97,6 +103,83 @@ export function attachViewStyleControls(viewer, { stage, toolbar = null, anchor
|
|
|
97
103
|
}
|
|
98
104
|
|
|
99
105
|
pop.append(styleHead, grid);
|
|
106
|
+
|
|
107
|
+
// The Parts section (parts-list.js) — only for a host that passes the seam.
|
|
108
|
+
// Separated from the style grid by a rule so the popover reads as two
|
|
109
|
+
// things: how the scene is drawn, then what is in it.
|
|
110
|
+
let partsList = null;
|
|
111
|
+
let divider2 = null;
|
|
112
|
+
if (parts) {
|
|
113
|
+
divider2 = el("hr", "pf-popover-rule");
|
|
114
|
+
partsList = attachPartsList(parts, { onSwatch: (name) => openCard(name) });
|
|
115
|
+
pop.append(divider2, partsList.element);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// The appearance card (appearance-card.js): the popover's second page, for
|
|
119
|
+
// one sub-part. The popover holds one page at a time — "list" (the style
|
|
120
|
+
// grid and the Parts section) or "card" — on `data-page`; placement, the
|
|
121
|
+
// close rules and the host-hidden observers are the same for both, and a
|
|
122
|
+
// close always returns to the list, so the next open starts there.
|
|
123
|
+
let saveState = "screen-only";
|
|
124
|
+
let card = null;
|
|
125
|
+
const listNodes = () => [styleHead, grid, divider2, partsList?.element].filter(Boolean);
|
|
126
|
+
// Pages exist only with the `parts` seam: without it the popover carries no
|
|
127
|
+
// data-page and its style nodes are never touched, so its DOM is exactly the
|
|
128
|
+
// pre-parts popover's.
|
|
129
|
+
function showPage(page) {
|
|
130
|
+
if (!parts) return;
|
|
131
|
+
pop.dataset.page = page;
|
|
132
|
+
for (const n of listNodes()) n.hidden = page !== "list";
|
|
133
|
+
if (card) card.element.hidden = page !== "card";
|
|
134
|
+
if (page === "list") card?.hide();
|
|
135
|
+
if (isOpen()) place();
|
|
136
|
+
}
|
|
137
|
+
// Built on the first openCard, not at attach: until a card is wanted the
|
|
138
|
+
// document holds none of its ~40 controls (a host or test querying the page's
|
|
139
|
+
// range inputs or buttons would otherwise find the hidden card's first).
|
|
140
|
+
function ensureCard() {
|
|
141
|
+
if (card || !parts) return card;
|
|
142
|
+
card = attachAppearanceCard(parts, {
|
|
143
|
+
onBack: (name) => backToList(name),
|
|
144
|
+
onClose: () => close(),
|
|
145
|
+
onGone: () => { if (pop.dataset.page === "card") backToList(null); },
|
|
146
|
+
getSaveState: () => saveState,
|
|
147
|
+
renderThumb: viewer.renderMaterialThumbnail ? (id, display) => viewer.renderMaterialThumbnail(display, { size: 96 }) : null,
|
|
148
|
+
});
|
|
149
|
+
card.element.hidden = true;
|
|
150
|
+
pop.append(card.element);
|
|
151
|
+
return card;
|
|
152
|
+
}
|
|
153
|
+
// Back to the list, with focus on the row the card was for (its swatch), else
|
|
154
|
+
// the first row's eye, else the first style tile. The control that had focus
|
|
155
|
+
// (Back) is about to be hidden, and focus dropping to <body> would also take
|
|
156
|
+
// Escape away: its listener is on the popover and the button, not document.
|
|
157
|
+
function backToList(name) {
|
|
158
|
+
const hadFocus = card?.element.contains(document.activeElement);
|
|
159
|
+
showPage("list");
|
|
160
|
+
if (!hadFocus) return;
|
|
161
|
+
const rowFor = name != null
|
|
162
|
+
? [...pop.querySelectorAll(".pf-parts-row")].find((r) => r.dataset.name === name)
|
|
163
|
+
: null;
|
|
164
|
+
const target = rowFor?.querySelector(".pf-parts-open")
|
|
165
|
+
?? pop.querySelector(".pf-parts-row .pf-parts-eye")
|
|
166
|
+
?? pop.querySelector(".pf-view-style-tile");
|
|
167
|
+
target?.focus({ preventScroll: true });
|
|
168
|
+
}
|
|
169
|
+
if (parts) pop.dataset.page = "list";
|
|
170
|
+
// Open the popover (if it is closed) on the card for `name`. False when there
|
|
171
|
+
// is no card (no `parts` seam), the host is hidden, or `name` is not a sub-part.
|
|
172
|
+
function openCard(name) {
|
|
173
|
+
if (!parts) return false;
|
|
174
|
+
// A bad name must leave a closed popover closed.
|
|
175
|
+
if (!parts.list().some((r) => r.name === name)) return false;
|
|
176
|
+
if (!isOpen()) open();
|
|
177
|
+
if (!isOpen()) return false; // the host is hidden: nothing to show on
|
|
178
|
+
if (!ensureCard().show(name)) return false;
|
|
179
|
+
showPage("card");
|
|
180
|
+
card.element.querySelector(".pf-card-back")?.focus();
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
100
183
|
stage.append(pop);
|
|
101
184
|
|
|
102
185
|
const tooltipBinding = tooltip ? attachButtonTooltips(tooltip, [{ element: button }]) : null;
|
|
@@ -213,12 +296,14 @@ export function attachViewStyleControls(viewer, { stage, toolbar = null, anchor
|
|
|
213
296
|
button.setAttribute("aria-expanded", "true");
|
|
214
297
|
button.classList.add("on");
|
|
215
298
|
render();
|
|
299
|
+
partsList?.render();
|
|
216
300
|
document.addEventListener("pointerdown", onOutside, true);
|
|
217
301
|
refreshThumbnails();
|
|
218
302
|
}
|
|
219
303
|
function close({ focus = true } = {}) {
|
|
220
304
|
if (!isOpen()) return;
|
|
221
305
|
pop.hidden = true;
|
|
306
|
+
showPage("list");
|
|
222
307
|
button.setAttribute("aria-expanded", "false");
|
|
223
308
|
button.classList.remove("on");
|
|
224
309
|
document.removeEventListener("pointerdown", onOutside, true);
|
|
@@ -230,12 +315,24 @@ export function attachViewStyleControls(viewer, { stage, toolbar = null, anchor
|
|
|
230
315
|
const offs = [
|
|
231
316
|
viewer.onRenderModeChange(() => render()),
|
|
232
317
|
viewer.onEnvironmentChange(() => render()),
|
|
318
|
+
// The card's sphere thumbnails show the look itself: a new one re-renders them.
|
|
319
|
+
viewer.onRenderModeChange(() => { viewer.invalidateMaterialThumbnails?.(); if (pop.dataset.page === "card") card?.render(); }),
|
|
320
|
+
viewer.onEnvironmentChange(() => { viewer.invalidateMaterialThumbnails?.(); if (pop.dataset.page === "card") card?.render(); }),
|
|
233
321
|
viewer.onAssemblyChange?.(() => cache.invalidate()) ?? (() => {}),
|
|
234
|
-
|
|
322
|
+
// A theme change also clears the viewer's material thumbnails (setTheme):
|
|
323
|
+
// repaint an open card's tiles, or CAD tiles keep the old theme's backdrop.
|
|
324
|
+
viewer.onThemeChange?.(() => { cache.invalidate(); if (pop.dataset.page === "card") card?.render(); }) ?? (() => {}),
|
|
235
325
|
// A thumbnail shows the section as it was when it was taken, so turning
|
|
236
326
|
// the cutaway on or off, or moving its plane, stales them all — without
|
|
237
327
|
// this a set taken with the cutaway on outlived the cutaway itself.
|
|
238
328
|
viewer.onCutawayChange?.(() => cache.invalidate()) ?? (() => {}),
|
|
329
|
+
// A material or hide change shows in the thumbnails too (spec A.3), so it
|
|
330
|
+
// stales them for the next open the same way.
|
|
331
|
+
viewer.onDisplayChange?.(() => cache.invalidate()) ?? (() => {}),
|
|
332
|
+
// An assembly rebuild changes the Parts rows (a sub-part enabled or
|
|
333
|
+
// dropped, a label or material that follows a param), and the seam has no
|
|
334
|
+
// assembly hook of its own — so the popover re-renders the list here.
|
|
335
|
+
viewer.onAssemblyChange?.(() => partsList?.render()) ?? (() => {}),
|
|
239
336
|
];
|
|
240
337
|
render();
|
|
241
338
|
// The popover closes whenever the button's host is hidden: #viewbar hides
|
|
@@ -279,6 +376,12 @@ export function attachViewStyleControls(viewer, { stage, toolbar = null, anchor
|
|
|
279
376
|
if (divider) divider.hidden = !!flag;
|
|
280
377
|
},
|
|
281
378
|
sync: render,
|
|
379
|
+
openCard: (name) => openCard(name),
|
|
380
|
+
setSaveState(state) {
|
|
381
|
+
saveState = state;
|
|
382
|
+
if (pop.dataset.page === "card") card?.render();
|
|
383
|
+
},
|
|
384
|
+
getSaveState: () => saveState,
|
|
282
385
|
detach() {
|
|
283
386
|
if (detached) return;
|
|
284
387
|
detached = true;
|
|
@@ -287,6 +390,8 @@ export function attachViewStyleControls(viewer, { stage, toolbar = null, anchor
|
|
|
287
390
|
button.removeEventListener("keydown", onKey);
|
|
288
391
|
pop.removeEventListener("keydown", onKey);
|
|
289
392
|
for (const off of offs) { try { off(); } catch { /* already gone */ } }
|
|
393
|
+
partsList?.detach();
|
|
394
|
+
card?.detach();
|
|
290
395
|
tooltipBinding?.detach();
|
|
291
396
|
hideObserver?.disconnect();
|
|
292
397
|
sizeObserver?.disconnect();
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Publish the viewbar's vertical claim as --pf-viewbar-clear on the stage, so
|
|
2
|
+
// chrome.css can stack the view cube on top of it without hardcoding a height
|
|
3
|
+
// that cutaway/measure/annotate action rows can change. A hidden bar (ours or a
|
|
4
|
+
// host stylesheet's) claims 0 — stage-clear.js has the why; the observer fires
|
|
5
|
+
// on the hide itself, since the bar's box goes to zero. Shared by mount() and
|
|
6
|
+
// mountPreview(). Returns a dispose; a no-op without a bar or a ResizeObserver.
|
|
7
|
+
import { stageClearFor } from "./stage-clear.js";
|
|
8
|
+
|
|
9
|
+
export function observeViewbarClear(stage, viewbar) {
|
|
10
|
+
if (!viewbar || typeof ResizeObserver !== "function") return () => {};
|
|
11
|
+
const publish = () => {
|
|
12
|
+
const clear = stageClearFor(stage.getBoundingClientRect(), viewbar.getBoundingClientRect());
|
|
13
|
+
stage.style.setProperty("--pf-viewbar-clear", `${clear}px`);
|
|
14
|
+
};
|
|
15
|
+
const observer = new ResizeObserver(publish);
|
|
16
|
+
observer.observe(viewbar);
|
|
17
|
+
observer.observe(stage);
|
|
18
|
+
publish();
|
|
19
|
+
return () => {
|
|
20
|
+
observer.disconnect();
|
|
21
|
+
stage.style.removeProperty("--pf-viewbar-clear");
|
|
22
|
+
};
|
|
23
|
+
}
|