partforge 0.73.1 → 0.75.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.75.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",
@@ -350,11 +350,20 @@ export function attachAnimationControls(viewer, part, {
350
350
  // even where ResizeObserver is absent.
351
351
  let onStructureChanged = null;
352
352
 
353
+ // Sketch mode takes this slot over: the app floats its own composer where the
354
+ // bar sits, and a frozen sketch over a playing animation is meaningless
355
+ // anyway. Its own flag rather than a straight write to `display`, for the
356
+ // reason mount.js spells out for the view cube's two hide reasons: with one
357
+ // assignment shared between causes, whichever fires last wins, and a view
358
+ // switch back to an animated view would reveal a bar that sketch mode still
359
+ // wants gone.
360
+ let hiddenForSketch = false;
361
+
353
362
  // Per-view + per-animation chrome: which chooser shows, the picker's options,
354
363
  // title, ⓘ description, pager labels, scrubber ticks. A view with no
355
364
  // animations hides the whole bar rather than showing an empty transport.
356
365
  function syncStructure() {
357
- bar.style.display = current ? "" : "none";
366
+ bar.style.display = current && !hiddenForSketch ? "" : "none";
358
367
  onStructureChanged?.();
359
368
  hideChapterBubble();
360
369
  if (!current) return;
@@ -791,6 +800,15 @@ export function attachAnimationControls(viewer, part, {
791
800
  playback?.userEdited();
792
801
  syncUi();
793
802
  },
803
+ // Hide/reveal for SKETCH mode only. `--pf-anim-clear` follows for free:
804
+ // syncStructure notifies the placement pass, which derives the clearance
805
+ // from this same `display` value and publishes 0px for a hidden bar.
806
+ setHidden(hidden) {
807
+ const next = hidden === true;
808
+ if (next === hiddenForSketch) return;
809
+ hiddenForSketch = next;
810
+ syncStructure();
811
+ },
794
812
  // Mount calls this from the view-tab onChange, BEFORE it refreshes the
795
813
  // view: the outgoing animation's params and opacity overrides must be
796
814
  // restored before the incoming view composes its assembly.
@@ -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
+ ];