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.
@@ -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) — so the popover is the style grid alone.
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 = "Style";
82
- const grid = el("div", "pf-view-style-grid", { role: "group", "aria-label": "Style" });
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
- viewer.onThemeChange?.(() => cache.invalidate()) ?? (() => {}),
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
+ }