partforge 0.73.1 → 0.74.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/bin/cli.js CHANGED
@@ -9,6 +9,7 @@ import { pathToFileURL } from "node:url";
9
9
  import { resolve, dirname } from "node:path";
10
10
  import { writeFileSync, mkdirSync } from "node:fs";
11
11
  import { detectBackend } from "../src/framework/backend-select.js";
12
+ import { fontsFor } from "../src/framework/fonts.js";
12
13
  import { viewAnimations, evaluate, cueAt } from "../src/framework/animation.js";
13
14
  import { bootOcctKernel } from "../src/testing/occt.js";
14
15
  import { bootManifoldKernel } from "../src/testing/manifold.js";
@@ -78,9 +79,13 @@ async function loadPart(partPath, usage) {
78
79
 
79
80
  // Pass the part's declared fonts through, mirroring the worker path (jobs.js) —
80
81
  // otherwise a part using a named font builds in the browser but dies headlessly
81
- // with `text2d: unknown font …`.
82
- const bootKernel = (part) => {
83
- const opts = { fonts: part.fonts, imports: part.imports };
82
+ // with `text2d: unknown font …`. A function-form `fonts` is resolved against
83
+ // the CLI's base params; see "CLI limitation" in the design doc — a verify case
84
+ // or animation frame that CHANGES the font param still builds with the
85
+ // base-params face, because the kernel is booted once.
86
+ const bootKernel = (part, params = {}) => {
87
+ const p = { ...(part.defaults ?? {}), ...params };
88
+ const opts = { fonts: fontsFor(part, p), imports: part.imports };
84
89
  const backend = process.env.PARTFORGE_BACKEND || detectBackend(part); // env: crash()'s NEEDS_OCCT retry
85
90
  return backend === "occt" ? bootOcctKernel(opts) : bootManifoldKernel(opts);
86
91
  };
@@ -238,7 +243,7 @@ const commands = {
238
243
  anim = byView.get(animView).find((x) => x.name === flags.animation);
239
244
  }
240
245
 
241
- const kernel = await bootKernel(part);
246
+ const kernel = await bootKernel(part, baseParams);
242
247
 
243
248
  if (anim === null) {
244
249
  const files = await renderViews(kernel, part, view, { views, out: outDir, params: baseParams });
@@ -72,7 +72,7 @@ export default {
72
72
  meta: { title, units, background? }, // title string; units e.g. "mm"; background = 0xRRGGBB scene colour
73
73
  parameters, // the control-panel schema (array of sections — see below)
74
74
  defaults, // flat { paramKey: value } — seeds params + control values
75
- fonts?, // { name: source } — fonts a part's k.text2d() needs; framework preloads before build (see below)
75
+ fonts?, // { name: source } — or (p) => ({ name: source }) when a control drives the typeface
76
76
  imports?, // { name: source } — STEP/STL/3MF files a part's k.import() needs; same preload timing as fonts (see below)
77
77
  derive?, // (p) => d, or { group: (p, d) => {…}, … } — dependent values computed once per build
78
78
  parts: { // named sub-parts; each builds ONE solid
@@ -631,6 +631,7 @@ Every control accepts `key`, `type`, `label`, `description`, `hidden`, `when` an
631
631
  | `"checkbox"` | an on/off box: ticked writes `on`, cleared writes `0` | `on` (default `1`) |
632
632
  | `"select"` | a dropdown | `options` |
633
633
  | `"radio"` | a segmented button row | `options` |
634
+ | `"font"` | a typeface picker, or a URL field with no catalog | `allow`, `preview` |
634
635
 
635
636
  Numeric controls always show the number box: drag the slider *or* type an exact
636
637
  value. Typed values may be finer than `step` and clamp to `[min, max]` on commit.
@@ -645,6 +646,32 @@ each entry is both value and label — or the long form
645
646
  types, `12` is not `"12"`). An option's `description` surfaces as a hover tooltip
646
647
  on that one option, not as a ⓘ popover.
647
648
 
649
+ **`allow` and `preview`** (font) configure the typeface control. `allow` lists the
650
+ source kinds a **param-supplied** value may use — what the picker writes, or what
651
+ arrives in a share link:
652
+
653
+ | value | accepts |
654
+ |---|---|
655
+ | `"https"` | any `https:` URL. **The default** — omitting `allow` means `["https"]` |
656
+ | `"gstatic"` | `https://fonts.gstatic.com` only (hostname-exact: a lookalike host is refused) |
657
+ | `"asset"` | a `pfc-asset://` token — a font the host has stored for this part |
658
+
659
+ Name as many as apply (`allow: ["gstatic", "asset"]`); anything unnamed is refused,
660
+ which is how `http:`, `file:`, `data:` and `blob:` are closed off. The check is
661
+ deliberately narrow — **it applies only to values that arrive as params.** A source
662
+ you write into `fonts` yourself is code, not user input, and stays unrestricted:
663
+ `fonts: { label: "https://cdn.example.com/Courier-Prime.ttf" }` keeps working
664
+ whatever `allow` says. A refused param falls back to `defaults[key]`, and the build
665
+ carries a warning naming the key rather than failing (lint's `font-source-scheme`
666
+ catches the case where that default is itself refused). `allow` gates what the
667
+ **picker fetches** too: a family whose files it refuses is dropped from the list
668
+ rather than offered, and neither that family's name-preview face nor its weight
669
+ samples are ever requested.
670
+
671
+ `preview` is the sample string the picker's weight list renders each face in — set
672
+ it when the generic sample shows the wrong glyphs (`preview: "0123456789"` for a
673
+ part that letters digits). Defaults to `Hamburgefonstiv 0123`.
674
+
648
675
  **`"readout"` is not a control.** It has no `key`, never writes `params`, and can
649
676
  never be a preset target. It displays one output of `derive()`, named by
650
677
  `derivedKey`, refreshed on every parameter change; `unit` is appended to numeric
@@ -1318,6 +1345,24 @@ fonts: {
1318
1345
 
1319
1346
  Reference a font by name: `k.text2d("text", { font: "heading" })`. Omit the `font` option to use the bundled **Roboto** (Regular, SIL OFL 1.1) default.
1320
1347
 
1348
+ **Making the typeface a parameter.** Give `fonts` a function of params instead of a
1349
+ static object, and a `type: "font"` control can drive which face `text2d` uses —
1350
+ `src/parts/nameplate.js` is the reference:
1351
+
1352
+ ```js
1353
+ { key: "face", type: "font", label: "Typeface" }, // in `parameters`
1354
+ fonts: (p) => (p.face ? { face: p.face } : {}), // a function, not a static map
1355
+ k.text2d(p.label, { font: "face" }), // only when p.face is set
1356
+ ```
1357
+
1358
+ An empty `face` declares nothing — `fonts` returns `{}`, and `text2d` falls back to
1359
+ the bundled Roboto — so the part still builds with no network access. A part with a
1360
+ fixed typeface needs none of this: a plain `{ name: source }` object is fine.
1361
+
1362
+ The control's `allow` list bounds what a picked — or share-link-supplied — value may
1363
+ be, and defaults to `["https"]`; see the control-types table above. It does **not**
1364
+ constrain sources you declare yourself.
1365
+
1321
1366
  **Build-time & curve semantics:**
1322
1367
 
1323
1368
  `text2d` is a **build-time operation** (not `derive()`), and **the curve representation differs by backend:**
@@ -1548,6 +1593,18 @@ returns instead:
1548
1593
  Pass `onDownload({ data, filename, mime })` to `mount()` to receive the exported bytes
1549
1594
  yourself (e.g. to download from a different origin) instead of partforge's own DOM download.
1550
1595
 
1596
+ - `fontCatalog` — a provider backing every `type: "font"` control in the part:
1597
+
1598
+ - `search(query, { limit }) → Promise<FontFamily[]>`, where a `FontFamily` is
1599
+ `{ id, family, category, variants: [{ variant, label, url, bytes }],
1600
+ menuUrl }`. `url` is what the picker writes into `params`; `menuUrl` is a
1601
+ name-only subset used to draw the list row.
1602
+ - `describe(source) → { family, variant } | null` — optional reverse lookup so
1603
+ the closed control can name a face whose URL carries a hashed filename.
1604
+
1605
+ partforge ships no provider — a host supplies one, and without it every font
1606
+ control renders as a URL field.
1607
+
1551
1608
  **Showcase capture (the mount handle).** The handle can also render the user's *current*
1552
1609
  framing offscreen at a resolution independent of the window size and devicePixelRatio —
1553
1610
  for gallery/preview images, where grabbing the live canvas would be capped at the viewer
@@ -1983,6 +2040,15 @@ runtime authority for those cases), `reference-unknown` (a sub-part's
1983
2040
  `refVolumeDeltaPct`, `refBboxDelta` — but the sub-part declares no `reference`,
1984
2041
  so the deviation gate always reports status "skip") (warning).
1985
2042
 
2043
+ **Font controls** — `font-control-not-in-fonts` (a `type: "font"` control's
2044
+ `key` is not read by a function-form `fonts` — a static `fonts` object or a
2045
+ missing `fonts` field both provably can't depend on a param, so the picker
2046
+ changes a param and nothing else happens; the message names which of the two
2047
+ it is) (error); `font-source-scheme` (`defaults` holds a value for a font
2048
+ control that the control's own `allow` list would refuse — at build time it's
2049
+ swapped for `defaults[key]`, i.e. itself, so the part boots with no usable
2050
+ font; use a source `allow` accepts, or widen `allow`) (warning).
2051
+
1986
2052
  A rule that itself throws yields an `internal-rule-error` **warning** and the run
1987
2053
  continues: `lintPart` never throws and never blocks a part because of a linter bug.
1988
2054
 
@@ -2135,6 +2201,14 @@ unverified.
2135
2201
  `">=[x,y,z]"` where `*` skips an axis. The parser is strict — a malformed assertion
2136
2202
  fails loudly.
2137
2203
 
2204
+ **A part whose typeface is a parameter needs band assertions, not points.** Glyph
2205
+ advance widths differ by family, so a `text2d` sub-part's `bbox`/`volume` shifts with
2206
+ the picked face even when every other param is unchanged. Write `verify` bounds wide
2207
+ enough to hold across the fonts your `allow` list admits (a range, or `<=`/`>=`,
2208
+ rather than exact equality). `verify` runs against `defaults`, which is stable — the
2209
+ nameplate ships `face: ""` (the bundled Roboto), so its own `verify` cases don't
2210
+ need this, but a part whose default already names a specific face does.
2211
+
2138
2212
  ```js
2139
2213
  verify: { expect: {
2140
2214
  stand: { boundsMin: ">=[0,0,0]", centerOfMass: "<=[*,*,25]" }, // sits in +octant, mass kept low
@@ -463,7 +463,9 @@ sub-part and attaches `warnings: [{part, message}]` to the `meshes` /
463
463
  `capture-meshes` result when any were recorded, so a host can tell its user (or its
464
464
  agent) that the part on screen is missing a feature it asked for. A skipped op still
465
465
  console.warns as before; the channel is additive. Hosts that ignore the field see
466
- exactly the old behavior.
466
+ exactly the old behavior. The same array also carries *job-level* notices that
467
+ belong to no single sub-part — currently a font source refused by its control's
468
+ `allow` list — as entries with `part: null`.
467
469
 
468
470
  ## Shape2D (2-D booleans)
469
471
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.73.1",
3
+ "version": "0.74.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",
@@ -198,6 +198,19 @@ textarea.text-input { min-height: 64px; resize: vertical; }
198
198
  .text-input:focus { outline: none; border-color: var(--pf-accent);
199
199
  box-shadow: 0 0 0 3px color-mix(in oklab, var(--pf-accent) 35%, transparent); }
200
200
 
201
+ /* the `type: "font"` control — a button that shows the current face IN it */
202
+ .font-btn { width: 100%; display: flex; align-items: center; gap: 8px; text-align: left; cursor: pointer;
203
+ background: var(--pf-input-bg); color: var(--pf-text-strong);
204
+ border: 1px solid var(--pf-border); border-radius: var(--pf-radius-control); padding: 7px 9px; }
205
+ .font-btn:hover { border-color: color-mix(in oklab, var(--pf-accent) 45%, var(--pf-border)); }
206
+ .font-btn:focus-visible { outline: none; border-color: var(--pf-accent);
207
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--pf-accent) 35%, transparent); }
208
+ .font-btn .fname { flex: 1; min-width: 0; font-size: 15px; line-height: 1.25;
209
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
210
+ .font-btn .fvar { flex: none; font: 10px/1 var(--pf-mono); color: var(--pf-muted); }
211
+ .font-btn .caret { flex: none; opacity: .5; }
212
+ .text-input.warn { border-color: var(--pf-err); color: var(--pf-err); }
213
+
201
214
  /* crafted range slider — hairline track + CAD-blue handle (the panel's signature control) */
202
215
  input[type="range"] { -webkit-appearance: none; appearance: none; width: 100%; height: 18px; margin: 0; background: transparent; cursor: pointer; }
203
216
  input[type="range"]::-webkit-slider-runnable-track { height: 3px; border-radius: 2px; background: var(--pf-border); }
@@ -750,3 +763,102 @@ button.action:focus-visible, .adv-toggle:focus-visible, .sec-title:focus-visible
750
763
  100% { outline: 3px solid transparent; }
751
764
  }
752
765
  @media (prefers-reduced-motion: reduce) { .pf-param-flash { animation: none; } }
766
+
767
+ /* ---- the `type: "font"` picker -------------------------------------------
768
+ A TAKEOVER panel: it covers the rail on desktop and the single visible pane
769
+ below the narrow breakpoint, rather than expanding inline under the control.
770
+ Choosing a typeface is a browse task, not a slider nudge — it earns the whole
771
+ surface, and one layout for both widths removes a mode from the widget.
772
+ Ported from spike/font-picker.html; the sizes, timings and densities here
773
+ were settled against that running build (design spec §6). */
774
+
775
+ /* The rail is the picker's containing block; nothing else in it is positioned. */
776
+ .pf-rail { position: relative; }
777
+
778
+ /* `position: absolute` is what places the takeover — and, just as load-bearing,
779
+ what makes .picker a POSITIONED box for the panes below, which are inset: 0
780
+ against it. Without that, the variants pane escapes the picker and fills the
781
+ rail (an hour of the spike went to exactly this). */
782
+ .picker {
783
+ position: absolute; inset: 0; z-index: 20;
784
+ display: flex; flex-direction: column;
785
+ background: var(--pf-surface); color: var(--pf-text);
786
+ }
787
+
788
+ .pk-head { flex: none; padding: 10px var(--pf-rail-pad) 8px;
789
+ border-bottom: 1px solid var(--pf-border); background: var(--pf-surface); }
790
+ .pk-titlebar { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
791
+ .pk-titlebar b { flex: 1; min-width: 0; font: 600 11px/1 var(--pf-sans);
792
+ letter-spacing: .04em; text-transform: uppercase; color: var(--pf-muted);
793
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
794
+ .pk-x { border: 0; background: transparent; color: var(--pf-muted); cursor: pointer;
795
+ padding: 2px 4px; font-size: 15px; line-height: 1; }
796
+ .pk-x:hover { color: var(--pf-text); }
797
+
798
+ .pk-search { width: 100%; font: 12px/1.4 var(--pf-mono); background: var(--pf-input-bg);
799
+ color: var(--pf-text-strong); border: 1px solid var(--pf-border);
800
+ border-radius: var(--pf-radius-control); padding: 6px 8px 6px 26px;
801
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Ccircle cx='5' cy='5' r='3.4' fill='none' stroke='%238b8b94' stroke-width='1.3'/%3E%3Cpath d='M7.6 7.6 L10.5 10.5' stroke='%238b8b94' stroke-width='1.3' stroke-linecap='round'/%3E%3C/svg%3E");
802
+ background-repeat: no-repeat; background-position: 8px center; }
803
+ .pk-search:focus { outline: none; border-color: var(--pf-accent);
804
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--pf-accent) 35%, transparent); }
805
+
806
+ /* The virtualized list: .pk-spacer carries the full scroll height and each row
807
+ is absolutely placed at translateY(i * 44px). */
808
+ .pk-list { flex: 1; overflow-y: auto; overscroll-behavior: contain; position: relative; }
809
+ .pk-spacer { position: relative; width: 100%; }
810
+ .pk-row { position: absolute; left: 0; right: 0; display: flex; align-items: center; gap: 8px;
811
+ padding: 0 var(--pf-rail-pad); cursor: pointer;
812
+ border-bottom: 1px solid color-mix(in oklab, var(--pf-border) 45%, transparent); }
813
+ .pk-row:hover { background: var(--pf-surface-2); }
814
+ .pk-row.sel { background: var(--pf-accent-soft); box-shadow: inset 2px 0 0 var(--pf-accent); }
815
+ .pk-main { flex: 1; min-width: 0; }
816
+ /* 16px, and the row height is reserved by the virtualizer, so a face arriving
817
+ late swaps glyphs in place instead of shifting the list. */
818
+ .pk-face { font-size: 16px; line-height: 1.15; color: var(--pf-text-strong);
819
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
820
+ .pk-sub { font: 9px/1.3 var(--pf-mono); color: var(--pf-hint);
821
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
822
+ .pk-meta { flex: none; font: 9px/1 var(--pf-mono); color: var(--pf-hint); }
823
+ /* A row whose own face has not arrived yet reads as pending rather than wrong. */
824
+ .pk-row.loading .pk-face { opacity: .38; }
825
+
826
+ /* Two panes sliding inside .pk-panes: browse, then that family's weights. The
827
+ footer sits BELOW this box rather than under it, so Done stays reachable from
828
+ either pane — picking a weight never has to exit to commit. */
829
+ .pk-panes { position: relative; flex: 1; min-height: 0; overflow: hidden; }
830
+ .pk-pane { position: absolute; inset: 0; display: flex; flex-direction: column;
831
+ background: var(--pf-surface);
832
+ transition: transform .26s cubic-bezier(.4, 0, .2, 1), opacity .26s ease; }
833
+ .pk-pane[data-pane="variants"] { transform: translateX(100%); opacity: 0; pointer-events: none; }
834
+ .picker.at-variants .pk-pane[data-pane="browse"] { transform: translateX(-22%); opacity: .3; pointer-events: none; }
835
+ .picker.at-variants .pk-pane[data-pane="variants"] { transform: translateX(0); opacity: 1; pointer-events: auto; }
836
+ @media (prefers-reduced-motion: reduce) { .pk-pane { transition: none; } }
837
+
838
+ .pk-back { display: flex; align-items: center; gap: 6px; border: 0; background: transparent;
839
+ color: var(--pf-muted); font: 10px/1 var(--pf-mono); cursor: pointer; padding: 0 0 8px; }
840
+ .pk-back:hover { color: var(--pf-text); }
841
+ .pk-vlist { flex: 1; overflow-y: auto; padding: 4px var(--pf-rail-pad) 12px; }
842
+ .vrow { display: flex; align-items: baseline; gap: 8px; width: 100%; text-align: left; cursor: pointer;
843
+ background: transparent; border: 0;
844
+ border-bottom: 1px solid color-mix(in oklab, var(--pf-border) 45%, transparent);
845
+ padding: 9px 0; color: var(--pf-text-strong); }
846
+ .vrow:hover { background: var(--pf-surface-2); }
847
+ .vrow.on, .vrow.on .vlabel { color: var(--pf-accent); }
848
+ .vrow .vsample { flex: 1; min-width: 0; font-size: 16px;
849
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
850
+ .vrow .vlabel { flex: none; font: 10px/1 var(--pf-mono); color: var(--pf-muted); }
851
+
852
+ .pk-hint { flex: none; padding: 7px var(--pf-rail-pad) 0; font: 9px/1.4 var(--pf-mono); color: var(--pf-hint); }
853
+ .pk-empty { margin: 0; padding: 22px var(--pf-rail-pad); font: 11px/1.6 var(--pf-mono); color: var(--pf-hint); }
854
+
855
+ .pk-foot { flex: none; display: flex; align-items: center; gap: 10px;
856
+ padding: 9px var(--pf-rail-pad); border-top: 1px solid var(--pf-border); background: var(--pf-surface); }
857
+ .pk-sel { flex: 1; min-width: 0; font: 10px/1.4 var(--pf-mono); color: var(--pf-hint);
858
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
859
+ .pk-sel b { font-weight: 500; color: var(--pf-text-2); }
860
+ .pk-done { flex: none; border: 0; border-radius: var(--pf-radius-control); cursor: pointer;
861
+ background: var(--pf-accent); color: var(--pf-on-accent); font: 11px/1 var(--pf-mono);
862
+ letter-spacing: .04em; padding: 8px 17px; }
863
+ .pk-done:hover { filter: brightness(1.09); }
864
+ .picker :where(button, input):focus-visible { outline: 2px solid var(--pf-accent); outline-offset: 2px; }
@@ -0,0 +1,70 @@
1
+ // What a PARAM-supplied font source may be. Author-declared `fonts` sources are
2
+ // code and get no restriction (see the design doc §4); this file exists only
3
+ // for the other case — a value that arrived in `params`, which on a shared link
4
+ // is attacker-controlled text that would otherwise become a fetch URL.
5
+ //
6
+ // DOM-free and node:-free: jobs.js (worker graph) and the panel both import it.
7
+
8
+ export const FONT_ALLOW_DEFAULT = ["https"];
9
+
10
+ // The "unset" font source. An empty value declares NO font — the documented way
11
+ // to opt out of a typeface, after which text2d falls back to the bundled Roboto.
12
+ // It is never a source to fetch, and never a source to refuse: fontSourceAllowed
13
+ // rejects "" under every allow list (new URL("") throws), so a site that forgets
14
+ // this reads "unset" as "disallowed" and warns on every build of a part whose
15
+ // font control is simply blank. Spelled once here because three sites have to
16
+ // agree on it — the allow check, the pre-resolve filter, and lint's default
17
+ // check — and the two bugs this rule has already caused were both a site that
18
+ // had drifted from the others.
19
+ export const isNoFontSource = (v) => v === undefined || v === null || v === "";
20
+
21
+ const GSTATIC_HOST = "fonts.gstatic.com";
22
+ const ASSET_SCHEME = "pfc-asset:";
23
+
24
+ // Parse once; an unparseable string is refused rather than guessed at.
25
+ function parse(source) {
26
+ try { return new URL(source); } catch { return null; }
27
+ }
28
+
29
+ export function fontSourceAllowed(source, allow = FONT_ALLOW_DEFAULT) {
30
+ if (typeof source !== "string") return false; // bytes/thunks are never param-supplied
31
+ const u = parse(source);
32
+ if (!u) return false;
33
+ for (const kind of allow) {
34
+ // hostname, not host or a suffix test: `fonts.gstatic.com.evil.test` must
35
+ // not pass, and neither must a userinfo trick like `https://fonts.gstatic.com@evil.test/`
36
+ // (URL parsing puts `evil.test` in hostname, which is exactly why this
37
+ // compares the parsed hostname rather than the raw string).
38
+ if (kind === "gstatic" && u.protocol === "https:" && u.hostname === GSTATIC_HOST) return true;
39
+ if (kind === "https" && u.protocol === "https:") return true;
40
+ if (kind === "asset" && u.protocol === ASSET_SCHEME) return true;
41
+ }
42
+ return false;
43
+ }
44
+
45
+ // paramKey → allow list, for every `type: "font"` control in the authored tree —
46
+ // new-shape (`controls`, including nested `group`s) AND legacy-shape
47
+ // (`advanced`/`toggles`/`features`, where panel/legacy.js desugars a
48
+ // descriptor's `control:` field to `type:` — see legacy.js's `toControl`).
49
+ // Missing the legacy arrays here would leave a `{key, control:"font"}`
50
+ // descriptor with no entry in the returned map, and jobs.js's check only
51
+ // looks at keys present in the map — so a legacy-declared font control would
52
+ // get silently unrestricted. Walk is deliberately tolerant of any of these
53
+ // arrays being absent or malformed; it must never throw on an existing part.
54
+ export function fontControlAllows(part) {
55
+ const out = new Map();
56
+ const visit = (nodes) => {
57
+ for (const n of nodes ?? []) {
58
+ if (!n || typeof n !== "object") continue;
59
+ if (Array.isArray(n.controls)) visit(n.controls);
60
+ if (Array.isArray(n.advanced)) visit(n.advanced);
61
+ if (Array.isArray(n.toggles)) visit(n.toggles);
62
+ if (Array.isArray(n.features)) visit(n.features);
63
+ if ((n.type === "font" || n.control === "font") && typeof n.key === "string") {
64
+ out.set(n.key, Array.isArray(n.allow) && n.allow.length ? n.allow : FONT_ALLOW_DEFAULT);
65
+ }
66
+ }
67
+ };
68
+ visit(part?.parameters);
69
+ return out;
70
+ }
@@ -13,6 +13,21 @@ const resolveOne = makeAssetResolver(
13
13
  "resolveFonts: a font source must be bytes, a URL, or a thunk returning one",
14
14
  );
15
15
 
16
+ // `fonts` may be a plain { name: source } map, or a function of the resolved
17
+ // params — the second form is what lets a `type: "font"` control drive the
18
+ // typeface. Resolving it needs `p`, which is why this is a separate step from
19
+ // resolveFonts rather than folded into it.
20
+ export function fontsFor(part, p) {
21
+ const decl = part?.fonts;
22
+ return typeof decl === "function" ? decl(p) : decl;
23
+ }
24
+
16
25
  export async function resolveFonts(fontsDecl) {
26
+ // A function reaching here means a caller passed `part.fonts` raw. It cannot
27
+ // be resolved without params, and silently returning an empty map would show
28
+ // up much later as `text2d: unknown font "…"`.
29
+ if (typeof fontsDecl === "function") {
30
+ throw new Error("resolveFonts: `fonts` is a function of params — resolve it with fontsFor(part, p) first");
31
+ }
17
32
  return resolveDecl(fontsDecl, resolveOne);
18
33
  }
@@ -4,7 +4,8 @@
4
4
  // kernel-bound module back.
5
5
  import { meshTo3MF } from "./geometry/threemf.js";
6
6
  import { exportablePartNames } from "./export-select.js";
7
- import { resolveFonts } from "./fonts.js";
7
+ import { fontControlAllows, fontSourceAllowed, isNoFontSource } from "./font-source.js";
8
+ import { fontsFor, resolveFonts } from "./fonts.js";
8
9
  import { normalizeOpentype, parseFont } from "./geometry/opentype-interop.js";
9
10
  import { ensureImports, resolveImports } from "./imports.js";
10
11
  import { safeName } from "./safe-name.js";
@@ -98,25 +99,106 @@ export async function handle(kernel, part, msg, post, opts = {}) {
98
99
  const label = (name) => part.parts[name].label ?? name;
99
100
  const exportName = (name) => part.parts[name].export?.name ?? name;
100
101
 
102
+ // Warnings this job raised before any sub-part was built — a refused font
103
+ // source, today. They ride the result's `warnings` (below) rather than only a
104
+ // progress phase, which the next busy chip overwrites milliseconds later: a
105
+ // tampered share link must leave a notice that is still readable once the
106
+ // build has landed. `part: null` because these belong to the job, not to any
107
+ // one sub-part.
108
+ const jobWarnings = [];
101
109
  try {
102
- // Preload any part-declared fonts into the kernel before building once per
103
- // font name; a lazy dynamic import because this is async context (unlike the
104
- // synchronous kernel-front), so it doesn't cost sync callers anything. The
105
- // namespace shape differs between bundler and Node resolution (a bare
106
- // `.default` here is undefined in every browser bundle) — normalize it.
110
+ // Params first: the fonts declaration may be a function of them, and a
111
+ // throwing derive() should surface before a font download rather than
112
+ // after one. Still inside the try, so that throw posts an error the UI can
113
+ // show instead of killing the worker turn silently (an endless spinner).
114
+ //
115
+ // The font-source check runs as resolveParams' sanitize hook, not after it:
116
+ // rewriting p[key] afterwards would leave derive() — and therefore `d`, and
117
+ // therefore the geometry — holding the refused value while build() saw the
118
+ // default.
119
+ const { p, d } = resolveParams(part, msg.params, (params) => {
120
+ // A param bound to a `type: "font"` control is user input — on a shared
121
+ // link it is arbitrary attacker-supplied text that `fonts: (p) => …` would
122
+ // turn into a fetch URL. Refuse out-of-`allow` values back to the part's
123
+ // own default rather than failing the build: a bad link should show the
124
+ // part, not an error page.
125
+ for (const [key, allow] of fontControlAllows(part)) {
126
+ const v = params[key];
127
+ if (isNoFontSource(v) || fontSourceAllowed(v, allow)) continue;
128
+ const message = `font source for "${key}" is not allowed — using the default`;
129
+ onProgress(message); // the live chip…
130
+ jobWarnings.push({ part: null, message }); // …and the durable record
131
+ params[key] = part.defaults?.[key];
132
+ }
133
+ });
134
+ // Preload any part-declared fonts into the kernel before building. A lazy
135
+ // dynamic import because this is async context (unlike the synchronous
136
+ // kernel-front), so it doesn't cost sync callers anything. The namespace
137
+ // shape differs between bundler and Node resolution (a bare `.default`
138
+ // here is undefined in every browser bundle) — normalize it.
139
+ const fontsDecl = fontsFor(part, p);
140
+ // A nullish/empty source means "no font declared" for that name, not an
141
+ // error — e.g. `fonts: (p) => ({ face: p.face })` when p.face ended up
142
+ // undefined because the refusal above had no default to fall back to, or
143
+ // because the author simply left it unset. text2d falls back to the
144
+ // bundled Roboto for a name with no declared source. Passing it through
145
+ // to resolveFonts would throw ("must be bytes, a URL, or a thunk…"),
146
+ // producing exactly the error-page outcome the refusal above exists to
147
+ // avoid. Drop it here, centrally, rather than teaching resolveFonts about
148
+ // "empty is fine" (it still must error on a *present* source of the wrong
149
+ // shape — that's a real authoring bug). The progress note is what keeps a
150
+ // genuine typo (a name that never resolves) visible instead of silently
151
+ // swallowed.
152
+ const fontsToResolve = fontsDecl && Object.fromEntries(
153
+ Object.entries(fontsDecl).filter(([name, src]) => {
154
+ if (!isNoFontSource(src)) return true;
155
+ onProgress(`no font source declared for "${name}" — skipping`);
156
+ return false;
157
+ }),
158
+ );
159
+ // Gated on the part DECLARING `fonts` at all, not on this job having one to
160
+ // resolve — the prune below has to run on the empty declaration too, and a
161
+ // part with no `fonts` field must not touch the map (a host or test harness
162
+ // may have seeded kernel._fonts directly, e.g. bootManifoldKernel({ fonts })).
107
163
  if (part.fonts && kernel._fonts) {
108
- const opentype = normalizeOpentype(await import("opentype.js"));
109
- const bufs = await resolveFonts(part.fonts);
110
- for (const [name, buf] of bufs) if (!kernel._fonts.has(name)) kernel._fonts.set(name, parseFont(opentype, buf, name));
164
+ const declared = fontsToResolve ?? {};
165
+ if (Object.keys(declared).length) {
166
+ onProgress("resolving fonts");
167
+ const opentype = normalizeOpentype(await import("opentype.js"));
168
+ const bufs = await resolveFonts(declared);
169
+ // Keyed on the SOURCE, not the name. A name is not a font identity: one
170
+ // worker outlives many parts (worker-rebind) and, once a font can come
171
+ // from a param, many picks — all of which reuse the same declared name.
172
+ // The old `if (!_fonts.has(name))` made the first bytes ever seen under a
173
+ // name permanent for the life of the worker.
174
+ //
175
+ // The source, not the resolved buffer: the two agree only because the
176
+ // resolver's own memo is unbounded and hands back the identical object
177
+ // every time. Key on that and this memo silently degrades to per-fetch
178
+ // identity — a re-parse per build — the day eviction is added there.
179
+ kernel._fontsBySource ??= new Map();
180
+ for (const [name, buf] of bufs) {
181
+ const source = declared[name];
182
+ let font = kernel._fontsBySource.get(source);
183
+ if (!font) { font = parseFont(opentype, buf, name); kernel._fontsBySource.set(source, font); }
184
+ kernel._fonts.set(name, font);
185
+ }
186
+ }
187
+ // Drop every name this build's declaration does not supply. `_fonts` is
188
+ // the kernel's, and the kernel outlives the job: without this, a face the
189
+ // user picked and then CLEARED stays registered under its old name, and an
190
+ // unconditional `k.text2d(s, { font: "face" })` goes on rendering it
191
+ // instead of falling back — the stale-registration bug of spec §5, one
192
+ // step narrower and just as silent.
193
+ for (const name of [...kernel._fonts.keys()]) {
194
+ if (!Object.hasOwn(declared, name)) kernel._fonts.delete(name);
195
+ }
111
196
  }
112
197
  // Register this part's declared imports on the kernel running this job — the
113
198
  // import-asset sibling of the fonts preload above. See ensureImports for the
114
199
  // lazy-error policy that keeps a STEP import inert until a build actually
115
200
  // calls k.import on it.
116
201
  if (part.imports) await ensureImports(kernel, part.imports, opts.importMeshes ?? null);
117
- // Inside the try so a throwing derive posts an error the UI can show,
118
- // instead of killing the worker turn silently (an endless spinner).
119
- const { p, d } = resolveParams(part, msg.params);
120
202
  // Local shorthand over the shared helper: kernel/part/view/p/d are fixed per job.
121
203
  const posed = (name, purpose, prog) => buildPosed(kernel, part, name, { purpose, view: msg.view, p, d, onProgress: prog });
122
204
  // Explicit selection (headless exportParts) overrides view-derived selection.
@@ -136,7 +218,7 @@ export async function handle(kernel, part, msg, post, opts = {}) {
136
218
  // first so a previous job's stragglers (an oracle build, an export) cannot be
137
219
  // misattributed to this build's first sub-part.
138
220
  kernel.takeBuildWarnings?.();
139
- const warnings = [];
221
+ const warnings = [...jobWarnings]; // job-level notices ride along with the per-sub-part ones
140
222
  kernel.resetCacheStats?.(); // count hits/misses for just this job
141
223
  for (const [i, name] of msg.subparts.entries()) {
142
224
  if (useCache) kernel.beginSubPart?.(name); // open the per-sub-part cache round
@@ -171,7 +253,7 @@ export async function handle(kernel, part, msg, post, opts = {}) {
171
253
  const useCache = msg.cache !== false;
172
254
  const meshes = [];
173
255
  kernel.takeBuildWarnings?.(); // discard a previous job's stragglers (same as generate)
174
- const warnings = [];
256
+ const warnings = [...jobWarnings];
175
257
  for (const name of msg.subparts) {
176
258
  if (useCache) kernel.beginSubPart?.(name);
177
259
  try {
@@ -16,8 +16,9 @@ import { VERIFY_RULES, resolveExpect } from "./rules-verify.js";
16
16
  import { ANIMATION_RULES } from "./rules-animations.js";
17
17
  import { PLACE_RULES } from "./rules-place.js";
18
18
  import { IMPORT_RULES } from "./rules-imports.js";
19
+ import { FONT_RULES } from "./rules-fonts.js";
19
20
 
20
- export const RULES = [...SHAPE_RULES, ...SCHEMA_RULES, ...BUILD_RULES, ...VERIFY_RULES, ...ANIMATION_RULES, ...PLACE_RULES, ...IMPORT_RULES];
21
+ export const RULES = [...SHAPE_RULES, ...SCHEMA_RULES, ...BUILD_RULES, ...VERIFY_RULES, ...ANIMATION_RULES, ...PLACE_RULES, ...IMPORT_RULES, ...FONT_RULES];
21
22
 
22
23
  // Every rule runs inside a guard. lintPart is called on a user-facing hosted path
23
24
  // (partforge-cloud's sandbox), and a linter that takes down the preview it exists to
@@ -0,0 +1,45 @@
1
+ // Group 8 — font-control well-formedness. Both conditions here are silent
2
+ // failures at runtime rather than errors: a picker bound to a key no `fonts`
3
+ // declaration reads changes a param and nothing else (the typeface never
4
+ // moves), and a default outside its own `allow` list is swapped for… itself,
5
+ // which is to say the part boots with no font at all.
6
+ //
7
+ // Detecting "does the fonts function read this key?" without executing the
8
+ // function is impossible in general, so the rule asks the cheaper, honest
9
+ // question: is `fonts` a function at all? A static `fonts` provably cannot
10
+ // depend on a param.
11
+ import { err, warn } from "./finding.js";
12
+ import { fontControlAllows, fontSourceAllowed, isNoFontSource } from "../font-source.js";
13
+
14
+ export const FONT_RULES = [
15
+ {
16
+ id: "font-control-not-in-fonts",
17
+ run: ({ part }) => {
18
+ const controls = fontControlAllows(part);
19
+ if (controls.size === 0 || typeof part?.fonts === "function") return [];
20
+ return [...controls.keys()].map((key) => err("font-control-not-in-fonts",
21
+ `control "${key}" is a font picker, but this part's \`fonts\` is ${part?.fonts ? "a static object" : "missing"} — the picked value is never resolved.`,
22
+ `Declare fonts as a function of params, e.g. fonts: (p) => ({ ${key}: p.${key} }), and reference it with k.text2d(str, { font: "${key}" }).`,
23
+ "fonts"));
24
+ },
25
+ },
26
+ {
27
+ id: "font-source-scheme",
28
+ run: ({ part }) => {
29
+ const out = [];
30
+ for (const [key, allow] of fontControlAllows(part)) {
31
+ const v = part?.defaults?.[key];
32
+ // An empty source declares nothing (jobs.js filters exactly these out
33
+ // before resolveFonts, and text2d falls back to the bundled Roboto),
34
+ // which is a legitimate way to author an optional typeface — not a
35
+ // source the allow list is refusing.
36
+ if (isNoFontSource(v) || fontSourceAllowed(v, allow)) continue;
37
+ out.push(warn("font-source-scheme",
38
+ `defaults.${key} is "${String(v).slice(0, 120)}", which control "${key}" would refuse (allow: ${allow.join(", ")}).`,
39
+ `Use a source the allow list accepts, or widen \`allow\` on the control. At build time this value is replaced by defaults.${key}, so as written the part has no usable font.`,
40
+ "defaults"));
41
+ }
42
+ return out;
43
+ },
44
+ },
45
+ ];
@@ -247,6 +247,7 @@ function createCleanupStack() {
247
247
  // exactly once here — submodules take element refs and never query the document.
248
248
  // `container`/`controls` remain as deprecated aliases for elements.viewer/.controls.
249
249
  export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDownload, onViewChange, onParamsCommit, onAnnotationSend,
250
+ fontCatalog,
250
251
  annotateSend = "viewbar",
251
252
  container: legacyContainer, controls: legacyControls } = {}) {
252
253
  // --- element resolution (the only getElementById calls in the framework, save the ?pickserver client's optional #viewbar lookup) ----
@@ -835,7 +836,8 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
835
836
  onParamChange();
836
837
  }, onParamsCommit
837
838
  ? (changed) => onParamsCommit({ changed, params: { ...params } })
838
- : undefined);
839
+ : undefined,
840
+ { fontCatalog });
839
841
  cleanup.defer(() => panel.dispose());
840
842
  panelRef = panel;
841
843
  const updateRelevance = () => {
@@ -0,0 +1,400 @@
1
+ // The `type: "font"` picker: a takeover panel over the rail with two sliding
2
+ // panes (families, then that family's weights) and a shared footer.
3
+ //
4
+ // Main-thread only — it is DOM-heavy and is NOT part of the worker graph.
5
+ // It draws list rows in each family's own face by loading Google's name-only
6
+ // `menuUrl` subset through a FontFace, which is why a row costs a few KB and
7
+ // not the whole family.
8
+ //
9
+ // Ported from spike/font-picker.html, whose layout and interaction were settled
10
+ // against a running build over the real 1,942-family catalog (spec §6). The
11
+ // spike's own data path — a bundled catalog.json plus the Google CSS API — does
12
+ // NOT come along: here the families arrive from the host's `fontCatalog` and
13
+ // every face is a `FontFace` over a URL that catalog handed us.
14
+ import { fontLabel, variantLabel, setFontPicker } from "./widgets/font.js";
15
+ import { fontSourceAllowed } from "../font-source.js";
16
+
17
+ const ROW_H = 44; // comfortable density (spec §6)
18
+ const OVERSCAN = 4; // rows rendered above/below the viewport
19
+ const SEARCH_LIMIT = 200;
20
+ const SEARCH_DEBOUNCE_MS = 120;
21
+ const SAMPLE = "Hamburgefonstiv 0123"; // the default variant-pane sample; `preview` overrides it
22
+ // A variants pane can hold 18 weights; auto-loading every real face for a CJK
23
+ // family would be tens of megabytes on one click. Past this, the sample line
24
+ // falls back to the panel font with the weight synthesized.
25
+ const VARIANT_FACE_MAX_BYTES = 1_500_000;
26
+
27
+ function el(tag, className, text) {
28
+ const node = document.createElement(tag);
29
+ if (className) node.className = className;
30
+ if (text != null) node.textContent = text;
31
+ return node;
32
+ }
33
+
34
+ // Family names come from the host and land in a `font-family` declaration, so
35
+ // strip the two characters that could end the quoted string early.
36
+ const faceStack = (...names) =>
37
+ [...names.map((n) => `"${String(n).replace(/["\\]/g, "")}"`), "var(--pf-sans)"].join(", ");
38
+
39
+ const kbLabel = (bytes) => (Number.isFinite(bytes) ? `${Math.round(bytes / 1024)}K` : "");
40
+
41
+ // The face a row advertises, and the one a click lands on when the user has no
42
+ // standing weight preference. Kept separate from `pickVariant` so the row's
43
+ // size caption does not change under the user as they audition weights.
44
+ const listVariant = (f) =>
45
+ f.variants.find((v) => v.variant === "400" || v.variant === "regular") ?? f.variants[0];
46
+
47
+ // At most one picker is open at a time, and the previous one has to be CLOSED
48
+ // rather than merely detached: its `keydown` listener lives on `document`, so
49
+ // dropping the element off the DOM leaves the handler — and the whole closure,
50
+ // up to 200 admitted families — alive forever, one more on every re-open.
51
+ // Only close() unregisters it, so every path that supersedes a picker goes
52
+ // through here.
53
+ let openPicker = null;
54
+
55
+ export function openFontPicker({ node, params, allow, fontCatalog, anchor, onPicked }) {
56
+ // Takeover: the picker covers the rail on desktop and the single visible pane
57
+ // below the narrow breakpoint. One layout for both widths (spec §6).
58
+ const host = anchor?.closest?.(".pf-rail") ?? anchor?.parentElement ?? document.body;
59
+ openPicker?.close(); // never two at once
60
+
61
+ // ── state ───────────────────────────────────────────────────────────────
62
+ // The author's `preview` string, when they set one. A part lettered in digits,
63
+ // or in a script "Hamburgefonstiv" cannot even render, is auditioned against
64
+ // the wrong glyphs by the generic sample — which is the whole point of the
65
+ // field (spec §1). Blank or non-string falls back to the default.
66
+ const sampleText = typeof node.preview === "string" && node.preview.trim() ? node.preview : SAMPLE;
67
+ let results = []; // what the catalog last returned…
68
+ let resultsQuery = ""; // …for this query
69
+ let query = ""; // what is in the box right now
70
+ let rows = []; // what the list is showing
71
+ let closed = false;
72
+ let searchSeq = 0;
73
+ let debounce = null;
74
+ let failed = false;
75
+ // The value alone cannot name a live-picked face (a gstatic filename is a
76
+ // content hash), so start from `fontLabel` and sharpen it the moment the
77
+ // catalog hands us a family whose variant URL is this exact value.
78
+ const initial = fontLabel(params[node.key]);
79
+ let selFamily = initial.family;
80
+ let selVariant = initial.variant ?? "400";
81
+ let selBytes = null;
82
+ let openFamily = null; // the family the variants pane is showing
83
+
84
+ const faceRequested = new Set(); // families whose menu face we have asked for
85
+ // …and the ones we are no longer waiting on: arrived, or definitively failed.
86
+ // A row is dimmed while its face is PENDING; a 404 is settled, not pending,
87
+ // so it goes back to full strength in the panel font rather than staying grey.
88
+ const faceSettled = new Set();
89
+ const variantFaces = new Set(); // variant URLs already loaded
90
+
91
+ // ── DOM ─────────────────────────────────────────────────────────────────
92
+ const picker = el("div", "picker");
93
+ const panes = el("div", "pk-panes");
94
+
95
+ const browse = el("div", "pk-pane");
96
+ browse.dataset.pane = "browse";
97
+ const head = el("div", "pk-head");
98
+ const titlebar = el("div", "pk-titlebar");
99
+ const closeBtn = el("button", "pk-x", "\u00d7");
100
+ closeBtn.type = "button";
101
+ closeBtn.title = "Close";
102
+ titlebar.append(el("b", "", node.label ?? node.key), closeBtn);
103
+ const search = document.createElement("input");
104
+ search.className = "pk-search";
105
+ search.type = "text";
106
+ search.placeholder = "Search fonts";
107
+ search.autocomplete = "off";
108
+ search.spellcheck = false;
109
+ head.append(titlebar, search);
110
+ const hint = el("div", "pk-hint");
111
+ hint.hidden = true;
112
+ const list = el("div", "pk-list");
113
+ const spacer = el("div", "pk-spacer");
114
+ const empty = el("p", "pk-empty");
115
+ empty.hidden = true;
116
+ list.append(spacer, empty);
117
+ browse.append(head, hint, list);
118
+
119
+ const variants = el("div", "pk-pane");
120
+ variants.dataset.pane = "variants";
121
+ const vhead = el("div", "pk-head");
122
+ const back = el("button", "pk-back", "\u2190 all families");
123
+ back.type = "button";
124
+ const vtitlebar = el("div", "pk-titlebar");
125
+ const vtitle = el("b");
126
+ vtitlebar.append(vtitle);
127
+ vhead.append(back, vtitlebar);
128
+ const vlist = el("div", "pk-vlist");
129
+ variants.append(vhead, vlist);
130
+
131
+ panes.append(browse, variants);
132
+
133
+ // The footer sits BELOW the sliding pane box, not inside it, so Done stays
134
+ // reachable from either pane — picking a weight never has to exit to commit.
135
+ const foot = el("div", "pk-foot");
136
+ const sel = el("span", "pk-sel");
137
+ const done = el("button", "pk-done", "Done");
138
+ done.type = "button";
139
+ foot.append(sel, done);
140
+
141
+ picker.append(panes, foot);
142
+ host.append(picker);
143
+ paintSel();
144
+ search.focus?.();
145
+
146
+ // ── faces ───────────────────────────────────────────────────────────────
147
+ // happy-dom (and any non-browser host) may not implement FontFace at all; a
148
+ // missing one must degrade to un-styled rows, never throw.
149
+ const canLoadFaces = () => typeof FontFace === "function" && typeof document.fonts?.add === "function";
150
+
151
+ function settle(family) {
152
+ faceSettled.add(family);
153
+ if (closed) return;
154
+ for (const row of spacer.children) {
155
+ if (row.dataset.family === family) row.classList.remove("loading");
156
+ }
157
+ }
158
+
159
+ function requestFaces(families) {
160
+ if (!canLoadFaces()) return;
161
+ for (const f of families) {
162
+ if (faceRequested.has(f.family)) continue;
163
+ faceRequested.add(f.family);
164
+ // The menu file is fetched, so it goes through the same allowlist as the
165
+ // value itself — a catalog is host-supplied, not trusted.
166
+ if (!f.menuUrl || !fontSourceAllowed(f.menuUrl, allow)) { settle(f.family); continue; }
167
+ let face;
168
+ try { face = new FontFace(f.family, `url(${f.menuUrl})`); } catch { settle(f.family); continue; }
169
+ face.load()
170
+ .then((loaded) => { document.fonts.add(loaded); })
171
+ .catch(() => { /* a family that will not load stays in the panel font */ })
172
+ .then(() => settle(f.family));
173
+ }
174
+ }
175
+
176
+ // The variants pane needs the REAL weights — the menu subset carries only the
177
+ // family name's glyphs at one weight, so it cannot show what 700 looks like.
178
+ // Each face is registered under `<family> <variant>` so the weights do not
179
+ // collide with each other or with the menu face.
180
+ function requestVariantFace(family, v) {
181
+ if (!canLoadFaces()) return;
182
+ if (variantFaces.has(v.url) || !fontSourceAllowed(v.url, allow)) return;
183
+ if (Number.isFinite(v.bytes) && v.bytes > VARIANT_FACE_MAX_BYTES) return;
184
+ variantFaces.add(v.url);
185
+ let face;
186
+ try { face = new FontFace(`${family} ${v.variant}`, `url(${v.url})`); } catch { return; }
187
+ face.load().then((loaded) => document.fonts.add(loaded)).catch(() => {});
188
+ }
189
+
190
+ // ── the list ────────────────────────────────────────────────────────────
191
+ // Reconcile by (index, family, selected) — index ALONE is wrong: after a
192
+ // search the same index holds a different family, and an index-keyed row
193
+ // keeps rendering the old one. The spike paid a screenshot to find this.
194
+ const rowKey = (i, f) => `${i}|${f.family}|${f.family === selFamily ? 1 : 0}`;
195
+
196
+ function rowEl(i, f) {
197
+ const row = el("div", "pk-row" + (f.family === selFamily ? " sel" : ""));
198
+ row.dataset.i = String(i);
199
+ row.dataset.key = rowKey(i, f);
200
+ row.dataset.family = f.family;
201
+ if (canLoadFaces() && !faceSettled.has(f.family)) row.classList.add("loading");
202
+ const main = el("div", "pk-main");
203
+ const face = el("div", "pk-face", f.family);
204
+ face.style.fontFamily = faceStack(f.family);
205
+ const n = f.variants.length;
206
+ main.append(face, el("div", "pk-sub", `${n} style${n === 1 ? "" : "s"} · ${f.category ?? "—"}`));
207
+ row.append(main, el("div", "pk-meta", kbLabel(listVariant(f)?.bytes)));
208
+ row.addEventListener("click", () => choose(f));
209
+ return row;
210
+ }
211
+
212
+ function render() {
213
+ if (closed) return;
214
+ spacer.style.height = `${rows.length * ROW_H}px`;
215
+ const top = list.scrollTop || 0;
216
+ const vh = list.clientHeight || 360;
217
+ const first = Math.max(0, Math.floor(top / ROW_H) - OVERSCAN);
218
+ const last = Math.min(rows.length, Math.ceil((top + vh) / ROW_H) + OVERSCAN);
219
+
220
+ const wanted = new Map();
221
+ for (let i = first; i < last; i++) wanted.set(i, rows[i]);
222
+ requestFaces([...wanted.values()]);
223
+
224
+ for (const node_ of [...spacer.children]) {
225
+ const i = Number(node_.dataset.i);
226
+ if (!wanted.has(i) || node_.dataset.key !== rowKey(i, wanted.get(i))) node_.remove();
227
+ else wanted.delete(i);
228
+ }
229
+ for (const [i, f] of wanted) spacer.append(rowEl(i, f));
230
+ for (const node_ of spacer.children) {
231
+ node_.style.height = `${ROW_H}px`;
232
+ node_.style.transform = `translateY(${Number(node_.dataset.i) * ROW_H}px)`;
233
+ }
234
+
235
+ empty.hidden = rows.length > 0;
236
+ if (!rows.length) {
237
+ empty.textContent = failed ? "The font catalog is unavailable."
238
+ : query.trim() ? `No families match "${query.trim()}".`
239
+ : "No families available.";
240
+ }
241
+ hint.hidden = !query.trim() || !rows.length;
242
+ if (!hint.hidden) hint.textContent = `${rows.length.toLocaleString()} match${rows.length === 1 ? "" : "es"}`;
243
+ }
244
+
245
+ // Drop variants the allowlist refuses, and drop a family left with none — the
246
+ // UI half of the font-source check. A family we cannot legally write must not
247
+ // be offered, not merely fail on click.
248
+ function admissible(entries) {
249
+ const out = [];
250
+ for (const f of entries ?? []) {
251
+ if (!f || typeof f.family !== "string" || !Array.isArray(f.variants)) continue;
252
+ const ok = f.variants.filter((v) => v && fontSourceAllowed(v.url, allow));
253
+ if (!ok.length) continue;
254
+ out.push({ ...f, variants: ok });
255
+ if (!selBytes) {
256
+ const hit = ok.find((v) => v.url === params[node.key]);
257
+ if (hit) { selFamily = f.family; selVariant = hit.variant; selBytes = hit.bytes; paintSel(); }
258
+ }
259
+ }
260
+ return out;
261
+ }
262
+
263
+ // While the user is typing ahead of the catalog, narrow what is already in
264
+ // hand rather than blanking the list; once the catalog has answered for this
265
+ // exact query, show precisely what it returned (its matching may be fuzzier
266
+ // than a substring test, and second-guessing it would drop real hits).
267
+ function recompute() {
268
+ // Compare TRIMMED against trimmed: runSearch stores the trimmed query, so a
269
+ // trailing space would otherwise never match and the list would stay stuck
270
+ // on the client-side narrowing instead of showing the catalog's answer.
271
+ const q = query.trim();
272
+ rows = resultsQuery === q || !q
273
+ ? results
274
+ : results.filter((f) => f.family.toLowerCase().includes(q.toLowerCase()));
275
+ render();
276
+ }
277
+
278
+ function runSearch(q) {
279
+ const seq = ++searchSeq;
280
+ Promise.resolve()
281
+ .then(() => fontCatalog.search(q, { limit: SEARCH_LIMIT }))
282
+ .then((entries) => {
283
+ if (closed || seq !== searchSeq) return; // a newer search already won
284
+ failed = false;
285
+ results = admissible(entries);
286
+ resultsQuery = q;
287
+ recompute();
288
+ })
289
+ .catch(() => {
290
+ if (closed || seq !== searchSeq) return;
291
+ failed = true;
292
+ results = [];
293
+ resultsQuery = q;
294
+ recompute();
295
+ });
296
+ }
297
+
298
+ search.addEventListener("input", () => {
299
+ query = search.value;
300
+ list.scrollTop = 0; // a new query starts at the top
301
+ recompute(); // instant, from what we hold
302
+ clearTimeout(debounce);
303
+ debounce = setTimeout(() => runSearch(query.trim()), SEARCH_DEBOUNCE_MS);
304
+ });
305
+ list.addEventListener("scroll", render);
306
+
307
+ // ── choosing ────────────────────────────────────────────────────────────
308
+ const pickVariant = (f) =>
309
+ f.variants.find((v) => v.variant === selVariant) ?? listVariant(f);
310
+
311
+ function choose(f) {
312
+ commit(f, pickVariant(f));
313
+ // 1,036 of the 1,942 catalog families ship a single face. Stepping into a
314
+ // one-row weight list you immediately back out of is pure friction, so for
315
+ // those the row click IS the selection and the list stays put (spec §6).
316
+ if (f.variants.length > 1) openVariants(f);
317
+ }
318
+
319
+ function commit(f, v) {
320
+ if (!v) return;
321
+ params[node.key] = v.url;
322
+ selFamily = f.family;
323
+ selVariant = v.variant;
324
+ selBytes = v.bytes;
325
+ onPicked?.();
326
+ paintSel();
327
+ paintVariantRows();
328
+ render();
329
+ }
330
+
331
+ function paintSel() {
332
+ sel.textContent = "";
333
+ const strong = el("b", "", selFamily);
334
+ const rest = ` · ${variantLabel(selVariant)}` + (selBytes ? ` · ${kbLabel(selBytes)}` : "");
335
+ sel.append(strong, document.createTextNode(rest));
336
+ }
337
+
338
+ function openVariants(f) {
339
+ openFamily = f;
340
+ vtitle.textContent = f.family;
341
+ vlist.textContent = "";
342
+ for (const v of f.variants) {
343
+ const b = el("button", "vrow" + (v.variant === selVariant ? " on" : ""));
344
+ b.type = "button";
345
+ b.dataset.v = v.variant;
346
+ const sample = el("span", "vsample", sampleText);
347
+ sample.style.fontFamily = faceStack(`${f.family} ${v.variant}`, f.family);
348
+ sample.style.fontWeight = String(v.variant).replace(/i$/, "") || "400";
349
+ sample.style.fontStyle = /i$/.test(String(v.variant)) ? "italic" : "normal";
350
+ b.append(sample, el("span", "vlabel", v.label ?? variantLabel(v.variant)));
351
+ // Commit WITHOUT leaving — you audition weights against the live
352
+ // geometry, so committing and navigating are separate actions (spec §6).
353
+ b.addEventListener("click", () => commit(f, v));
354
+ requestVariantFace(f.family, v);
355
+ vlist.append(b);
356
+ }
357
+ vlist.scrollTop = 0;
358
+ picker.classList.add("at-variants");
359
+ }
360
+
361
+ // Repaint which weight is current without rebuilding the list — the pane
362
+ // stays put while you audition, so the rows must not be torn down under you.
363
+ function paintVariantRows() {
364
+ if (openFamily?.family !== selFamily) return;
365
+ for (const b of vlist.children) b.classList.toggle("on", b.dataset.v === selVariant);
366
+ }
367
+
368
+ const leaveVariants = () => { openFamily = null; picker.classList.remove("at-variants"); render(); };
369
+ back.addEventListener("click", leaveVariants);
370
+
371
+ // ── closing ─────────────────────────────────────────────────────────────
372
+ // Named here so close() can clear the module-level handle; `close` is a
373
+ // hoisted function declaration, so this captures it.
374
+ const handle = { close };
375
+
376
+ function close() {
377
+ if (closed) return; // idempotent
378
+ closed = true;
379
+ clearTimeout(debounce);
380
+ document.removeEventListener("keydown", onKey);
381
+ picker.remove();
382
+ if (openPicker === handle) openPicker = null;
383
+ }
384
+ function onKey(ev) {
385
+ if (ev.key !== "Escape") return;
386
+ ev.stopPropagation();
387
+ if (picker.classList.contains("at-variants")) leaveVariants();
388
+ else close();
389
+ }
390
+ document.addEventListener("keydown", onKey);
391
+ closeBtn.addEventListener("click", close);
392
+ done.addEventListener("click", close);
393
+
394
+ runSearch("");
395
+ render();
396
+ openPicker = handle;
397
+ return handle;
398
+ }
399
+
400
+ setFontPicker(openFontPicker);
@@ -24,7 +24,7 @@ function indexNodes(nodes, map) {
24
24
  }
25
25
  }
26
26
 
27
- export function buildControls(root, parameters, params, onDirty, onCommit) {
27
+ export function buildControls(root, parameters, params, onDirty, onCommit, opts = {}) {
28
28
  const info = createInfoPopover();
29
29
  const tree = buildTree(desugar(parameters));
30
30
 
@@ -34,6 +34,7 @@ export function buildControls(root, parameters, params, onDirty, onCommit) {
34
34
  const syncFns = []; // { key, sync } for every widget
35
35
  const rawSyncs = new Map(); // sectionId -> [{ key, sync }] for preset application
36
36
  const widgetSyncs = new Map(); // id -> the RAW widget sync (no markCustom)
37
+ const disposers = []; // widget teardown — a font picker lives OUTSIDE root
37
38
  const nodeById = new Map(); // id -> node, for the reveal re-sync
38
39
  const lastVisible = new Map(); // id -> previous `visible`, to detect a reveal
39
40
  const lastDisabled = new Map(); // id -> previous `disabled`, to skip a no-op input pass
@@ -225,10 +226,12 @@ export function buildControls(root, parameters, params, onDirty, onCommit) {
225
226
  onChange: () => { markCustom(); onEdit(); },
226
227
  onCommit: () => commit([node.key]),
227
228
  info,
229
+ fontCatalog: opts.fontCatalog,
228
230
  });
229
231
  nodeEls.set(node.id, widget.el);
230
232
  if (node.key && !keyToId.has(node.key)) keyToId.set(node.key, node.id);
231
233
  widgetSyncs.set(node.id, widget.sync);
234
+ if (widget.dispose) disposers.push(widget.dispose);
232
235
  container.append(widget.el);
233
236
 
234
237
  // The raw sync is what a PRESET application uses — it must not mark itself
@@ -351,6 +354,8 @@ export function buildControls(root, parameters, params, onDirty, onCommit) {
351
354
  }
352
355
  return true;
353
356
  },
354
- dispose: () => { info.dispose(); root.replaceChildren(); },
357
+ // replaceChildren() only reaches what is INSIDE root; a widget that parked
358
+ // DOM (or a document-level listener) elsewhere has to be told to let go.
359
+ dispose: () => { info.dispose(); for (const d of disposers) d(); root.replaceChildren(); },
355
360
  };
356
361
  }
@@ -36,6 +36,7 @@ export const WIDGET_SPECS = [
36
36
  { type: "checkbox", kind: "control", fields: LEGACY_TOGGLE },
37
37
  { type: "select", kind: "control", fields: [...AUTHOR_COMMON, "options"] },
38
38
  { type: "radio", kind: "control", fields: [...AUTHOR_COMMON, "options"] },
39
+ { type: "font", kind: "control", fields: [...AUTHOR_COMMON, "allow", "preview"] },
39
40
  { type: "readout", kind: "display", fields: ["type", "label", "description", "unit", "derivedKey", "hidden", "when", "whenFalse"] },
40
41
  ];
41
42
 
@@ -54,6 +55,7 @@ const AUTHOR_EXTRAS = {
54
55
  checkbox: ["on"],
55
56
  select: ["options"],
56
57
  radio: ["options"],
58
+ font: ["allow", "preview"],
57
59
  };
58
60
  const AUTHOR_FIELDS = new Map(Object.entries(AUTHOR_EXTRAS).map(
59
61
  ([type, extra]) => [type, [...AUTHOR_COMMON, ...extra]]));
@@ -0,0 +1,125 @@
1
+ // The `type: "font"` control. Its VALUE is a font source string — the same
2
+ // grammar `PartDefinition.fonts` already accepts — so everything downstream
3
+ // (presets, undo, the params hash, `when`) works with no special case.
4
+ //
5
+ // Two renderings. With a host-supplied `fontCatalog` it is a button showing the
6
+ // current face IN that face, opening the picker. Without one it degrades to a
7
+ // URL text field, so a standalone partforge app (which ships no catalog) still
8
+ // exposes the parameter.
9
+ import { attachInfo } from "../info.js";
10
+ import { FONT_ALLOW_DEFAULT, fontSourceAllowed } from "../../font-source.js";
11
+
12
+ function el(tag, className, text) {
13
+ const node = document.createElement(tag);
14
+ if (className) node.className = className;
15
+ if (text != null) node.textContent = text;
16
+ return node;
17
+ }
18
+
19
+ const WEIGHTS = { 100: "Thin", 200: "ExtraLight", 300: "Light", 400: "Regular", 500: "Medium",
20
+ 600: "SemiBold", 700: "Bold", 800: "ExtraBold", 900: "Black" };
21
+ export const variantLabel = (v) => {
22
+ if (!v) return "Regular";
23
+ const w = String(v).replace(/i$/, ""), italic = /i$/.test(String(v));
24
+ return `${WEIGHTS[w] ?? w}${italic ? " Italic" : ""}`;
25
+ };
26
+
27
+ // A source string → something human. Cloud's fetch_web_font stores files as
28
+ // `<family-slug>[-<variant>].ttf`, so the filename round-trips the label for
29
+ // free on the vendored path; a bare URL falls back to its filename stem.
30
+ export function fontLabel(source) {
31
+ if (typeof source !== "string" || !source) return { family: "—", variant: null };
32
+ let path = source;
33
+ try { path = new URL(source).pathname; } catch { /* not a URL — use the raw string */ }
34
+ const file = path.split("/").filter(Boolean).pop() ?? source;
35
+ const stem = file.replace(/\.(ttf|otf)$/i, "");
36
+ const m = /^(.*)-(\d{3}i?|italic)$/i.exec(stem);
37
+ const slug = m ? m[1] : stem;
38
+ const family = slug.split("-").filter(Boolean)
39
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
40
+ .join(" ") || "—";
41
+ return { family, variant: m ? m[2] : null };
42
+ }
43
+
44
+ export function makeFont(node, params, { onChange, onCommit, info, fontCatalog } = {}) {
45
+ const allow = Array.isArray(node.allow) && node.allow.length ? node.allow : FONT_ALLOW_DEFAULT;
46
+ const wrap = el("div", "slider");
47
+ const row = el("div", "row");
48
+ const label = el("label", "", node.label ?? node.key);
49
+ attachInfo(label, node.description, info);
50
+ row.append(label);
51
+ wrap.append(row);
52
+
53
+ if (!fontCatalog) {
54
+ // Degraded path: a URL field. Unlike `text`, it does NOT write on every
55
+ // keystroke — a half-typed URL is a guaranteed failed fetch, and the
56
+ // rebuild loop would chase every one of them.
57
+ const field = document.createElement("input");
58
+ field.type = "text";
59
+ field.className = "text-input";
60
+ field.value = String(params[node.key] ?? "");
61
+ field.addEventListener("change", () => {
62
+ if (!fontSourceAllowed(field.value, allow)) { field.classList.add("warn"); return; }
63
+ field.classList.remove("warn");
64
+ params[node.key] = field.value;
65
+ onChange?.();
66
+ onCommit?.();
67
+ });
68
+ wrap.append(field);
69
+ return { el: wrap, sync: () => { field.value = String(params[node.key] ?? ""); field.classList.remove("warn"); } };
70
+ }
71
+
72
+ const btn = el("button", "font-btn");
73
+ btn.type = "button";
74
+ const fname = el("span", "fname");
75
+ const fvar = el("span", "fvar");
76
+ btn.append(fname, fvar);
77
+ btn.insertAdjacentHTML("beforeend",
78
+ '<svg class="caret" width="8" height="7" viewBox="0 0 8 7" aria-hidden="true"><polygon points="0,0 8,0 4,7" fill="currentColor"/></svg>');
79
+ wrap.append(btn);
80
+
81
+ // The value alone cannot name a live-picked face: a gstatic filename is a
82
+ // content hash. Ask the catalog first (it holds the reverse lookup), and fall
83
+ // back to the filename — which is right for a vendored `<family>-<variant>.ttf`
84
+ // and merely ugly for a hash. `describe` is optional and may be async, so the
85
+ // label is painted twice: filename immediately, catalog answer when it lands.
86
+ let paintSeq = 0;
87
+ const paint = () => {
88
+ const src = params[node.key];
89
+ const seq = ++paintSeq;
90
+ const show = ({ family, variant }) => {
91
+ if (seq !== paintSeq) return; // a newer paint already won
92
+ fname.textContent = family;
93
+ fvar.textContent = variantLabel(variant);
94
+ fname.style.fontFamily = `"${family}", var(--pf-sans)`;
95
+ };
96
+ show(fontLabel(src));
97
+ if (typeof fontCatalog.describe !== "function") return;
98
+ Promise.resolve()
99
+ .then(() => fontCatalog.describe(src))
100
+ .then((d) => { if (d?.family) show(d); })
101
+ .catch(() => { /* a failed lookup keeps the filename label */ });
102
+ };
103
+ paint();
104
+
105
+ // The picker registers itself through setFontPicker (see below); with no
106
+ // picker in the bundle the button is inert rather than broken.
107
+ //
108
+ // The handle is kept because the picker is a TAKEOVER: it appends itself to
109
+ // the rail, outside the panel root, so tearing the panel down does not take it
110
+ // with it. Without dispose() the element — and the `document` keydown listener
111
+ // that only close() unhooks — would outlive the panel holding a stale `params`.
112
+ let picker = null;
113
+ btn.addEventListener("click", () => {
114
+ picker = openFontPicker?.({ node, params, allow, fontCatalog, anchor: wrap, onPicked: () => { paint(); onChange?.(); onCommit?.(); } }) ?? null;
115
+ });
116
+
117
+ return { el: wrap, sync: paint, dispose: () => { picker?.close(); picker = null; } };
118
+ }
119
+
120
+ // Assigned by font-picker.js, which widgets/index.js imports for the side
121
+ // effect. Kept
122
+ // as a mutable binding rather than a static import so this file stays usable —
123
+ // and testable — without dragging the whole picker in.
124
+ export let openFontPicker = null;
125
+ export const setFontPicker = (fn) => { openFontPicker = fn; };
@@ -4,6 +4,13 @@ import { makeNumeric } from "./numeric.js";
4
4
  import { makeText } from "./text.js";
5
5
  import { makeCheckbox } from "./checkbox.js";
6
6
  import { makeSelect, makeRadio } from "./select.js";
7
+ import { makeFont } from "./font.js";
8
+ // Side-effect import: font-picker.js calls setFontPicker() at module scope, so
9
+ // the font widget's button finds a picker to open. It lives HERE and not in
10
+ // font.js because the dependency has to run picker → widget and never back —
11
+ // font.js must stay importable (and testable) without dragging the whole
12
+ // DOM-heavy picker in. See the note at the bottom of font.js.
13
+ import "../font-picker.js";
7
14
 
8
15
  export const WIDGET_FACTORIES = {
9
16
  slider: makeNumeric,
@@ -13,4 +20,5 @@ export const WIDGET_FACTORIES = {
13
20
  checkbox: makeCheckbox,
14
21
  select: makeSelect,
15
22
  radio: makeRadio,
23
+ font: makeFont,
16
24
  };
@@ -32,8 +32,16 @@ export function exportSubParts(part, view, params) {
32
32
 
33
33
  // Resolve a part's effective params + derived values for a build: the user's params
34
34
  // layered over the part defaults, and derive() run once over the result.
35
- export function resolveParams(part, params) {
35
+ //
36
+ // `sanitize(p)` is an optional hook that may rewrite the layered params IN PLACE —
37
+ // the seam a caller uses to refuse an untrusted value before it means anything.
38
+ // It runs BEFORE resolveDerived deliberately: derive() must see exactly the params
39
+ // build() will see, or a refused value still reaches the geometry through `d`.
40
+ // A hook rather than a second copy of this function in the caller, so "resolve a
41
+ // part's params" keeps one definition.
42
+ export function resolveParams(part, params, sanitize) {
36
43
  const p = { ...part.defaults, ...params };
44
+ sanitize?.(p);
37
45
  return { p, d: resolveDerived(part, p) };
38
46
  }
39
47
 
@@ -23,6 +23,15 @@ export default {
23
23
  description: "Grow (>0, bolder) or shrink (<0, thinner) the letters with a **Shape2D offset** — the same operation used for print clearance. Large negative values collapse thin strokes, so the letters hold at their thinnest valid size rather than breaking." },
24
24
  ],
25
25
  },
26
+ {
27
+ id: "typeface",
28
+ title: "Typeface",
29
+ description: "The face the lettering is cut in. Falls back to the bundled Roboto when left as the default.",
30
+ controls: [
31
+ { key: "face", type: "font", label: "Typeface",
32
+ description: "Any face the host's font catalog offers. Without a catalog this is a URL field — a direct link to a `.ttf` or `.otf` that allows cross-origin requests." },
33
+ ],
34
+ },
26
35
  {
27
36
  id: "plate",
28
37
  title: "Plate",
@@ -45,14 +54,19 @@ export default {
45
54
  ],
46
55
  },
47
56
  ],
48
- defaults: { label: "PARTFORGE\nv0.20", size: 8, depth: 1.2, stroke: 0, margin: 4, corner: 3, thickness: 3, engrave: 0 },
57
+ defaults: { label: "PARTFORGE\nv0.20", size: 8, depth: 1.2, stroke: 0, margin: 4, corner: 3, thickness: 3, engrave: 0, face: "" },
58
+ // A function of params, not a static map — that is what makes `face` a
59
+ // parameter rather than a constant. An empty value declares nothing, and
60
+ // text2d falls back to the bundled Roboto.
61
+ fonts: (p) => (p.face ? { face: p.face } : {}),
49
62
  parts: {
50
63
  plate: {
51
64
  label: "Nameplate",
52
65
  views: ["plate"],
53
66
  export: { name: "nameplate" },
54
67
  build: (k, p) => {
55
- let text = k.text2d(p.label, { size: p.size, align: "center", valign: "middle", lineHeight: p.size * 1.7 });
68
+ let text = k.text2d(p.label, { size: p.size, align: "center", valign: "middle",
69
+ lineHeight: p.size * 1.7, ...(p.face ? { font: "face" } : {}) });
56
70
  // Shape2D offset on the lettering: grow (>0, bolder) or shrink (<0, thinner). Guard
57
71
  // against a shrink that collapses thin strokes — keep the un-offset letters if so.
58
72
  if (p.stroke !== 0) {