partforge 0.147.0 → 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.
@@ -14,6 +14,8 @@ import { ENVIRONMENTS } from "./materials/environments.js";
14
14
  import { declaresMaterials, resolveMaterial } from "./materials/resolve.js";
15
15
  import { printFrameMatrix } from "./materials/print-frame.js";
16
16
  import { burnsFor, realisticDisplay, sheetFrameFor } from "./materials/sheet-look.js";
17
+ import { pruneOverrides } from "./materials/overrides.js";
18
+ import { subPartRows } from "./sub-part-list.js";
17
19
  import { buildControls } from "./controls.js";
18
20
  import { recordedRelevantKeys, subPartParamKeys } from "./param-deps.js";
19
21
  import { createMeshCache } from "./mesh-cache.js";
@@ -70,7 +72,7 @@ const IMPORT_MESH_BROKEN_MESSAGE = "STEP import tessellation failed to satisfy t
70
72
  // carries the worker's own error text. See the correlated "error" case below.
71
73
  const importTessellateFailedMessage = (workerMessage) => `STEP import tessellation failed — ${workerMessage}`;
72
74
 
73
- 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 }) {
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 }) {
74
76
  return {
75
77
  ready, dispose, setParams,
76
78
  // Whether the `preview` GLB handed to mount() is on screen (see mount's
@@ -132,7 +134,29 @@ export function makeHandle({ ready, dispose, viewer, setParams, listExportablePa
132
134
  // from meta.environment (or the default) would outrank the part's own
133
135
  // meta.environment on the next mount, masking an edit to it.
134
136
  ...(viewer.isEnvironmentChosen?.() ? { environment: viewer.getEnvironment() } : {}),
137
+ // Only while something is overridden: an empty map would be noise in a
138
+ // token hosts compare and store.
139
+ ...(() => { const d = viewer.getDisplayOverrides?.(); return d && Object.keys(d).length ? { display: d } : {}; })(),
135
140
  }),
141
+ // --- per-sub-part display overrides (spec 2026-10-13 part appearance controls) ---
142
+ // Live, no rebuild: appearance keys (material, colour, finish) merge over
143
+ // the authored `display` for every render; `hidden` and `opacity` are how
144
+ // the user is looking and never reach a material read, an export or a
145
+ // host's save. All of it rides getViewerState().display above.
146
+ setSubPartDisplay: (name, patch) => viewer.setSubPartDisplay?.(name, patch) ?? null,
147
+ getSubPartDisplay: (name) => viewer.getSubPartDisplayState?.(name) ?? null,
148
+ clearSubPartDisplay: (opts) => viewer.clearSubPartDisplay?.(opts),
149
+ onSubPartDisplayChange: (cb) => viewer.onDisplayChange?.(cb) ?? (() => {}),
150
+ // Every sub-part as a row (sub-part-list.js), for a host's own list; the
151
+ // active view unless `view` names another.
152
+ listSubParts: listSubParts ?? (() => []),
153
+ // Open the popover on the appearance card for `name`; false when there is
154
+ // no popover (a host with neither #viewbar nor a view cube stack).
155
+ openAppearance: openAppearance ?? (() => false),
156
+ // "saved" | "screen-only" | "blocked": the card's footer copy. A host that
157
+ // persists overrides into source says "saved"; one that cannot, for this
158
+ // sub-part, says "blocked"; the default is "screen-only".
159
+ setDisplaySaveState: setDisplaySaveState ?? (() => {}),
136
160
  // Every custom control's transient state (selection, a scroll position),
137
161
  // keyed by param, as plain JSON — the panel's twin of getViewerState. Hand
138
162
  // it back as mount()'s `panelState` and a remount comes up with the same
@@ -400,8 +424,9 @@ function createCleanupStack() {
400
424
  // // payload is small, only that it is bounded.
401
425
  // viewerState: ViewerState // a previous mount's runtime.getViewerState(), handed back to
402
426
  // // resume the camera, projection, cutaway, render mode,
403
- // // environment and per-style feature-line preferences
404
- // // where that mount
427
+ // // environment, per-style feature-line preferences and
428
+ // // per-sub-part display overrides (`display`, pruned for
429
+ // // this part) where that mount
405
430
  // // left them. For a host that applies edits by REMOUNTING: the
406
431
  // // part changed, the user's view of it should not. Omit on a
407
432
  // // first mount — the viewer then restores its own persisted
@@ -604,6 +629,9 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
604
629
 
605
630
  // View tabs (generated from part.views) + live params. A tab switch shows the
606
631
  // cached assembly instantly if it's current, else auto-builds what's missing.
632
+ // The popover's Parts section (attachViewStyleControls' `parts` seam) follows
633
+ // the active view; declared here because tabsCtl's onChange reads it.
634
+ const viewChangeListeners = new Set();
607
635
  const tabsCtl = createViewTabs(els.tabs, part, {
608
636
  onChange: (name) => {
609
637
  // FIRST: the outgoing animation restores its param snapshot, so the
@@ -616,6 +644,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
616
644
  // (and clear the posed ones' matrices) before anything else reads them.
617
645
  fastPath.apply(viewSubParts(part, view(), params));
618
646
  updateRelevance(); loop.kick(); animCtl?.autoplayKick();
647
+ for (const cb of [...viewChangeListeners]) { try { cb(name); } catch { /* listener's problem */ } }
619
648
  onViewChange?.(name);
620
649
  },
621
650
  });
@@ -740,6 +769,14 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
740
769
  // right rig first time rather than the default and then this one.
741
770
  const initialEnv = viewerState?.environment ?? loadEnvironment();
742
771
  if (initialEnv) viewer.setEnvironment(initialEnv);
772
+ // Carried display overrides, pruned for THIS part (materials/overrides.js):
773
+ // a renamed sub-part's entry goes, and an appearance key that now equals the
774
+ // authored value goes — which is how an owner's saved change self-cleans
775
+ // after the remount that applied it. Restored before the first showView so
776
+ // the first frame already draws the override, hidden pieces included.
777
+ for (const [name, override] of Object.entries(pruneOverrides(viewerState?.display, part))) {
778
+ viewer.setSubPartDisplay(name, override);
779
+ }
743
780
  // Realistic mode by the same precedence, requested HERE rather than on the
744
781
  // first accepted build: every mount has a fresh renderer, so the
745
782
  // environment loads again, and starting now runs that alongside the build
@@ -764,6 +801,15 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
764
801
  stage: els.viewer,
765
802
  toolbar: els.viewer.querySelector("#viewbar"),
766
803
  anchor: viewcube.element,
804
+ // The Parts section's seam (view-style-controls.js); an older popover ignores it.
805
+ parts: {
806
+ list: () => subPartRows(part, { view: view(), params, overrides: viewer.getDisplayOverrides?.() ?? {} }),
807
+ setDisplay: (name, patch) => viewer.setSubPartDisplay(name, patch),
808
+ clear: (opts) => viewer.clearSubPartDisplay(opts),
809
+ onChange: (cb) => viewer.onDisplayChange?.(cb) ?? (() => {}),
810
+ onViewChange: (cb) => { viewChangeListeners.add(cb); return () => viewChangeListeners.delete(cb); },
811
+ getState: (name) => viewer.getSubPartDisplayState(name),
812
+ },
767
813
  }, { tooltip });
768
814
  cleanup.defer(() => viewStyle.detach());
769
815
  // setHidden takes one boolean, and there are two independent reasons to hide
@@ -1027,10 +1073,17 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
1027
1073
  // - sheet frames for the burn pass (materials/sheet-look.js): a laser sheet in a wood,
1028
1074
  // mapped back into its canonical frame — from data, no probe.
1029
1075
  // Each describes the delivered mesh (which a later pose-only repair only moves).
1030
- const layerLined = new Set(Object.keys(part.parts).filter((n) => {
1031
- try { return resolveMaterial(realisticDisplay(part.parts[n])).params.pattern === "layer-lines"; } catch { return false; }
1032
- }));
1033
- const burning = new Set(Object.keys(part.parts).filter((n) => burnsFor(part.parts[n])));
1076
+ // Which sub-parts need frames is read off the EFFECTIVE display at the time
1077
+ // the frames are computed, not fixed at mount: a live material change (the
1078
+ // Parts section) can turn a plain piece into a layer-lined print, and it
1079
+ // must get its print frame without a rebuild. So every delivery's
1080
+ // dispatch record is kept (one small object per sub-part), and computeFrames
1081
+ // decides per name.
1082
+ const effectiveSubPart = (n) => ({ ...part.parts[n], display: viewer.effectiveDisplayOf?.(n) ?? part.parts[n].display });
1083
+ const isLayerLined = (n) => {
1084
+ try { return resolveMaterial(realisticDisplay(effectiveSubPart(n))).params.pattern === "layer-lines"; } catch { return false; }
1085
+ };
1086
+ const isBurning = (n) => burnsFor(effectiveSubPart(n));
1034
1087
  // LAZY: a delivery only records the view and params its job was DISPATCHED with
1035
1088
  // (`dispatched`, set where the loop sends) — not the live ones, which a delivery shown
1036
1089
  // during playback was never built at; the viewer pulls the frames (computeFrames)
@@ -1039,20 +1092,32 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
1039
1092
  // snapshot keeps a late computation describing the mesh actually delivered.
1040
1093
  const printFrames = {};
1041
1094
  const sheetFrames = {};
1042
- const undrawnFrames = new Map(); // sub-part -> { at: { view, params } its delivered mesh was built at, frame }
1095
+ const deliveredAt = new Map(); // sub-part -> { at: { view, params }, frame } of its delivered mesh (kept, not consumed)
1096
+ const undrawnFrames = new Set(); // sub-part names whose frames are stale
1097
+ // The (print, burn) pair each name's frames were last computed for. A display
1098
+ // change re-stales a name only when its pair flips — hide, show, opacity and a
1099
+ // swap between two layer-lined (or two burning) materials leave the frames
1100
+ // valid, so a see-through drag in realistic mode never re-probes a placed piece.
1101
+ const frameNeeds = new Map();
1102
+ const needsKey = (n) => `${isLayerLined(n) ? 1 : 0}${isBurning(n) ? 1 : 0}`;
1043
1103
  // `meshes` are the reply's deliveries: each one's `frame` ("canonical" | "posed"; an older
1044
1104
  // worker sends none, so posed) rides beside the view/params so the frames describe it.
1045
1105
  function recordFrames(meshes) {
1046
- const wanted = meshes.filter((m) => layerLined.has(m.name) || burning.has(m.name));
1047
- if (!wanted.length) return;
1048
- for (const m of wanted) {
1049
- undrawnFrames.set(m.name, { at: dispatched.get(m.name) ?? null, frame: m.frame ?? "posed" }); // at null: built at params nobody recorded
1106
+ for (const m of meshes) {
1107
+ deliveredAt.set(m.name, { at: dispatched.get(m.name) ?? null, frame: m.frame ?? "posed" }); // at null: built at params nobody recorded
1108
+ undrawnFrames.add(m.name);
1050
1109
  }
1051
1110
  viewer.invalidateFrames?.();
1052
1111
  }
1053
1112
  function computeFrames() {
1054
1113
  const resolvedFor = new Map(); // one resolveParams per delivery, not per sub-part
1055
- for (const [n, { at, frame }] of undrawnFrames) {
1114
+ for (const n of undrawnFrames) {
1115
+ const rec = deliveredAt.get(n);
1116
+ if (!rec) continue;
1117
+ const { at, frame } = rec;
1118
+ const needPrint = isLayerLined(n), needBurn = isBurning(n);
1119
+ frameNeeds.set(n, `${needPrint ? 1 : 0}${needBurn ? 1 : 0}`);
1120
+ if (!needPrint && !needBurn) { delete sheetFrames[n]; continue; }
1056
1121
  if (at && !resolvedFor.has(at)) {
1057
1122
  try { resolvedFor.set(at, resolveParams(part, at.params)); } catch { resolvedFor.set(at, null); } // diagnosed by the build
1058
1123
  }
@@ -1064,8 +1129,8 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
1064
1129
  if (!resolved) { delete sheetFrames[n]; continue; }
1065
1130
  // For a views-map sub-part the export pose is the build itself (plus a sheet pose), so a canonical
1066
1131
  // delivery's print frame is identity — no special case, the probe reads the compiled placement.
1067
- if (layerLined.has(n)) printFrames[n] = printFrameMatrix(part.parts[n], { view: at.view, ...resolved, frame });
1068
- if (burning.has(n)) {
1132
+ if (needPrint) printFrames[n] = printFrameMatrix(part.parts[n], { view: at.view, ...resolved, frame });
1133
+ if (needBurn) {
1069
1134
  const f = sheetFrameFor(part.parts[n], { ...resolved, frame });
1070
1135
  if (f) sheetFrames[n] = f; else delete sheetFrames[n];
1071
1136
  }
@@ -1073,6 +1138,20 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
1073
1138
  undrawnFrames.clear();
1074
1139
  return { print: { ...printFrames }, sheet: { ...sheetFrames } };
1075
1140
  }
1141
+ // A display change re-stales a delivered sub-part's frames when it changes
1142
+ // WHICH frames that piece needs (frameNeeds above); a `null` event (a clear)
1143
+ // re-checks every delivered name. computeFrames stays lazy (viewer.js
1144
+ // syncFrames), so a CAD-only session still computes nothing.
1145
+ cleanup.defer(viewer.onDisplayChange?.((evt) => {
1146
+ let stale = false;
1147
+ for (const n of evt?.name ? [evt.name] : deliveredAt.keys()) {
1148
+ if (!deliveredAt.has(n) || undrawnFrames.has(n)) continue; // nothing drawn yet, or already stale
1149
+ if (frameNeeds.get(n) === needsKey(n)) continue;
1150
+ undrawnFrames.add(n);
1151
+ stale = true;
1152
+ }
1153
+ if (stale) viewer.invalidateFrames?.();
1154
+ }) ?? (() => {}));
1076
1155
  viewer.setFrameSource?.(computeFrames);
1077
1156
 
1078
1157
  // Sub-parts whose latest fresh delivery had zero triangles (see the `meshes` case).
@@ -1576,6 +1655,10 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
1576
1655
  setHostPane: paneTabs.setHostPane,
1577
1656
  setRailLayout: paneTabs.setRailLayout,
1578
1657
  getView: view, // () => tabsCtl.current()
1658
+ // `opts` may be null from a host (a destructuring default covers only undefined).
1659
+ listSubParts: (opts) => subPartRows(part, { view: opts?.view ?? view(), params, overrides: viewer.getDisplayOverrides?.() ?? {} }),
1660
+ openAppearance: (name) => viewStyle.openCard?.(name) ?? false,
1661
+ setDisplaySaveState: (state) => viewStyle.setSaveState?.(state),
1579
1662
  setView: (name) => tabsCtl.select(name),
1580
1663
  captureView,
1581
1664
  getPanelState: () => panelRef?.getState() ?? {},
@@ -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,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();