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.
@@ -493,6 +493,13 @@ the viewer's blue-grey.
493
493
  maple table), `print-bed` (a dim, hard-lit enclosure over a standard-size PEI build plate (180, 220, 256 or 350 mm) marked with its size), `outdoor` (overcast sky,
494
494
  concrete).
495
495
 
496
+ **Viewers can change a sub-part's look.** The View style popover's Parts
497
+ section lets a user pick a material and colour per sub-part, fade it, or hide
498
+ it, live and without a rebuild (`runtime.setSubPartDisplay` below). Material
499
+ and colour draw exactly as if authored, and a host may write them back into
500
+ `display`; hiding and see-through are only ever how the screen looks — exports,
501
+ `captureViews` and `renderViews` never see them.
502
+
496
503
  ---
497
504
 
498
505
  ## Geometry: the kernel / `Solid` API
@@ -3299,6 +3306,48 @@ back into the next `mount()` call to resume both where the previous mount
3299
3306
  left them; omit it on a first mount and the viewer restores its own persisted
3300
3307
  choice instead, the same way it does for the camera.
3301
3308
 
3309
+ ### `runtime.setSubPartDisplay`, `runtime.listSubParts`, `runtime.openAppearance`
3310
+
3311
+ A viewer user can change how one sub-part looks, or hide it, without a rebuild.
3312
+ The View style popover (the eye) has a **Parts** section — the sub-parts in the
3313
+ active view, an eye and a swatch each — and the swatch opens an appearance card
3314
+ (material presets, colour for tintable presets, finish, See-through). A host can
3315
+ drive the same state:
3316
+
3317
+ - `runtime.setSubPartDisplay(name, patch)` — merges `patch` over the sub-part's
3318
+ override and returns the override now held (`null` when nothing is left, or
3319
+ for an unknown `name`). Appearance keys (`material`, `color`, `roughness`,
3320
+ `metalness`, `clearcoat`, `clearcoatRoughness`, `anisotropy`, `textureScale`)
3321
+ merge over the authored `display` for every render — CAD, realistic and the
3322
+ `captureView` thumbnail — so a tile picked here is drawn exactly as if it had
3323
+ been authored. Two **viewing** keys are different: `hidden` (boolean) and
3324
+ `opacity` (0.05–1, a multiplier on an authored `display.opacity`). They are
3325
+ how the user is looking: never part of a material read, an export, lint or
3326
+ `declaresMaterials`, and `captureViews` / `renderViews` (the agent's renders)
3327
+ draw every sub-part as if they were unset. `captureCurrent` follows them,
3328
+ like the screen, unless called with `{ includeHidden: true }`. A `null` value
3329
+ clears a key; `hidden: false` and `opacity: 1` clear theirs.
3330
+ - `runtime.getSubPartDisplay(name)` → `{ authored, override, effective, hidden,
3331
+ opacity }`, or `null`.
3332
+ - `runtime.clearSubPartDisplay({ viewing?, appearance?, name? })` — Show all is
3333
+ `{ viewing: true }`; the card's Reset is `{ appearance: true, name }`.
3334
+ - `runtime.onSubPartDisplayChange(cb)` — `cb({ name, override })` after a
3335
+ change, `cb(null)` after a clear; returns the unsubscribe. A host that
3336
+ persists overrides into source (partforge-cloud writes the sub-part's
3337
+ `display` literal) listens here.
3338
+ - `runtime.listSubParts({ view? })` — every sub-part as `{ name, label, inView,
3339
+ hidden, opacity, effective, color, materialLabel, tintable, overridden }`, in
3340
+ definition order, for the active view unless `view` names another.
3341
+ - `runtime.openAppearance(name)` — opens the popover on the card for `name`;
3342
+ `runtime.setDisplaySaveState("saved" | "screen-only" | "blocked")` sets the
3343
+ card's footer line ("screen-only" is the default; a host that saves says so).
3344
+
3345
+ The override map rides `viewerState.display` and comes back through
3346
+ `mount({ viewerState })`, pruned for the new part: an entry for a sub-part the
3347
+ part no longer declares is dropped, and an appearance key that now equals the
3348
+ authored value is dropped (a host that saved the change into source sees its
3349
+ override vanish on the next mount, which is the intended self-clean).
3350
+
3302
3351
  ### The annotation payload's camera block
3303
3352
 
3304
3353
  `onAnnotationSend(payload)` receives a `camera` block in two frames — `world`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.147.0",
3
+ "version": "0.149.0",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -774,10 +774,11 @@ button.action:focus-visible, .adv-toggle:focus-visible, .sec-title:focus-visible
774
774
  }
775
775
  .pf-view-style-popover[hidden] { display: none; }
776
776
  .pf-view-style-heading { margin: 0; font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--pf-muted-2); }
777
- .pf-view-style-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
777
+ .pf-view-style-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 6px; }
778
778
  .pf-view-style-tile {
779
779
  display: flex; flex-direction: column; gap: 4px; padding: 0; border: 0;
780
780
  background: none; color: var(--pf-muted-2); cursor: pointer; font: inherit; text-align: center;
781
+ font-size: 10.5px;
781
782
  }
