partforge 0.135.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.135.0",
3
+ "version": "0.136.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",
@@ -31,3 +31,11 @@ export async function resolveFonts(fontsDecl) {
31
31
  }
32
32
  return resolveDecl(fontsDecl, resolveOne);
33
33
  }
34
+
35
+ // Drop one source's memo entry. jobs.js calls this once a font is PARSED: the
36
+ // parsed Font is what the kernel keeps (kernel._fontsBySource, bounded), so
37
+ // holding the raw ArrayBuffer here as well doubled the cost of every font a
38
+ // session ever auditioned and released none of it (spec 2026-10-01 §3).
39
+ export function forgetFontSource(source) {
40
+ cache.delete(source);
41
+ }
@@ -6,7 +6,7 @@ import { meshTo3MF } from "./geometry/threemf.js";
6
6
  import { printColor } from "./materials/resolve.js";
7
7
  import { exportablePartNames } from "./export-select.js";
8
8
  import { fontControlAllows, fontSourceAllowed, isNoFontSource } from "./font-source.js";
9
- import { fontsFor, resolveFonts } from "./fonts.js";
9
+ import { fontsFor, resolveFonts, forgetFontSource } from "./fonts.js";
10
10
  import { normalizeOpentype, parseFont } from "./geometry/opentype-interop.js";
11
11
  import { imageControlAllows, imageSourceAllowed, isNoImageSource } from "./image-source.js";
12
12
  import { imagesFor, ensureImages } from "./images.js";
@@ -16,6 +16,8 @@ import { vectorControlAllows, vectorSourceAllowed, isNoVectorSource } from "./ve
16
16
  import { vectorsFor, ensureVectors } from "./vectors.js";
17
17
  import { exportSubParts, resolveParams, buildPosed } from "./part-model.js";
18
18
 
19
+ export const FONT_SOURCE_CACHE_MAX = 4;
20
+
19
21
  // The oracle loads LAZILY, per job family, never at worker boot. It is the largest
20
22
  // JS payload in the worker's graph (measure/verify/build and silhouette/match), and
21
23
  // only the `inspect` job runs any of it — the generate/export hot path touches none.
@@ -264,26 +266,51 @@ export async function handle(kernel, part, msg, post, opts = {}) {
264
266
  if (part.fonts && kernel._fonts) {
265
267
  const declared = fontsToResolve ?? {};
266
268
  if (Object.keys(declared).length) {
267
- onProgress("resolving fonts");
268
- const opentype = normalizeOpentype(await import("opentype.js"));
269
- const bufs = await resolveFonts(declared);
270
269
  // Keyed on the SOURCE, not the name. A name is not a font identity: one
271
270
  // worker outlives many parts (worker-rebind) and, once a font can come
272
271
  // from a param, many picks — all of which reuse the same declared name.
273
272
  // The old `if (!_fonts.has(name))` made the first bytes ever seen under a
274
273
  // name permanent for the life of the worker.
275
274
  //
276
- // The source, not the resolved buffer: the two agree only because the
277
- // resolver's own memo is unbounded and hands back the identical object
278
- // every time. Key on that and this memo silently degrades to per-fetch
279
- // identity — a re-parse per build — the day eviction is added there.
275
+ // The parse cache keys on the source and is the ONLY holder of a font:
276
+ // the resolver's memo is transient (forgotten once a font is parsed).
280
277
  kernel._fontsBySource ??= new Map();
281
- for (const [name, buf] of bufs) {
282
- const source = declared[name];
278
+ // Resolve only what this kernel has not parsed yet: a source already in
279
+ // the parse cache needs no bytes at all, which is what lets the resolver
280
+ // forget a buffer the moment its font is parsed (below).
281
+ const unparsed = Object.fromEntries(
282
+ Object.entries(declared).filter(([, src]) => !kernel._fontsBySource.has(src)),
283
+ );
284
+ let bufs = new Map();
285
+ if (Object.keys(unparsed).length) {
286
+ onProgress("resolving fonts");
287
+ bufs = await resolveFonts(unparsed);
288
+ }
289
+ const opentype = bufs.size ? normalizeOpentype(await import("opentype.js")) : null;
290
+ for (const [name, source] of Object.entries(declared)) {
283
291
  let font = kernel._fontsBySource.get(source);
284
- if (!font) { font = parseFont(opentype, buf, name); kernel._fontsBySource.set(source, font); }
292
+ if (font) {
293
+ kernel._fontsBySource.delete(source); // re-insert = most recent
294
+ } else {
295
+ font = parseFont(opentype, bufs.get(name), name);
296
+ forgetFontSource(source); // the parsed Font is what we keep
297
+ }
298
+ kernel._fontsBySource.set(source, font);
285
299
  kernel._fonts.set(name, font);
286
300
  }
301
+ // Bounded (spec 2026-10-01 §3): auditioning fonts in the picker
302
+ // otherwise kept every face ever tried, per kernel, for the worker's
303
+ // life. Map order is recency (re-inserted on use above), so eviction
304
+ // takes the oldest. A source THIS build declared is never evicted — a
305
+ // part declaring more than the bound would otherwise evict its own
306
+ // fonts and refetch/reparse them on every build — so the bound is
307
+ // max(FONT_SOURCE_CACHE_MAX, this build's distinct sources).
308
+ const keep = new Set(Object.values(declared));
309
+ const limit = Math.max(FONT_SOURCE_CACHE_MAX, keep.size);
310
+ for (const src of kernel._fontsBySource.keys()) {
311
+ if (kernel._fontsBySource.size <= limit) break;
312
+ if (!keep.has(src)) kernel._fontsBySource.delete(src);
313
+ }
287
314
  }
288
315
  // Drop every name this build's declaration does not supply. `_fonts` is
289
316
  // the kernel's, and the kernel outlives the job: without this, a face the
@@ -241,7 +241,8 @@ function createCleanupStack() {
241
241
  // mesh-validity cache, and the geometry workers. The app supplies `createWorker(name)`
242
242
  // so Vite can bundle the worker (see geometry-service.js).
243
243
  //
244
- // 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):
245
246
  // const runtime = mount(part, { createWorker, elements, onBuild, onPick, onDownload, onViewChange });
246
247
  // await runtime.ready; // first successful build of the default view
247
248
  // runtime.setParams({ openAngle: 45 }); // programmatic edit; pose-only changes apply instantly
@@ -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
@@ -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.