partforge 0.146.4 → 0.149.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -169,6 +169,31 @@ Parking releases both large GPU allocations — the drawing buffer and the cache
169
169
  unchanged, so a host can still take a build screenshot of a hidden viewer; the
170
170
  first capture after parking just re-allocates its target.
171
171
 
172
+ **Show a cached preview while the kernel boots (0.147.0+).** `partforge/gltf`'s
173
+ `encodePreviewGlb({ meshes, part, view })` writes one view's posed display
174
+ meshes as a small GLB — welded, quantized and meshopt-compressed, one node per
175
+ sub-part, Y-up, millimetres — in Node, a page or a worker, and
176
+ `runtime.exportPreview()` encodes the view on screen with it. Hand the file back
177
+ as `mount()`'s `preview` and the viewer draws it before the worker is ready, then
178
+ swaps each sub-part for the live build in place without re-framing;
179
+ `runtime.previewActive` is true in between, while picking, measure, sketch and
180
+ cutaway wait for the live build. A file for another view, or one that will not
181
+ decode, is logged and ignored (a file written without a view is drawn on
182
+ sub-part name match alone). `mountPreview(container, { glb, elements,
183
+ viewerState })` is the kernel-free form — the same viewer, view cube, view style
184
+ and theme, with no part, no worker and no rail — for a page that only shows a
185
+ model (CAD look exact; realistic mode uses each sub-part's display only — no sheet
186
+ stock, no layer-line frames). Previews carry no edge lines, textures or patterns.
187
+ `resolveDefaultView(part)` (main entry and `partforge/testing`) names the view
188
+ `mount()` opens on, so a preview built headlessly matches it.
189
+
190
+ ```js
191
+ const glb = await runtime.exportPreview(); // Uint8Array: the view on screen
192
+ mount(part, { createWorker, elements, preview: glb }); // drawn at once, replaced by the build
193
+ const shown = mountPreview(stage, { glb }); // no kernel at all
194
+ await shown.ready;
195
+ ```
196
+
172
197
  Every `elements` entry defaults to the legacy global ID (`#app`, `#controls`,
173
198
  `#panel` for `rail`, `#status`/`#busy`/`#phase`, `#part`,
174
199
  `#download`/`#download-step`/`#download-3mf`,
@@ -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.146.4",
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",
@@ -62,6 +62,10 @@
62
62
  "types": "./types/panel-values.d.ts",
63
63
  "default": "./src/panel-values.js"
64
64
  },
65
+ "./gltf": {
66
+ "types": "./types/gltf.d.ts",
67
+ "default": "./src/gltf.js"
68
+ },
65
69
  "./tokens.css": "./src/framework/tokens.css",
66
70
  "./chrome.css": "./src/framework/chrome.css"
67
71
  },
@@ -90,6 +94,9 @@
90
94
  ],
91
95
  "panel-values": [
92
96
  "./types/panel-values.d.ts"
97
+ ],
98
+ "gltf": [
99
+ "./types/gltf.d.ts"
93
100
  ]
94
101
  }
95
102
  },
@@ -120,10 +127,14 @@
120
127
  }
121
128
  },
122
129
  "dependencies": {
130
+ "@gltf-transform/core": "^4.5.1",
131
+ "@gltf-transform/extensions": "^4.5.1",
132
+ "@gltf-transform/functions": "^4.5.1",
123
133
  "dompurify": "^3.4.11",
124
134
  "fflate": "^0.8.3",
125
135
  "manifold-3d": "^3.5.1",
126
136
  "marked": "^18.0.5",
137
+ "meshoptimizer": "^1.3.0",
127
138
  "opentype.js": "^2.0.0",
128
139
  "paper": "^0.12.18",
129
140
  "pngjs": "^7.0.0",
@@ -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
+ }
@@ -5,10 +5,13 @@
5
5
  // declaration order, so every part written before this existed resolves to the view
6
6
  // it has always opened on.
7
7
  //
8
- // Pure: no DOM, no storage, no kernel, no build. The headless surfaces (measure /
9
- // verify / renderViews) deliberately do NOT use this — they stay on
8
+ // Pure: no DOM, no storage, no kernel, no build. partforge's own headless surfaces
9
+ // (measure / verify / renderViews) deliberately do NOT use this — they stay on
10
10
  // Object.keys(part.views)[0], where a mechanically obvious rule matters more than a
11
- // convenient one.
11
+ // convenient one. It is exported (main entry and partforge/testing, 0.147.0) for a
12
+ // host that must open the SAME view mount() does — a cached preview built off the
13
+ // viewer has to match the live build it is swapped for. The signature is (part): it
14
+ // reads part.defaults, never caller params.
12
15
  import { inView } from "./sub-part-views.js";
13
16
 
14
17
  const isPlainObject = (x) => x !== null && typeof x === "object" && !Array.isArray(x);