partforge 0.134.0 → 0.136.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,204 @@
1
+ // src/framework/materials/sheet-look.js
2
+ // How a laser-cut sheet part looks in realistic mode — plain data and pure math, three-free
3
+ // and DOM-free like resolve.js, so the viewer, mount, lint and the tests all read ONE
4
+ // statement of it. patterns.js turns it into GLSL; nothing here knows about three.
5
+ //
6
+ // A laser sheetPart in a wood shows its burns: charred cut walls, scorched engrave and
7
+ // score floors, faces untouched. The shader tells those surfaces apart in the sheet's
8
+ // CANONICAL frame — profile in XY, material over z ∈ [0, t], laser face at z = t — but the
9
+ // viewer holds the DELIVERED display mesh. With no author `place`, sheetPart's generated
10
+ // place is exactly the rigid steps poseSteps(pose, t) (and there is no place at all with no
11
+ // pose), so the delivered mesh is that motion applied to the canonical solid and the frame
12
+ // is its inverse: computed from data, with no pose probe and no geometry. A sheet with an
13
+ // author place, a replaced place or a custom build is not trusted with a frame — it draws
14
+ // plain wood, never a frame that could char a face.
15
+ //
16
+ // It also says what a laser sheet part with no usable material looks like: not a PLA print
17
+ // (resolve.js's default for every other sub-part) but the sheet its stock label names —
18
+ // realisticDisplay, from the ONE keyword table STOCK_LOOKS. Realistic-only, like the PLA
19
+ // default: the CAD view, 3MF colours and declaresMaterials read sp.display itself.
20
+ import { isSheetPart, sheetMeta, MARK_DEPTH } from "../sheet/constants.js";
21
+ import { poseSteps, validatePose } from "../sheet/pose.js";
22
+ import { composePose, invertRigid } from "../geometry/pose.js";
23
+ import { resolveMaterial } from "./resolve.js";
24
+ import { PRESETS } from "./presets.js";
25
+
26
+ const IDENTITY = Object.freeze([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
27
+
28
+ // The burn's numbers, ONE statement: patterns.js templates the GLSL from them, and the JS
29
+ // twins below (classifySheetSurface, burnAlbedo) read the same object.
30
+ // wallNz a surface whose canonical |n.z| is below this band is a cut wall (the
31
+ // shader blends across the band; the JS twin splits at its middle). Creased
32
+ // normals are hard at a sheet's 90° arris, so real walls sit at n.z = 0.
33
+ // floorDepth an up-facing surface at least this far below the laser face is an engrave
34
+ // or score floor: half the preview's MARK_DEPTH, so the laser face (z = t) is
35
+ // never one and a mark's floor (z = t − MARK_DEPTH) always is.
36
+ // Char is RELATIVE to the face, all in linear RGB. A cut wall is the face's average colour
37
+ // mixed toward a char TARGET by k — kThin at ≤ tThin mm, kThick at ≥ tThick (thick stock takes
38
+ // a longer dwell) — plus up to `exit` more toward the exit side (z = 0); an engrave or score
39
+ // floor mixes `engraveLess` less. The target is PER CHANNEL: min(charcoal_i, charOfDark ·
40
+ // face_i) (burnAlbedo's `target`). For oak and plywood that is `charcoal` itself; charOfDark
41
+ // takes a channel over wherever face_i < charcoal_i / charOfDark — walnut's green and blue —
42
+ // so a dark or strongly tinted face still chars darker in every channel. Mixing toward
43
+ // `charcoal` alone would RAISE walnut's near-black blue, since charcoal's is lighter. Char and
44
+ // scorched floors take `roughness`, a matte finish, and shed any clearcoat; on plywood's cut
45
+ // walls a cross ply is `crossPly` of a face-grain ply.
46
+ // grain how much of the wood's grain shows through the char: each texel's ratio to
47
+ // the face average, blended in by `grain`, multiplies the average char (1, a
48
+ // pure multiply: every texel darkens by the same per-channel factor, so the
49
+ // average is unchanged and no texel comes out lighter; burnTexel is the twin).
50
+ // relief the share of the wood's surface relief (its normal map) that char and
51
+ // scorched floors keep; the rest fades to the geometric normal.
52
+ //
53
+ // The numbers were tuned by eye on the contact sheet in all four environments, inside the
54
+ // calibration floors test/framework/sheet-look.test.js holds them to. The twins see only the
55
+ // albedo. The render adds specular reflection — the same at every thickness — which no albedo
56
+ // can take away, and on walnut, whose char is near black, it is most of what a lit wall shows.
57
+ // So judge a retune on captures (scripts/capture-contact-sheet.mjs) compared at the SAME place
58
+ // on the sheet, never on the twins alone: swatches in different places reflect different
59
+ // light. The design spec records the measurements.
60
+ export const BURN = Object.freeze({
61
+ wallNz: Object.freeze([0.35, 0.65]),
62
+ floorDepth: MARK_DEPTH / 2,
63
+ charcoal: 0x262220,
64
+ kThin: 0.74,
65
+ kThick: 0.97,
66
+ tThin: 3,
67
+ tThick: 9,
68
+ exit: 0.1,
69
+ engraveLess: 0.1,
70
+ charOfDark: 0.37,
71
+ roughness: 0.68,
72
+ crossPly: 0.78,
73
+ grain: 1,
74
+ relief: 0.35,
75
+ });
76
+
77
+ // sRGB 0xRRGGBB → linear [r, g, b]: the conversion three applies to a colour uniform.
78
+ export function srgbToLinear(hex) {
79
+ return [(hex >> 16) & 255, (hex >> 8) & 255, hex & 255].map((c) => {
80
+ const s = c / 255;
81
+ return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
82
+ });
83
+ }
84
+
85
+ // Plies in a birch-plywood sheet t mm thick: 3 mm → 3, 6 → 5, 9 → 7, 12 → 9. Always odd, so
86
+ // both outer plies run with the face grain.
87
+ const PLY_STEPS = Object.freeze([[4.5, 3], [7.5, 5], [10.5, 7]]);
88
+ export function plyCount(t) {
89
+ for (const [below, plies] of PLY_STEPS) if (t < below) return plies;
90
+ return 9;
91
+ }
92
+
93
+ // The material realistic mode draws this sub-part in.
94
+ const lookOf = (sp) => resolveMaterial(realisticDisplay(sp));
95
+
96
+ // A laser sheet with no usable material takes its look from its stock label: the first row
97
+ // with a stem that STARTS a word of the label (ignoring case; a hyphenated word is also read
98
+ // joined, so "poly-carbonate" is one word and "clear-acrylic" still has "acrylic"), else
99
+ // DEFAULT_STOCK_LOOK — plywood for birch, basswood, poplar, MDF, hardboard or anything
100
+ // unlabelled. Word starts, not substrings, so "Plexiglas" and "Acrylite" match while
101
+ // "perplexing" does not. A residual, named in the guide: stock that is neither wood nor
102
+ // acrylic (felt, leather, card, cork) takes the plywood look too, burns and all. Only a
103
+ // string label is read: a (p, d) function would make a sub-part's look (and its program)
104
+ // depend on the params it was first drawn at, so it counts as unlabelled.
105
+ export const STOCK_LOOKS = Object.freeze([
106
+ Object.freeze({ look: "clear-acrylic", words: Object.freeze([
107
+ "acryl", "perspex", "plexi", "pmma", "methacryl", "polycarb", "lexan", "makrolon", "lucite",
108
+ ]) }),
109
+ ]);
110
+ export const DEFAULT_STOCK_LOOK = "plywood";
111
+
112
+ const labelWords = (label) => {
113
+ const lower = label.toLowerCase();
114
+ return [...lower.split(/[^\p{L}]+/u), ...lower.replace(/-/g, "").split(/[^\p{L}]+/u)].filter(Boolean);
115
+ };
116
+
117
+ export function stockLook(stock) {
118
+ if (typeof stock !== "string") return DEFAULT_STOCK_LOOK;
119
+ const words = labelWords(stock);
120
+ return STOCK_LOOKS.find((row) => row.words.some((stem) => words.some((w) => w.startsWith(stem))))?.look ?? DEFAULT_STOCK_LOOK;
121
+ }
122
+
123
+ // The stock's look for a laser sheet part, else null (lint names it in its fallback hint).
124
+ export function sheetStockLook(sp) {
125
+ return isSheetPart(sp) && sp.sheet.process === "laser" ? stockLook(sp.sheet.material) : null;
126
+ }
127
+
128
+ // The display realistic mode draws: sp.display itself, except that a laser sheet part
129
+ // naming no KNOWN material — none, or one the library does not know, the same "no usable
130
+ // material" resolve.js treats as one case — gets its stock's look. A `color` rides along,
131
+ // so a bare colour tints that look (stained plywood, coloured acrylic).
132
+ export function realisticDisplay(sp) {
133
+ const display = sp?.display;
134
+ const look = sheetStockLook(sp);
135
+ if (!look) return display;
136
+ if (typeof display?.material === "string" && Object.hasOwn(PRESETS, display.material)) return display;
137
+ return { ...(display && typeof display === "object" ? display : {}), material: look };
138
+ }
139
+
140
+ // Does this sub-part carry the burn pass at all? Static — it reads no params — so the
141
+ // viewer decides a sub-part's PROGRAM once, and every sub-part that answers false keeps
142
+ // exactly the program it had before the burn existed.
143
+ export function burnsFor(sp) {
144
+ if (!isSheetPart(sp) || sp.sheet.process !== "laser") return false;
145
+ if (sp.build !== sp.sheet.generatedBuild) return false; // a custom build is not the canonical sheet
146
+ if (sp.sheet.place != null) return false; // an author place moves it off its pose
147
+ if ((sp.place ?? null) !== (sp.sheet.generatedPlace ?? null)) return false; // place replaced after sheetPart
148
+ try { return lookOf(sp).params.pattern === "wood"; } catch { return false; }
149
+ }
150
+
151
+ // At (p, d) — a delivery's params — the frame that maps the delivered display mesh's
152
+ // object space into the canonical sheet frame, the sheet's thickness, and how many plies
153
+ // its cut walls show (0 unless its preset is `laminated`). null when burnsFor says no or
154
+ // the pose or thickness cannot be read (the build reports those).
155
+ export function sheetFrameFor(sp, { p, d } = {}) {
156
+ if (!burnsFor(sp)) return null;
157
+ const meta = sheetMeta(sp, p, d);
158
+ if (!meta) return null;
159
+ const t = meta.thickness;
160
+ let pose;
161
+ try { pose = typeof sp.sheet.pose === "function" ? sp.sheet.pose(p, d) : sp.sheet.pose; } catch { return null; }
162
+ if (pose != null && validatePose(pose)) return null;
163
+ const frame = pose == null ? [...IDENTITY] : invertRigid(composePose(poseSteps(pose, t)));
164
+ return { frame, t, plies: lookOf(sp).preset.laminated ? plyCount(t) : 0 };
165
+ }
166
+
167
+ // The shader's surface classification, in JS: the frame tests hold real geometry to it.
168
+ // `pos` and `normal` are canonical (after the frame); `t` is the thickness.
169
+ export function classifySheetSurface(pos, normal, t) {
170
+ if (Math.abs(normal[2]) < (BURN.wallNz[0] + BURN.wallNz[1]) / 2) return "wall";
171
+ if (normal[2] < 0) return "back";
172
+ return pos[2] <= t - BURN.floorDepth ? "floor" : "face";
173
+ }
174
+
175
+ const clamp01 = (x) => Math.min(1, Math.max(0, x));
176
+
177
+ // The shader's burnt albedo, in JS (the calibration test's twin — same numbers, same
178
+ // formula): `face` is the unburnt face's average colour in linear RGB (the preset's texture
179
+ // average times any tint), `t` the sheet thickness (mm), `kind` "edge" — a cut wall, `zFrac`
180
+ // of the way from the exit side (0) up to the laser face (1) — or "engrave", a mark's floor.
181
+ // Ply bands are left out: they only modulate an edge. It returns the AVERAGE char — what the
182
+ // face's average colour burns to. The shader then multiplies that by each texel's ratio to
183
+ // the face average (texel ÷ face, blended by BURN.grain), so the grain shows through;
184
+ // burnTexel below is that step's twin.
185
+ export function burnAlbedo(face, t, { kind = "edge", zFrac = 1 } = {}) {
186
+ const charcoal = srgbToLinear(BURN.charcoal);
187
+ // The mix target, PER CHANNEL: whichever is darker of raw charcoal or charOfDark of this
188
+ // channel's own face value — never one colour for the whole vector, which a channel darker
189
+ // than that colour would be pulled UP toward (the BURN comment above). Multiplicative in the
190
+ // channel that governs it, so charring a face darker than charcoal in that channel still
191
+ // only ever removes light from it.
192
+ const target = charcoal.map((c, i) => Math.min(c, BURN.charOfDark * face[i]));
193
+ const k = BURN.kThin + (BURN.kThick - BURN.kThin) * clamp01((t - BURN.tThin) / (BURN.tThick - BURN.tThin));
194
+ const amount = kind === "engrave" ? k - BURN.engraveLess : Math.min(1, k + BURN.exit * (1 - clamp01(zFrac)));
195
+ return face.map((f, i) => f + (target[i] - f) * amount);
196
+ }
197
+
198
+ // One texel's burnt albedo, in JS — the shader's pfGrain step on top of burnAlbedo: `texel`
199
+ // is the unburnt texel in linear RGB (the wood texture times any tint), the rest is
200
+ // burnAlbedo's. The average char times mix(1, texel ÷ face, BURN.grain), per channel, with
201
+ // the shader's 1e-4 floor under the divide. At grain 1 that is texel × (char ÷ face).
202
+ export function burnTexel(texel, face, t, opts) {
203
+ return burnAlbedo(face, t, opts).map((c, i) => c * (1 + (texel[i] / Math.max(face[i], 1e-4) - 1) * BURN.grain));
204
+ }
@@ -13,6 +13,7 @@ import { attachViewStyleControls } from "./view-style-controls.js";
13
13
  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
+ import { burnsFor, realisticDisplay, sheetFrameFor } from "./materials/sheet-look.js";
16
17
  import { buildControls } from "./controls.js";
17
18
  import { relevantParamKeys } from "./param-deps.js";
18
19
  import { createMeshCache } from "./mesh-cache.js";
@@ -240,7 +241,8 @@ function createCleanupStack() {
240
241
  // mesh-validity cache, and the geometry workers. The app supplies `createWorker(name)`
241
242
  // so Vite can bundle the worker (see geometry-service.js).
242
243
  //
243
- // Embedding contract (0.45.0):
244
+ // Embedding contract (0.136.0: the font control commits once per picker
245
+ // session, on close, not per pick):
244
246
  // const runtime = mount(part, { createWorker, elements, onBuild, onPick, onDownload, onViewChange });
245
247
  // await runtime.ready; // first successful build of the default view
246
248
  // runtime.setParams({ openAngle: 45 }); // programmatic edit; pose-only changes apply instantly
@@ -863,6 +865,12 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
863
865
  // to its own worker in parallel, so a mixed part previews its plain sub-parts at
864
866
  // Manifold speed while only the OCCT-routed ones wait. The return value is
865
867
  // the job count — the loop holds the cycle open until every group has replied.
868
+ // Each dispatched sub-part remembers the { view, params } its job was sent with
869
+ // (`dispatched`): the frames the realistic look reads must describe the geometry
870
+ // that job builds, and a delivery shown during playback was built at params the
871
+ // live ones have already left (see recordFrames). One cycle is in flight at a time,
872
+ // so a sub-part's entry is its in-flight job's until the cycle's last reply.
873
+ const dispatched = new Map(); // sub-part -> { view, params } its latest job was sent with
866
874
  const loop = createRegenLoop({
867
875
  missingParts,
868
876
  send: (missing) => {
@@ -870,6 +878,8 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
870
878
  // for the overlay; this build reports the poses repaired in its own cycle.
871
879
  lastGen = { skipped: needed.length - missing.length, rebuilt: missing.length, posed: pendingPosed.size };
872
880
  pendingPosed.clear(); // consumed — never counted against a second build
881
+ const at = { view: view(), params: { ...params } };
882
+ for (const n of missing) dispatched.set(n, at);
873
883
  ui.showBusy("generating");
874
884
  const backends = backendPolicy.backendsFor(params);
875
885
  let jobs = 0;
@@ -951,44 +961,58 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
951
961
  }
952
962
  }
953
963
 
954
- // Print frames for the layer-line pattern: the display → export map of the
955
- // geometry just delivered, so layers run the way the part is printed rather
956
- // than the way it is displayed. Each frame describes the delivered mesh
957
- // (which a later pose-only repair only moves), and only sub-parts whose
958
- // material draws layer lines have one — which includes every
959
- // sub-part naming no material, since realistic mode shows those as PLA
960
- // (resolve.js). One without a place() is identity at once; one with it
961
- // costs two geometry-free probe builds.
964
+ // Frames the realistic look reads, for the geometry just delivered:
965
+ // - print frames for the layer-line pattern: the display → export map, so layers run
966
+ // the way the part is printed rather than the way it is displayed. Only sub-parts
967
+ // whose material draws layer lines have one — which includes every sub-part naming
968
+ // no material, since realistic mode shows those as PLA (resolve.js) — except a laser
969
+ // sheet part, which it shows as its stock (sheet-look.js realisticDisplay). One
970
+ // without a place() is identity at once; one with it costs two geometry-free probe
971
+ // builds.
972
+ // - sheet frames for the burn pass (materials/sheet-look.js): a laser sheet in a wood,
973
+ // mapped back into its canonical frame — from data, no probe.
974
+ // Each describes the delivered mesh (which a later pose-only repair only moves).
962
975
  const layerLined = new Set(Object.keys(part.parts).filter((n) => {
963
- try { return resolveMaterial(part.parts[n].display).params.pattern === "layer-lines"; } catch { return false; }
976
+ try { return resolveMaterial(realisticDisplay(part.parts[n])).params.pattern === "layer-lines"; } catch { return false; }
964
977
  }));
965
- // LAZY: a delivery only records the view and params it was built at; the
966
- // viewer pulls the frames (computePrintFrames) when it is about to draw the
967
- // realistic look — live, loading, or borrowed by a capture — so a CAD-only
968
- // session never probes (viewer.js setPrintFrameSource). The snapshot keeps
969
- // a late computation describing the mesh actually delivered.
978
+ const burning = new Set(Object.keys(part.parts).filter((n) => burnsFor(part.parts[n])));
979
+ // LAZY: a delivery only records the view and params its job was DISPATCHED with
980
+ // (`dispatched`, set where the loop sends) — not the live ones, which a delivery shown
981
+ // during playback was never built at; the viewer pulls the frames (computeFrames)
982
+ // when it is about to draw the realistic look — live, loading, or borrowed by a
983
+ // capture — so a CAD-only session computes nothing (viewer.js setFrameSource). The
984
+ // snapshot keeps a late computation describing the mesh actually delivered.
970
985
  const printFrames = {};
971
- const undrawnFrames = new Map(); // sub-part -> { view, params } at its delivery
972
- function recordPrintFrames(names) {
973
- const wanted = names.filter((n) => layerLined.has(n));
986
+ const sheetFrames = {};
987
+ const undrawnFrames = new Map(); // sub-part -> { view, params } its delivered mesh was built at
988
+ function recordFrames(names) {
989
+ const wanted = names.filter((n) => layerLined.has(n) || burning.has(n));
974
990
  if (!wanted.length) return;
975
- const at = { view: view(), params: { ...params } };
976
- for (const n of wanted) undrawnFrames.set(n, at);
977
- viewer.invalidatePrintFrames?.();
991
+ for (const n of wanted) undrawnFrames.set(n, dispatched.get(n) ?? null); // null: built at params nobody recorded
992
+ viewer.invalidateFrames?.();
978
993
  }
979
- function computePrintFrames() {
994
+ function computeFrames() {
980
995
  const resolvedFor = new Map(); // one resolveParams per delivery, not per sub-part
981
996
  for (const [n, at] of undrawnFrames) {
982
- if (!resolvedFor.has(at)) {
997
+ if (at && !resolvedFor.has(at)) {
983
998
  try { resolvedFor.set(at, resolveParams(part, at.params)); } catch { resolvedFor.set(at, null); } // diagnosed by the build
984
999
  }
985
- const resolved = resolvedFor.get(at);
986
- if (resolved) printFrames[n] = printFrameMatrix(part.parts[n], { view: at.view, ...resolved });
1000
+ const resolved = at ? resolvedFor.get(at) : null;
1001
+ // No params to frame this mesh by: a burning sheet draws plain wood rather than
1002
+ // keep a frame that describes other geometry — a sub-millimetre lag along its
1003
+ // normal is enough to char a whole face. A print frame keeps its last value (the
1004
+ // worst it can do is run layer lines the wrong way).
1005
+ if (!resolved) { delete sheetFrames[n]; continue; }
1006
+ if (layerLined.has(n)) printFrames[n] = printFrameMatrix(part.parts[n], { view: at.view, ...resolved });
1007
+ if (burning.has(n)) {
1008
+ const f = sheetFrameFor(part.parts[n], resolved);
1009
+ if (f) sheetFrames[n] = f; else delete sheetFrames[n];
1010
+ }
987
1011
  }
988
1012
  undrawnFrames.clear();
989
- return { ...printFrames };
1013
+ return { print: { ...printFrames }, sheet: { ...sheetFrames } };
990
1014
  }
991
- viewer.setPrintFrameSource?.(computePrintFrames);
1015
+ viewer.setFrameSource?.(computeFrames);
992
1016
 
993
1017
  // Sub-parts whose latest fresh delivery had zero triangles (see the `meshes` case).
994
1018
  const emptySubParts = new Set();
@@ -1051,7 +1075,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
1051
1075
  // delivered, which buildDone() true guarantees is at the live params.
1052
1076
  fastPath.recordDelivered(m.name);
1053
1077
  }
1054
- recordPrintFrames(data.meshes.map((m) => m.name));
1078
+ recordFrames(data.meshes.map((m) => m.name));
1055
1079
  // A split dispatch answers in two meshes replies; the busy spinner
1056
1080
  // stays up until the view has everything (the other worker's job may
1057
1081
  // still be running — often OCCT, the slow one).
@@ -1082,10 +1106,14 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
1082
1106
  } else if (lastAnimApplyVersion === loop.version()) {
1083
1107
  // Stale ONLY because animation frames kept bumping the version:
1084
1108
  // show the delivered meshes anyway — that IS best-effort playback —
1085
- // but record NOTHING. Cache and fast-path stamps must describe
1086
- // geometry built at the live params, and this delivery wasn't; the
1087
- // fast-path stamp is dropped too, so a later pose-only repair can
1088
- // never re-pose this newer geometry off an older delivery's stamp.
1109
+ // but record nothing about the LIVE params. Cache and fast-path stamps
1110
+ // must describe geometry built at the live params, and this delivery
1111
+ // wasn't; the fast-path stamp is dropped too, so a later pose-only
1112
+ // repair can never re-pose this newer geometry off an older delivery's
1113
+ // stamp. Its material frames are the exception: those are recorded
1114
+ // against the params its job was dispatched with, which is what it was
1115
+ // built at — keeping the last fresh delivery's frame instead would lay a
1116
+ // sheet's burn over geometry that has moved along its normal.
1089
1117
  // A user edit mid-play pauses playback and bumps the version WITHOUT
1090
1118
  // touching lastAnimApplyVersion, so a genuinely user-stale result
1091
1119
  // fails this test and is discarded exactly as before.
@@ -1093,6 +1121,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
1093
1121
  viewer.setSubGeometry(m.name, m);
1094
1122
  fastPath.forget(m.name);
1095
1123
  }
1124
+ recordFrames(data.meshes.map((m) => m.name));
1096
1125
  ui.hideBusy();
1097
1126
  refreshView();
1098
1127
  }
@@ -52,7 +52,19 @@ const listVariant = (f) =>
52
52
  // through here.
53
53
  let openPicker = null;
54
54
 
55
- export function openFontPicker({ node, params, allow, fontCatalog, anchor, onPicked }) {
55
+ // At most this many weights beyond the selected one load their real face per
56
+ // family opened (the previous family's variant faces are released when another
57
+ // opens, so resident variant faces stay <= 1 + this). A COUNT, not bytes: the cloud catalog reports `bytes: 0` for every
58
+ // variant, so a byte budget would never bind (spec 2026-10-01 §3). Rows past
59
+ // the budget render in the menu face with the weight synthesized.
60
+ export const VARIANT_FACE_BUDGET = 6;
61
+
62
+ // The menu face the last closed picker chose to keep (the widget label renders
63
+ // in it). The next picker adopts it, so a session neither re-fetches that face
64
+ // nor orphans it: its own close() deletes it if the selection moved on.
65
+ let keptFace = null;
66
+
67
+ export function openFontPicker({ node, params, allow, fontCatalog, anchor, onPicked, onClose }) {
56
68
  // Takeover: the picker covers the rail on desktop and the single visible pane
57
69
  // below the narrow breakpoint. One layout for both widths (spec §6).
58
70
  const host = anchor?.closest?.(".pf-rail") ?? anchor?.parentElement ?? document.body;
@@ -148,6 +160,33 @@ export function openFontPicker({ node, params, allow, fontCatalog, anchor, onPic
148
160
  // missing one must degrade to un-styled rows, never throw.
149
161
  const canLoadFaces = () => typeof FontFace === "function" && typeof document.fonts?.add === "function";
150
162
 
163
+ const addedFaces = []; // every FontFace this picker put in document.fonts
164
+ let variantBudget = VARIANT_FACE_BUDGET;
165
+ const variantList = []; // {face, url} for the ONE family whose variants are open
166
+ let variantFamily = null;
167
+ if (keptFace) {
168
+ addedFaces.push(keptFace);
169
+ faceRequested.add(keptFace.family);
170
+ faceSettled.add(keptFace.family);
171
+ keptFace = null;
172
+ }
173
+ // A face that finishes loading after close() would otherwise land in
174
+ // document.fonts with nobody left to delete it. The selected family's menu
175
+ // face is the one exception: it is kept on close by design.
176
+ const track = (loaded) => {
177
+ if (closed && (loaded.family !== selFamily || keptFace)) return;
178
+ document.fonts.add(loaded);
179
+ if (!closed) addedFaces.push(loaded);
180
+ else keptFace = loaded; // landed after close: hand it on
181
+ };
182
+ function releaseVariantFaces() {
183
+ for (const { face, url } of variantList) {
184
+ variantFaces.delete(url);
185
+ try { document.fonts.delete?.(face); } catch { /* already gone */ }
186
+ }
187
+ variantList.length = 0;
188
+ }
189
+
151
190
  function settle(family) {
152
191
  faceSettled.add(family);
153
192
  if (closed) return;
@@ -167,7 +206,7 @@ export function openFontPicker({ node, params, allow, fontCatalog, anchor, onPic
167
206
  let face;
168
207
  try { face = new FontFace(f.family, `url(${f.menuUrl})`); } catch { settle(f.family); continue; }
169
208
  face.load()
170
- .then((loaded) => { document.fonts.add(loaded); })
209
+ .then((loaded) => { track(loaded); })
171
210
  .catch(() => { /* a family that will not load stays in the panel font */ })
172
211
  .then(() => settle(f.family));
173
212
  }
@@ -177,14 +216,22 @@ export function openFontPicker({ node, params, allow, fontCatalog, anchor, onPic
177
216
  // family name's glyphs at one weight, so it cannot show what 700 looks like.
178
217
  // Each face is registered under `<family> <variant>` so the weights do not
179
218
  // collide with each other or with the menu face.
180
- function requestVariantFace(family, v) {
219
+ function requestVariantFace(family, v, { selected = false } = {}) {
181
220
  if (!canLoadFaces()) return;
182
221
  if (variantFaces.has(v.url) || !fontSourceAllowed(v.url, allow)) return;
183
222
  if (Number.isFinite(v.bytes) && v.bytes > VARIANT_FACE_MAX_BYTES) return;
223
+ if (!selected) {
224
+ if (variantBudget <= 0) return;
225
+ variantBudget -= 1;
226
+ }
184
227
  variantFaces.add(v.url);
185
228
  let face;
186
229
  try { face = new FontFace(`${family} ${v.variant}`, `url(${v.url})`); } catch { return; }
187
- face.load().then((loaded) => document.fonts.add(loaded)).catch(() => {});
230
+ face.load().then((loaded) => {
231
+ if (closed || variantFamily !== family) { variantFaces.delete(v.url); return; }
232
+ document.fonts.add(loaded);
233
+ variantList.push({ face: loaded, url: v.url });
234
+ }).catch(() => {});
188
235
  }
189
236
 
190
237
  // ── the list ────────────────────────────────────────────────────────────
@@ -337,6 +384,11 @@ export function openFontPicker({ node, params, allow, fontCatalog, anchor, onPic
337
384
 
338
385
  function openVariants(f) {
339
386
  openFamily = f;
387
+ if (variantFamily !== f.family) {
388
+ releaseVariantFaces();
389
+ variantFamily = f.family;
390
+ variantBudget = VARIANT_FACE_BUDGET;
391
+ }
340
392
  vtitle.textContent = f.family;
341
393
  vlist.textContent = "";
342
394
  for (const v of f.variants) {
@@ -351,9 +403,15 @@ export function openFontPicker({ node, params, allow, fontCatalog, anchor, onPic
351
403
  // Commit WITHOUT leaving — you audition weights against the live
352
404
  // geometry, so committing and navigating are separate actions (spec §6).
353
405
  b.addEventListener("click", () => commit(f, v));
354
- requestVariantFace(f.family, v);
355
406
  vlist.append(b);
356
407
  }
408
+ // Nearest weight to the selected one first, so the budget lands on the
409
+ // weights the user is most likely to compare.
410
+ const weightOf = (v) => Number(String(v.variant).replace(/i(talic)?$/, "")) || 400;
411
+ const selV = pickVariant(f);
412
+ const order = [...f.variants].sort((a, b) =>
413
+ (a === selV ? -1 : b === selV ? 1 : Math.abs(weightOf(a) - weightOf(selV)) - Math.abs(weightOf(b) - weightOf(selV))));
414
+ for (const v of order) requestVariantFace(f.family, v, { selected: v === selV });
357
415
  vlist.scrollTop = 0;
358
416
  picker.classList.add("at-variants");
359
417
  }
@@ -378,8 +436,21 @@ export function openFontPicker({ node, params, allow, fontCatalog, anchor, onPic
378
436
  closed = true;
379
437
  clearTimeout(debounce);
380
438
  document.removeEventListener("keydown", onKey);
439
+ // Faces are the picker's, not the page's: every one auditioned would
440
+ // otherwise stay resident until the iframe dies (the 2026-10 Safari tab
441
+ // kill). Keep only the selected family's MENU face — the widget's button
442
+ // label renders in it.
443
+ if (typeof document.fonts?.delete === "function") {
444
+ for (const face of addedFaces) {
445
+ if (face.family === selFamily && !keptFace) { keptFace = face; continue; }
446
+ try { document.fonts.delete(face); } catch { /* already gone */ }
447
+ }
448
+ }
449
+ releaseVariantFaces();
381
450
  picker.remove();
382
451
  if (openPicker === handle) openPicker = null;
452
+ // After removal, so a host reacting to the close sees the rail as it will be.
453
+ try { onClose?.(); } catch { /* host's problem, not the picker's */ }
383
454
  }
384
455
  function onKey(ev) {
385
456
  if (ev.key !== "Escape") return;
@@ -145,9 +145,21 @@ export function makeFont(node, params, { onChange, onCommit, info, fontCatalog,
145
145
  // the rail, outside the panel root, so tearing the panel down does not take it
146
146
  // with it. Without dispose() the element — and the `document` keydown listener
147
147
  // that only close() unhooks — would outlive the panel holding a stale `params`.
148
+ //
149
+ // A picker SESSION is one commit (spec 2026-10-01 §2): every pick rebuilds
150
+ // live through onChange, and the commit — "the user finished", the same
151
+ // split the slider makes on release — fires once, when the picker closes
152
+ // (Done, ×, Escape, a re-open superseding it, or dispose), and only if the
153
+ // value actually moved. A host that persists on commit (partforge-cloud
154
+ // vendors and saves) then pays once per session instead of once per pick.
148
155
  let picker = null;
149
156
  btn.addEventListener("click", () => {
150
- picker = openFontPicker?.({ node, params, allow, fontCatalog, anchor: wrap, onPicked: () => { paint(); onChange?.(); onCommit?.(); } }) ?? null;
157
+ const atOpen = params[node.key];
158
+ picker = openFontPicker?.({
159
+ node, params, allow, fontCatalog, anchor: wrap,
160
+ onPicked: () => { paint(); onChange?.(); },
161
+ onClose: () => { picker = null; if (params[node.key] !== atOpen) onCommit?.(); },
162
+ }) ?? null;
151
163
  });
152
164
 
153
165
  // Ambient: this branch already has the catalog button as its visible way in, so
@@ -9,6 +9,11 @@
9
9
  // `sp.sheet.generatedBuild` is the build made here; an author who replaces `build`
10
10
  // afterwards is detectable as `sp.build !== sp.sheet.generatedBuild`.
11
11
  //
12
+ // Likewise `sp.sheet.generatedPlace` is the place made here (the pose, then the author's
13
+ // own) and `sp.sheet.place` the author's own place, or null: the realistic look
14
+ // (materials/sheet-look.js) trusts a sheet's canonical frame only with no author place
15
+ // and `sp.place === sp.sheet.generatedPlace`.
16
+ //
12
17
  // Placement (decision 10): the pose applies for display AND export, then the author's
13
18
  // own `place` — so STEP/3MF/STL files of a sheet forge come out assembled.
14
19
  import { RENAMED_KEYS, RESERVED_KEYS, SHEET_KEYS, SUBPART_PASSTHROUGH_KEYS } from "./constants.js";
@@ -67,32 +72,42 @@ export function sheetPart(spec) {
67
72
  const s = resolveSheet(k, sub, p, d);
68
73
  return (processById(s.process).preview ?? sheetPreview)(k, s);
69
74
  };
75
+
76
+ // Placement (decision 10): the pose applies for display AND export, then the author's own
77
+ // place. Made BEFORE the record is frozen, so the record can name it. Everything the
78
+ // place reads is taken NOW, into the same values the record freezes — never read off
79
+ // `spec` later, which an author may reuse and change for the next panel: the build and
80
+ // the burn's frame both read the frozen record, and a place that read the spec live would
81
+ // move the panel away from them.
82
+ const pose = spec.pose ?? null;
83
+ const thickness = spec.thickness;
84
+ const authorPlace = spec.place ?? null;
85
+ const generatedPlace = pose !== null || authorPlace ? (solid, ctx) => {
86
+ const resolved = typeof pose === "function" ? pose(ctx.p, ctx.d) : pose;
87
+ let posed = solid;
88
+ if (resolved !== null) {
89
+ const reason = validatePose(resolved);
90
+ if (reason) throw new Error(`sheet pose: ${reason}`);
91
+ const t = typeof thickness === "function" ? thickness(ctx.p, ctx.d) : thickness;
92
+ posed = applyPose(solid, resolved, t);
93
+ }
94
+ return authorPlace ? authorPlace(posed, ctx) : posed;
95
+ } : null;
96
+
70
97
  const sheet = Object.freeze({
71
98
  process,
72
99
  material: spec.material,
73
- thickness: spec.thickness,
100
+ thickness,
74
101
  profile: spec.profile,
75
102
  score: spec.score ?? null,
76
103
  engrave: spec.engrave ?? null,
77
- pose: spec.pose ?? null,
104
+ pose,
105
+ place: authorPlace,
78
106
  generatedBuild,
107
+ generatedPlace,
79
108
  });
80
109
  sub.build = generatedBuild;
81
-
82
- const authorPlace = spec.place;
83
- if (sheet.pose !== null || authorPlace) {
84
- sub.place = (solid, ctx) => {
85
- const pose = typeof sheet.pose === "function" ? sheet.pose(ctx.p, ctx.d) : sheet.pose;
86
- let posed = solid;
87
- if (pose !== null) {
88
- const reason = validatePose(pose);
89
- if (reason) throw new Error(`sheet pose: ${reason}`);
90
- const t = typeof sheet.thickness === "function" ? sheet.thickness(ctx.p, ctx.d) : sheet.thickness;
91
- posed = applyPose(solid, pose, t);
92
- }
93
- return authorPlace ? authorPlace(posed, ctx) : posed;
94
- };
95
- }
110
+ if (generatedPlace) sub.place = generatedPlace;
96
111
  sub.sheet = sheet;
97
112
  return sub;
98
113
  }
@@ -19,8 +19,9 @@ const cache = new Map(); // source → Promise<Uint8Array> (raw bytes)
19
19
  // source → { units, shapes }. The bytes memo above stops a refetch; this stops a re-PARSE
20
20
  // (UTF-8 decode + JSON.parse + validation + a bbox recomputation that tessellates
21
21
  // every contour). Without it every regen of a part with artwork redoes all of that.
22
- // Fonts solve the same problem with kernel._fontsBySource (jobs.js:206-212) and
23
- // imports with a digest comparison; this is the third pipeline's version of it.
22
+ // Fonts solve the same problem with kernel._fontsBySource (jobs.js:277-312), a bounded
23
+ // LRU keyed by source and the only holder of a parsed font (the resolver's memo is
24
+ // transient); imports use a digest comparison. This is the third pipeline's version.
24
25
  // Keyed on the SOURCE, not the name: one worker outlives many parts, and a name is
25
26
  // not an identity. Only successes are cached — a parse failure must throw again
26
27
  // under the next name that declares it, with that name in the message.