782
783
  .pf-view-style-thumb {
783
784
  display: block; aspect-ratio: 1; border-radius: 8px; overflow: hidden;
@@ -796,6 +797,70 @@ button.action:focus-visible, .adv-toggle:focus-visible, .sec-title:focus-visible
796
797
  .pf-view-style-button:focus-visible, .pf-view-style-tile:focus-visible {
797
798
  outline: none; box-shadow: 0 0 0 3px color-mix(in oklab, var(--pf-accent) 35%, transparent);
798
799
  }
800
+ /* The Parts section (parts-list.js): under the style grid, behind a rule. */
801
+ .pf-popover-rule { border: 0; border-top: 1px solid var(--pf-border); margin: 0 -12px; }
802
+ .pf-parts { display: flex; flex-direction: column; gap: 6px; }
803
+ .pf-parts-head { display: flex; align-items: baseline; justify-content: space-between; }
804
+ .pf-parts-showall { font: inherit; font-weight: 500; color: var(--pf-accent); background: none; border: 0; padding: 0; cursor: pointer; }
805
+ .pf-parts-showall[hidden] { display: none; }
806
+ .pf-parts-list { display: flex; flex-direction: column; gap: 2px; }
807
+ .pf-parts-row { display: grid; grid-template-columns: 28px minmax(0, 1fr); align-items: center; gap: 4px; padding: 0; border-radius: var(--pf-radius-control); min-width: 0; }
808
+ .pf-parts-open { display: grid; grid-template-columns: 22px minmax(0, 1fr) auto; align-items: center; gap: 8px; min-width: 0; padding: 3px 6px 3px 0; border: 0; border-radius: var(--pf-radius-control); background: none; font: inherit; text-align: left; cursor: pointer; }
809
+ .pf-parts-open:hover { background: var(--pf-surface-2); }
810
+ .pf-parts-eye { width: 28px; height: 28px; border: 0; background: none; color: var(--pf-muted-2); border-radius: 6px; display: grid; place-items: center; cursor: pointer; }
811
+ .pf-parts-eye:hover { background: var(--pf-accent-soft); color: var(--pf-accent); }
812
+ .pf-parts-row.hidden .pf-parts-eye { color: var(--pf-hint); }
813
+ .pf-parts-row.faded .pf-parts-eye { opacity: .55; }
814
+ .pf-parts-row.hidden .pf-parts-name { color: var(--pf-hint); }
815
+ .pf-parts-row.hidden .pf-parts-swatch { opacity: .35; }
816
+ .pf-parts-swatch { width: 22px; height: 22px; border-radius: 50%; background: var(--pf-swatch, var(--pf-surface-2)); box-shadow: inset 0 0 0 1px rgb(0 0 0 / .18); }
817
+ .pf-parts-open:hover .pf-parts-swatch { box-shadow: inset 0 0 0 1px rgb(0 0 0 / .18), 0 0 0 2px var(--pf-surface), 0 0 0 4px var(--pf-accent); }
818
+ .pf-parts-name { font-size: 13px; color: var(--pf-text); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
819
+ .pf-parts-mat { font-size: 11.5px; color: var(--pf-muted); white-space: nowrap; }
820
+ .pf-parts-eye:focus-visible, .pf-parts-open:focus-visible, .pf-parts-showall:focus-visible { outline: none; box-shadow: 0 0 0 3px color-mix(in oklab, var(--pf-accent) 35%, transparent); }
821
+ /* The appearance card (appearance-card.js): the popover's second page. The
822
+ popover shows one page at a time by hiding the other's nodes; the grid and
823
+ the Parts section set display, which beats the UA's [hidden] rule, so the
824
+ page switch needs this guard (the #viewbar button[hidden] trap). */
825
+ .pf-view-style-popover > [hidden] { display: none; }
826
+ .pf-card { display: flex; flex-direction: column; gap: 10px; }
827
+ .pf-card[hidden] { display: none; }
828
+ .pf-card-head { display: flex; align-items: center; gap: 4px; }
829
+ .pf-card-title { flex: 1; min-width: 0; font-weight: 600; font-size: 13px; color: var(--pf-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
830
+ .pf-card-back, .pf-card-eye, .pf-card-close { width: 28px; height: 28px; border: 0; background: none; color: var(--pf-muted-2); border-radius: 6px; display: grid; place-items: center; cursor: pointer; }
831
+ .pf-card-back:hover, .pf-card-eye:hover, .pf-card-close:hover { background: var(--pf-surface-2); color: var(--pf-text); }
832
+ .pf-card-eye[aria-pressed="true"] { color: var(--pf-hint); }
833
+ .pf-card-row { display: grid; grid-template-columns: 1fr auto; gap: 2px 8px; align-items: baseline; }
834
+ .pf-card-row input[type="range"] { grid-column: 1 / -1; width: 100%; margin: 0; accent-color: var(--pf-accent); }
835
+ .pf-card-lab-row { display: flex; justify-content: space-between; align-items: baseline; }
836
+ .pf-card-lab { font-size: 12px; color: var(--pf-muted); }
837
+ .pf-card-val { font-size: 12px; color: var(--pf-text-2); font-variant-numeric: tabular-nums; }
838
+ .pf-card-tiles { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 6px; }
839
+ .pf-card-tile { display: grid; justify-items: center; gap: 3px; border: 0; background: none; padding: 3px 0; border-radius: 8px; cursor: pointer; font: inherit; color: var(--pf-muted); }
840
+ .pf-card-tile:hover { background: var(--pf-surface-2); }
841
+ .pf-card-tile[aria-pressed="true"] { background: var(--pf-accent-soft); color: var(--pf-accent); }
842
+ .pf-card-ball { position: relative; width: 30px; height: 30px; border-radius: 50%; overflow: hidden; background: radial-gradient(circle at 35% 30%, color-mix(in oklab, var(--pf-swatch) 72%, white) 0, var(--pf-swatch) 48%, color-mix(in oklab, var(--pf-swatch) 68%, black) 100%); box-shadow: inset 0 -1px 2px rgb(0 0 0 / .25); }
843
+ .pf-card-ball img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
844
+ .pf-card-ball img[hidden] { display: none; }
845
+ .pf-card-tile-label { font-size: 9.5px; line-height: 1.1; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
846
+ .pf-card-unknown { margin: 0; font-size: 11px; color: var(--pf-err); font-family: var(--pf-mono); }
847
+ .pf-card-unknown[hidden] { display: none; }
848
+ .pf-card-colors { display: grid; grid-template-columns: repeat(10, minmax(0, 1fr)); gap: 6px; }
849
+ .pf-card-colors.disabled { opacity: .35; pointer-events: none; }
850
+ .pf-card-color { width: 100%; aspect-ratio: 1; border-radius: 50%; border: 0; padding: 0; cursor: pointer; background: var(--pf-swatch); box-shadow: inset 0 0 0 1px rgb(0 0 0 / .18); }
851
+ .pf-card-color.on, .pf-card-color:hover { box-shadow: inset 0 0 0 1px rgb(0 0 0 / .18), 0 0 0 2px var(--pf-surface), 0 0 0 4px var(--pf-accent); }
852
+ .pf-card-native { width: 100%; min-width: 0; aspect-ratio: 1; border: 1px solid var(--pf-border); border-radius: 50%; padding: 0; background: none; cursor: pointer; }
853
+ .pf-card-hex { width: 100%; font: 12px var(--pf-mono); padding: 5px 8px; border: 1px solid var(--pf-border); border-radius: var(--pf-radius-control); background: var(--pf-input-bg); color: var(--pf-text); }
854
+ .pf-card-hex:disabled { opacity: .5; }
855
+ .pf-card-tint-note { margin: 0; font-size: 12px; color: var(--pf-muted); }
856
+ .pf-card-tint-note[hidden] { display: none; }
857
+ .pf-card-finish-group summary { cursor: pointer; font-size: 12px; color: var(--pf-muted-2); list-style: none; display: flex; align-items: center; gap: 6px; }
858
+ .pf-card-finish-group summary::before { content: ""; width: 6px; height: 6px; border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor; transform: rotate(-45deg); }
859
+ .pf-card-finish-group[open] summary::before { transform: rotate(45deg); }
860
+ .pf-card-finish-group .pf-card-row { margin-top: 8px; }
861
+ .pf-card-foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; font-size: 11.5px; color: var(--pf-muted); border-top: 1px solid var(--pf-border); padding-top: 10px; }
862
+ .pf-card-reset { font: inherit; font-weight: 500; color: var(--pf-accent); background: none; border: 0; padding: 0; cursor: pointer; white-space: nowrap; }
863
+ .pf-card button:focus-visible, .pf-card input:focus-visible { outline: none; box-shadow: 0 0 0 3px color-mix(in oklab, var(--pf-accent) 35%, transparent); }
799
864
 
800
865
  /* At very narrow widths the full-size viewbar (5 icon buttons + cutaway's
801
866
  Flip/Reset, ~314px) is wider than a small phone's viewport minus the
@@ -0,0 +1,331 @@
1
+ // src/framework/appearance-card.js
2
+ // The appearance card: the popover's second page, for one sub-part. Header
3
+ // (Back, label, eye, close), See-through, the material tiles, the colour row,
4
+ // the finish sliders, and a footer that says where a change goes. Every write
5
+ // is one parts.setDisplay(name, patch); the card re-renders from parts.getState
6
+ // on the change event, so what it shows is always the viewer's answer.
7
+ import { PRESETS } from "./materials/presets.js";
8
+ import { OVERRIDE_RANGES } from "./materials/resolve.js";
9
+ import { MIN_USER_OPACITY } from "./materials/overrides.js";
10
+ import { hexColor } from "./parts-list.js";
11
+
12
+ const COLORS = ["#b3261e", "#e8711a", "#e3b31c", "#2e8b57", "#1f5bd6", "#6f42c1", "#f2f2f0", "#8c8c90", "#1f1f22"];
13
+ const FINISH = [
14
+ ["roughness", "Roughness"], ["metalness", "Metalness"], ["clearcoat", "Clearcoat"],
15
+ ["clearcoatRoughness", "Clearcoat roughness"], ["anisotropy", "Anisotropy"], ["textureScale", "Texture scale"],
16
+ ];
17
+ const FOOT = {
18
+ "saved": { note: "Material and colour are saved to this forge. See-through and hiding are only on this screen.", reset: "Reset to authored" },
19
+ "screen-only": { note: "", reset: "Reset" },
20
+ "blocked": { note: "This part's look is set by code. Ask the assistant to change it.", reset: "Reset" },
21
+ };
22
+ const ICONS = {
23
+ back: `<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="m15 18-6-6 6-6"/></svg>`,
24
+ close: `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"/></svg>`,
25
+ 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>`,
26
+ eyeOff: `<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49"/><path d="M14.084 14.158a3 3 0 0 1-4.242-4.242"/><path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143"/><path d="m2 2 20 20"/></svg>`,
27
+ };
28
+ // Texture scale spans 0.01–1000 mm, five decades: a linear slider over that puts
29
+ // ~4 mm under each pixel and cannot land on the print presets' 0.2 mm. The
30
+ // slider moves a POSITION in [0, 1] instead, mapped logarithmically onto
31
+ // OVERRIDE_RANGES.textureScale; values are clamped to the range both ways.
32
+ const [TS_LO, TS_HI] = OVERRIDE_RANGES.textureScale;
33
+ const clampTs = (v) => Math.min(TS_HI, Math.max(TS_LO, v));
34
+ export function textureScaleToPos(value) {
35
+ const v = Number.isFinite(value) ? clampTs(value) : TS_LO;
36
+ return Math.log(v / TS_LO) / Math.log(TS_HI / TS_LO);
37
+ }
38
+ export function posToTextureScale(pos) {
39
+ const p = Number.isFinite(pos) ? Math.min(1, Math.max(0, pos)) : 0;
40
+ return clampTs(TS_LO * (TS_HI / TS_LO) ** p);
41
+ }
42
+ // Sensible precision for a millimetre scale: 0.2 mm, 4.8 mm, 48 mm, 250 mm.
43
+ function formatTextureScale(v) {
44
+ const digits = v < 1 ? 2 : v < 10 ? 1 : 0;
45
+ return `${Number(v.toFixed(digits))} mm`;
46
+ }
47
+ // The tiles' repaint is a pass over ~25 spheres, one per frame; a dragged colour
48
+ // well re-renders the card every frame, so repaints are held to one per this
49
+ // many milliseconds (leading edge, plus one trailing pass for the last state).
50
+ const REPAINT_MS = 150;
51
+
52
+ const parseHex = (s) => (/^#?[0-9a-f]{6}$/i.test(String(s).trim()) ? parseInt(String(s).trim().replace("#", ""), 16) : null);
53
+ const known = (m) => typeof m === "string" && Object.hasOwn(PRESETS, m);
54
+
55
+ // Leading-edge, once-per-frame write. The viewer rebuilds the sub-part's
56
+ // material on every appearance write, so a dragged slider or colour well must
57
+ // not write on every input event: the first event in a frame writes at once,
58
+ // later ones in the same frame keep only their latest value, which is written
59
+ // when the frame ends. requestAnimationFrame where there is one; a 16ms timer
60
+ // otherwise (a test DOM, a worker-less host).
61
+ const hasRaf = () => typeof requestAnimationFrame === "function";
62
+ const nextFrame = () => new Promise((res) => (hasRaf() ? requestAnimationFrame(() => res()) : setTimeout(res, 16)));
63
+ function frameThrottle(fn) {
64
+ const raf = hasRaf() ? requestAnimationFrame : (cb) => setTimeout(cb, 16);
65
+ const caf = typeof cancelAnimationFrame === "function" && hasRaf() ? cancelAnimationFrame : clearTimeout;
66
+ let handle = null;
67
+ let pending = null; // [args] waiting for the frame's end, or null
68
+ const tick = () => {
69
+ handle = null;
70
+ if (!pending) return;
71
+ const args = pending; pending = null;
72
+ fn(...args);
73
+ handle = raf(tick); // this write opened a new frame of its own
74
+ };
75
+ const call = (...args) => {
76
+ if (handle === null) { fn(...args); handle = raf(tick); } else pending = args;
77
+ };
78
+ call.cancel = () => { if (handle !== null) caf(handle); handle = null; pending = null; };
79
+ return call;
80
+ }
81
+
82
+ function el(tag, className, attrs = {}) {
83
+ const e = document.createElement(tag);
84
+ if (className) e.className = className;
85
+ for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, v);
86
+ return e;
87
+ }
88
+
89
+ // `renderThumb(materialId, display) -> Promise<dataUrl|null>` (Task 7) paints a
90
+ // sphere into a tile; absent, tiles stay flat discs in the preset colour.
91
+ // `onBack(name)` gets the name the card was showing, so the host can put focus
92
+ // back on that row; `onGone(name)` fires when a re-render finds the part gone
93
+ // (dropped by a rebuild), so the host can leave the card for the list.
94
+ export function attachAppearanceCard(parts, { onBack, onClose, onGone, getSaveState, renderThumb = null } = {}) {
95
+ const root = el("div", "pf-card");
96
+ let name = null;
97
+ let row = null;
98
+
99
+ // --- static skeleton (rebuilt values only on render) ---
100
+ const head = el("div", "pf-card-head");
101
+ const back = el("button", "pf-card-back", { type: "button", "aria-label": "Back to parts" }); back.innerHTML = ICONS.back;
102
+ const title = el("span", "pf-card-title");
103
+ const eye = el("button", "pf-card-eye", { type: "button" });
104
+ const close = el("button", "pf-card-close", { type: "button", "aria-label": "Close" }); close.innerHTML = ICONS.close;
105
+ head.append(back, title, eye, close);
106
+
107
+ const seeRow = el("label", "pf-card-row");
108
+ const seeLab = el("span", "pf-card-lab"); seeLab.textContent = "See-through";
109
+ const seeVal = el("span", "pf-card-val");
110
+ const see = el("input", "pf-card-seethrough", { type: "range", min: String(Math.round(MIN_USER_OPACITY * 100)), max: "100", step: "1", "aria-label": "See-through" });
111
+ seeRow.append(seeLab, seeVal, see);
112
+
113
+ const matHead = el("div", "pf-card-lab-row");
114
+ const matLab = el("span", "pf-card-lab"); matLab.textContent = "Material";
115
+ const matVal = el("span", "pf-card-val");
116
+ matHead.append(matLab, matVal);
117
+ const tiles = el("div", "pf-card-tiles", { role: "group", "aria-label": "Material" });
118
+ const tileFor = (id, label, color) => {
119
+ const t = el("button", "pf-card-tile", { type: "button", "data-material": id, "aria-pressed": "false", title: label });
120
+ const ball = el("span", "pf-card-ball"); ball.style.setProperty("--pf-swatch", hexColor(color));
121
+ const img = el("img", "", { alt: "" }); img.hidden = true; ball.append(img);
122
+ const lab = el("span", "pf-card-tile-label"); lab.textContent = label;
123
+ t.append(ball, lab);
124
+ return t;
125
+ };
126
+ // One tile per preset. There is no "no material" tile: in the realistic
127
+ // view a part with no material looks like plain matte in its colour, which
128
+ // nothing on screen can tell from a neighbouring preset, so the tile read as
129
+ // dead. A part with no (or an unknown) material presses nothing; Reset is
130
+ // the way back to the authored look.
131
+ tiles.append(...Object.values(PRESETS).map((p) => tileFor(p.id, p.label, p.color)));
132
+ const unknown = el("p", "pf-card-unknown"); unknown.hidden = true;
133
+
134
+ const colHead = el("div", "pf-card-lab-row");
135
+ const colLab = el("span", "pf-card-lab"); colLab.textContent = "Colour";
136
+ const colVal = el("span", "pf-card-val");
137
+ colHead.append(colLab, colVal);
138
+ const colors = el("div", "pf-card-colors");
139
+ for (const c of COLORS) {
140
+ const b = el("button", "pf-card-color", { type: "button", "data-color": c, title: c, "aria-label": c });
141
+ b.style.setProperty("--pf-swatch", c);
142
+ colors.append(b);
143
+ }
144
+ const native = el("input", "pf-card-native", { type: "color", "aria-label": "Custom colour" });
145
+ colors.append(native);
146
+ const hexRow = el("div", "pf-card-hexrow");
147
+ const hex = el("input", "pf-card-hex", { type: "text", "aria-label": "Hex colour", spellcheck: "false", maxlength: "7" });
148
+ hexRow.append(hex);
149
+ const tintNote = el("p", "pf-card-tint-note"); tintNote.hidden = true;
150
+
151
+ const finish = el("details", "pf-card-finish-group");
152
+ const summary = el("summary"); summary.textContent = "Finish";
153
+ finish.append(summary);
154
+ const finishInputs = new Map();
155
+ // Each throttle is bound to the part it was started for, so a write still
156
+ // pending when the card switches part lands on the part it was meant for.
157
+ const writeFinish = frameThrottle((who, patch) => parts.setDisplay(who, patch));
158
+ for (const [key, label] of FINISH) {
159
+ const [lo, hi] = OVERRIDE_RANGES[key];
160
+ const r = el("label", "pf-card-row");
161
+ const lab = el("span", "pf-card-lab"); lab.textContent = label;
162
+ const val = el("span", "pf-card-val");
163
+ // Texture scale's range input holds a log POSITION (textureScaleToPos), not mm.
164
+ const logScale = key === "textureScale";
165
+ const input = el("input", "pf-card-finish", logScale
166
+ ? { type: "range", "data-key": key, min: "0", max: "1", step: "0.001", "aria-label": label }
167
+ : { type: "range", "data-key": key, min: String(lo), max: String(hi), step: "0.01", "aria-label": label });
168
+ r.append(lab, val, input);
169
+ finish.append(r);
170
+ finishInputs.set(key, { input, val });
171
+ input.addEventListener("input", () => {
172
+ const raw = Number(input.value);
173
+ if (!name || !Number.isFinite(raw)) return;
174
+ const v = logScale ? posToTextureScale(raw) : raw;
175
+ writeFinish(name, { [key]: Math.min(hi, Math.max(lo, v)) });
176
+ });
177
+ }
178
+
179
+ const foot = el("div", "pf-card-foot");
180
+ const note = el("span", "pf-card-foot-note");
181
+ const reset = el("button", "pf-card-reset", { type: "button" });
182
+ foot.append(note, reset);
183
+
184
+ root.append(head, seeRow, matHead, tiles, unknown, colHead, colors, hexRow, tintNote, finish, foot);
185
+
186
+ // --- wiring ---
187
+ back.addEventListener("click", () => onBack?.(name));
188
+ close.addEventListener("click", () => onClose?.());
189
+ eye.addEventListener("click", () => { if (name && row) parts.setDisplay(name, { hidden: !row.hidden }); });
190
+ // See-through rebuilds the fade clone on every write, like the finish sliders:
191
+ // once per frame, the first event at once.
192
+ const writeSee = frameThrottle((who, patch) => parts.setDisplay(who, patch));
193
+ see.addEventListener("input", () => {
194
+ if (!name) return;
195
+ const v = Number(see.value) / 100;
196
+ writeSee(name, { opacity: v >= 1 ? null : v });
197
+ });
198
+ tiles.addEventListener("click", (e) => {
199
+ const t = e.target.closest(".pf-card-tile");
200
+ if (!t || !name) return;
201
+ const id = t.dataset.material;
202
+ // An untintable preset draws its own colour, and the card disables the
203
+ // colour row under it — so a colour the USER set would be stranded there
204
+ // (drawn as red brass, with nothing but Reset to clear it). Picking one
205
+ // clears the override colour. An authored colour is source and persists.
206
+ const ownColor = known(id) && !PRESETS[id].tintable && typeof parts.getState(name)?.override?.color === "number";
207
+ parts.setDisplay(name, ownColor ? { material: id, color: null } : { material: id });
208
+ });
209
+ colors.addEventListener("click", (e) => {
210
+ const b = e.target.closest(".pf-card-color");
211
+ if (!b || !name) return;
212
+ parts.setDisplay(name, { color: parseHex(b.dataset.color) });
213
+ });
214
+ const writeNative = frameThrottle((who, patch) => parts.setDisplay(who, patch));
215
+ native.addEventListener("input", () => { const c = parseHex(native.value); if (name && c !== null) writeNative(name, { color: c }); });
216
+ hex.addEventListener("change", () => { const c = parseHex(hex.value); if (name && c !== null) parts.setDisplay(name, { color: c }); else render(); });
217
+ reset.addEventListener("click", () => { if (name) parts.clear({ appearance: true, name }); });
218
+
219
+ function render() {
220
+ if (!name) return;
221
+ row = parts.list().find((r) => r.name === name) ?? null;
222
+ const state = parts.getState(name);
223
+ if (!row || !state) {
224
+ // The part went away under the card (a rebuild dropped it): there is
225
+ // nothing left to show, so hand the page back rather than leave a card
226
+ // for a part that no longer exists.
227
+ const gone = name;
228
+ name = null; row = null; thumbToken++; cancelRepaint();
229
+ onGone?.(gone);
230
+ return;
231
+ }
232
+ title.textContent = row.label;
233
+ eye.innerHTML = row.hidden ? ICONS.eyeOff : ICONS.eye;
234
+ eye.setAttribute("aria-pressed", String(row.hidden));
235
+ eye.setAttribute("aria-label", `${row.hidden ? "Show" : "Hide"} ${row.label}`);
236
+ eye.title = eye.getAttribute("aria-label");
237
+ const pct = Math.round(row.opacity * 100);
238
+ see.value = String(pct); seeVal.textContent = `${pct}%`;
239
+ const m = state.effective.material;
240
+ for (const t of tiles.children) t.setAttribute("aria-pressed", String(known(m) && t.dataset.material === m));
241
+ matVal.textContent = row.materialLabel ?? "None";
242
+ unknown.hidden = !(m != null && !known(m));
243
+ unknown.textContent = unknown.hidden ? "" : String(m);
244
+ const color = row.color;
245
+ // An explicit colour under an untintable preset still exists (an override
246
+ // cannot clear an authored colour, only replace it), so it is shown by
247
+ // value rather than as "from the material" — the row stays disabled.
248
+ const ownColor = typeof state.effective.color === "number";
249
+ colVal.textContent = row.tintable || ownColor ? hexColor(color) : "from the material";
250
+ colors.classList.toggle("disabled", !row.tintable);
251
+ // Disabled for real, not only dimmed: pointer-events alone left the
252
+ // swatches and the colour well reachable by keyboard and screen reader.
253
+ for (const b of colors.querySelectorAll(".pf-card-color")) b.disabled = !row.tintable;
254
+ native.disabled = !row.tintable;
255
+ hex.disabled = !row.tintable;
256
+ for (const b of colors.querySelectorAll(".pf-card-color")) b.classList.toggle("on", parseHex(b.dataset.color) === color);
257
+ native.value = hexColor(color);
258
+ if (document.activeElement !== hex) hex.value = hexColor(color);
259
+ tintNote.hidden = row.tintable;
260
+ tintNote.textContent = row.tintable ? "" : `${row.materialLabel} has its own colour. Pick a tintable preset to colour it.`;
261
+ for (const [key, { input, val }] of finishInputs) {
262
+ const v = state.effective[key];
263
+ const shown = typeof v === "number" ? v : (known(m) ? (PRESETS[m][key] ?? 0) : 0);
264
+ if (key === "textureScale") {
265
+ const mm = shown > 0 ? clampTs(shown) : null;
266
+ input.value = String(textureScaleToPos(mm ?? TS_LO));
267
+ val.textContent = mm === null ? "—" : formatTextureScale(mm);
268
+ } else {
269
+ input.value = String(shown); val.textContent = shown.toFixed(2);
270
+ }
271
+ }
272
+ const f = FOOT[getSaveState?.() ?? "screen-only"] ?? FOOT["screen-only"];
273
+ note.textContent = f.note; note.hidden = !f.note; reset.textContent = f.reset;
274
+ if (renderThumb) requestPaint(state.effective);
275
+ }
276
+
277
+ // Repaints, held to one per REPAINT_MS: the first at once, then at most one
278
+ // trailing pass carrying the latest state when the window closes.
279
+ let repaintTimer = null;
280
+ let repaintPending = null;
281
+ function requestPaint(effective) {
282
+ if (repaintTimer !== null) { repaintPending = effective; return; }
283
+ paintThumbs(effective);
284
+ const windowEnd = () => {
285
+ repaintTimer = null;
286
+ if (!repaintPending) return;
287
+ const next = repaintPending; repaintPending = null;
288
+ paintThumbs(next);
289
+ repaintTimer = setTimeout(windowEnd, REPAINT_MS);
290
+ };
291
+ repaintTimer = setTimeout(windowEnd, REPAINT_MS);
292
+ }
293
+ function cancelRepaint() {
294
+ if (repaintTimer !== null) clearTimeout(repaintTimer);
295
+ repaintTimer = null; repaintPending = null;
296
+ }
297
+
298
+ // Thumbnails: one per tile, lazily, in order, cached by the renderer (Task 7),
299
+ // with a frame between tiles so a pass of cold renders never blocks a task
300
+ // for all ~25 spheres at once.
301
+ let thumbToken = 0;
302
+ async function paintThumbs(effective) {
303
+ const token = ++thumbToken;
304
+ let first = true;
305
+ for (const t of tiles.children) {
306
+ if (token !== thumbToken || !root.isConnected) return;
307
+ const id = t.dataset.material;
308
+ if (!first) { await nextFrame(); if (token !== thumbToken || !root.isConnected) return; }
309
+ first = false;
310
+ let url = null;
311
+ try { url = await renderThumb(id, { ...effective, material: id }); } catch { url = null; }
312
+ if (token !== thumbToken) return;
313
+ const img = t.querySelector("img");
314
+ if (url) { img.src = url; img.hidden = false; } else img.hidden = true;
315
+ }
316
+ }
317
+
318
+ const off = parts.onChange(() => render());
319
+ return {
320
+ element: root,
321
+ show(next) {
322
+ if (!parts.list().some((r) => r.name === next)) return false;
323
+ name = next;
324
+ render();
325
+ return true;
326
+ },
327
+ hide() { name = null; thumbToken++; cancelRepaint(); },
328
+ render,
329
+ detach() { writeFinish.cancel(); writeNative.cancel(); writeSee.cancel(); cancelRepaint(); thumbToken++; try { off(); } catch { /* gone */ } root.remove(); },
330
+ };
331
+ }
@@ -0,0 +1,120 @@
1
+ // src/framework/materials/overrides.js
2
+ // The viewer-side override of a sub-part's appearance, and the viewing state
3
+ // beside it. Pure: no three, no DOM (lint purity scans this folder).
4
+ //
5
+ // Two kinds of key, deliberately separate (spec A.1):
6
+ // - APPEARANCE keys merge over the authored `display` into effective(), which
7
+ // every material read uses — CAD, realistic, the throwaway thumbnail scene.
8
+ // A host may persist these into source.
9
+ // - VIEWING keys (`hidden`, `opacity`) are how the user is LOOKING. They never
10
+ // reach effective(), exports, lint or declaresMaterials, and no host saves
11
+ // them. `opacity` MULTIPLIES an authored display.opacity (the arithmetic
12
+ // animation opacity already uses), so a ghost can be faded further but not
13
+ // made solid from here.
14
+ import { DISPLAY_KEYS, OVERRIDE_RANGES } from "./resolve.js";
15
+
16
+ export const APPEARANCE_OVERRIDE_KEYS = DISPLAY_KEYS.filter((k) => k !== "opacity");
17
+ export const VIEWING_KEYS = ["hidden", "opacity"];
18
+ export const MIN_USER_OPACITY = 0.05;
19
+
20
+ const isObject = (x) => x !== null && typeof x === "object" && !Array.isArray(x);
21
+ const isColor = (v) => Number.isInteger(v) && v >= 0 && v <= 0xffffff;
22
+ const finite = (v) => typeof v === "number" && Number.isFinite(v);
23
+
24
+ // Only known keys survive; `null` is the "clear this key" value and survives
25
+ // as-is. Bad values are DROPPED (not clamped) where clamping would invent a
26
+ // choice: a non-string material, a colour out of range. Numeric overrides clamp
27
+ // to OVERRIDE_RANGES exactly as resolve.js clamps authored ones. `opacity`
28
+ // floors at MIN_USER_OPACITY and reads >= 1 as a clear; `hidden` must be a real
29
+ // boolean (a host's "false" string would otherwise read as truthy and hide the
30
+ // part), anything else is dropped.
31
+ export function sanitizeDisplayPatch(patch) {
32
+ if (!isObject(patch)) return {};
33
+ const out = {};
34
+ for (const [key, v] of Object.entries(patch)) {
35
+ if (v === null) {
36
+ if (APPEARANCE_OVERRIDE_KEYS.includes(key) || VIEWING_KEYS.includes(key)) out[key] = null;
37
+ continue;
38
+ }
39
+ if (key === "material") { if (typeof v === "string" && v.length > 0) out.material = v; continue; }
40
+ if (key === "color") { if (isColor(v)) out.color = v; continue; }
41
+ if (key === "hidden") { if (typeof v === "boolean") out.hidden = v; continue; }
42
+ if (key === "opacity") {
43
+ if (!finite(v)) continue;
44
+ out.opacity = v >= 1 ? null : Math.max(MIN_USER_OPACITY, v);
45
+ continue;
46
+ }
47
+ if (key in OVERRIDE_RANGES) {
48
+ if (!finite(v)) continue;
49
+ const [lo, hi] = OVERRIDE_RANGES[key];
50
+ out[key] = Math.min(hi, Math.max(lo, v));
51
+ }
52
+ }
53
+ return out;
54
+ }
55
+
56
+ // Merge a SANITIZED patch over the current override. `hidden: false` and
57
+ // `opacity: null` (or >= 1) are clears like any other null. Returns null when nothing is
58
+ // left, so a Map entry can be deleted rather than kept empty.
59
+ export function mergeOverride(current, patch) {
60
+ const next = { ...(isObject(current) ? current : {}) };
61
+ for (const [key, v] of Object.entries(patch ?? {})) {
62
+ if (v === null || (key === "hidden" && v === false) || (key === "opacity" && v >= 1)) delete next[key];
63
+ else next[key] = v;
64
+ }
65
+ return Object.keys(next).length ? next : null;
66
+ }
67
+
68
+ // The display every material read sees: authored, with the override's
69
+ // APPEARANCE keys on top. Viewing keys are never in it.
70
+ export function effectiveDisplay(authored, override) {
71
+ const out = isObject(authored) ? { ...authored } : {};
72
+ if (!isObject(override)) return out;
73
+ for (const key of APPEARANCE_OVERRIDE_KEYS) if (key in override) out[key] = override[key];
74
+ return out;
75
+ }
76
+
77
+ export function isAppearanceNoop(authored, override) {
78
+ if (!isObject(override)) return true;
79
+ const a = isObject(authored) ? authored : {};
80
+ return APPEARANCE_OVERRIDE_KEYS.every((key) => !(key in override) || override[key] === a[key]);
81
+ }
82
+
83
+ // A carried map, made safe for THIS part (spec A.4): names the part no longer
84
+ // declares go; appearance keys that now equal the authored value go (an owner's
85
+ // saved change self-cleans this way); every kept entry is re-sanitized, since
86
+ // the token came from a host; empties go.
87
+ export function pruneOverrides(map, part) {
88
+ const out = {};
89
+ if (!isObject(map)) return out;
90
+ for (const [name, raw] of Object.entries(map)) {
91
+ // Own names only: `parts` is a plain object, so "toString" or "constructor"
92
+ // would otherwise read as declared sub-parts and be replayed into the viewer.
93
+ const parts = part?.parts;
94
+ if (!isObject(parts) || !Object.hasOwn(parts, name)) continue;
95
+ const sp = parts[name];
96
+ if (!sp) continue;
97
+ const clean = mergeOverride(null, sanitizeDisplayPatch(raw));
98
+ if (!clean) continue;
99
+ const authored = isObject(sp.display) ? sp.display : {};
100
+ for (const key of APPEARANCE_OVERRIDE_KEYS) if (key in clean && clean[key] === authored[key]) delete clean[key];
101
+ if (Object.keys(clean).length) out[name] = clean;
102
+ }
103
+ return out;
104
+ }
105
+
106
+ // The one visibility decision viewer.js's applySubOpacity makes, as a pure
107
+ // function of its inputs. `anim` is the animation driver's opacity (undefined =
108
+ // none), `user` the viewing override's (undefined = none). `fade` is null when
109
+ // the mesh should sit on its base material, else the fade clone's opacity.
110
+ export function subPartVisibility({ shown, anim, user, hidden, renderMode, staticOpacity = 1 }) {
111
+ const off = { meshVisible: false, linesVisible: false, fade: null };
112
+ if (!shown || hidden) return off;
113
+ const a = anim === undefined ? 1 : anim;
114
+ const u = user === undefined ? 1 : user;
115
+ const v = a * u;
116
+ if (v <= 0) return off;
117
+ const linesVisible = renderMode === "cad";
118
+ if (v >= 1) return { meshVisible: true, linesVisible, fade: null };
119
+ return { meshVisible: true, linesVisible, fade: staticOpacity * v };
120
+ }
@@ -0,0 +1,33 @@
1
+ // Keys and bounds for the appearance card's sphere thumbnails. A tile depicts
2
+ // the PRESET, tinted: finish overrides (and see-through) are neither in the key
3
+ // nor in the render — re-rendering 25 spheres on every slider tick would make
4
+ // the card unusable, and a render that read them would cache one part's finish
5
+ // under a key every other part shares. thumbDisplay is what the render builds
6
+ // from, so it can depend on exactly what the key holds.
7
+ //
8
+ // An UNTINTABLE preset (brass, oak, ...) draws its own colour whatever the
9
+ // part's colour is, so its colour is dropped from both the render and the key:
10
+ // every part shares one sphere for it, and a colour pick re-renders only the
11
+ // tintable tiles. No material (and an unknown id) counts as tintable.
12
+ import { PRESETS } from "./presets.js";
13
+
14
+ const CAP = 120;
15
+ const tintable = (material) =>
16
+ typeof material !== "string" || !Object.hasOwn(PRESETS, material) || PRESETS[material].tintable === true;
17
+ export function thumbDisplay(display) {
18
+ const out = { material: display?.material };
19
+ if (display?.color !== undefined && tintable(display?.material)) out.color = display.color;
20
+ return out;
21
+ }
22
+ export function createMaterialThumbCache() {
23
+ const map = new Map();
24
+ return {
25
+ key: (display, env, theme, mode) => {
26
+ const look = thumbDisplay(display);
27
+ return `${look.material ?? ""}|${look.color ?? ""}|${env}|${theme}|${mode}`;
28
+ },
29
+ get(k) { const v = map.get(k); if (v !== undefined) { map.delete(k); map.set(k, v); } return v; },
30
+ set(k, url) { map.delete(k); map.set(k, url); if (map.size > CAP) map.delete(map.keys().next().value); },
31
+ clear() { map.clear(); },
32
+ };
33
+ }