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.
- package/docs/AUTHORING-PARTS.md +49 -0
- package/package.json +1 -1
- package/src/framework/app.css +66 -1
- package/src/framework/appearance-card.js +331 -0
- package/src/framework/materials/overrides.js +120 -0
- package/src/framework/materials/thumb-cache.js +33 -0
- package/src/framework/mount.js +98 -15
- package/src/framework/parts-list.js +116 -0
- package/src/framework/sub-part-list.js +44 -0
- package/src/framework/view-style-controls.js +110 -5
- package/src/framework/viewer.js +343 -49
package/src/framework/mount.js
CHANGED
|
@@ -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
|
|
404
|
-
// //
|
|
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
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
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
|
|
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
|
|
1047
|
-
|
|
1048
|
-
|
|
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
|
|
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 (
|
|
1068
|
-
if (
|
|
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)
|
|
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();
|