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
package/src/framework/fonts.js
CHANGED
|
@@ -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
|
+
}
|
package/src/framework/jobs.js
CHANGED
|
@@ -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
|
|
277
|
-
// resolver's
|
|
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
|
-
|
|
282
|
-
|
|
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 (
|
|
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
|
package/src/framework/mount.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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) => {
|
|
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) =>
|
|
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
|
-
|
|
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
|
package/src/framework/vectors.js
CHANGED
|
@@ -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:
|
|
23
|
-
//
|
|
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.
|