partforge 0.73.0 → 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 +9 -4
- package/docs/AUTHORING-PARTS.md +75 -1
- package/docs/KERNEL-CONTRACT.md +3 -1
- package/package.json +1 -1
- package/src/framework/app.css +112 -0
- package/src/framework/font-source.js +70 -0
- package/src/framework/fonts.js +15 -0
- package/src/framework/jobs.js +96 -14
- package/src/framework/lint/index.js +2 -1
- package/src/framework/lint/rules-fonts.js +45 -0
- package/src/framework/measure/dim3-place.js +77 -8
- package/src/framework/measure/measure-mode.js +80 -15
- package/src/framework/mount.js +3 -1
- package/src/framework/panel/font-picker.js +400 -0
- package/src/framework/panel/render.js +7 -2
- package/src/framework/panel/widget-specs.js +2 -0
- package/src/framework/panel/widgets/font.js +125 -0
- package/src/framework/panel/widgets/index.js +8 -0
- package/src/framework/part-model.js +9 -1
- package/src/parts/nameplate.js +16 -2
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
|
-
|
|
83
|
-
|
|
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 });
|
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -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 } —
|
|
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
|
package/docs/KERNEL-CONTRACT.md
CHANGED
|
@@ -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
package/src/framework/app.css
CHANGED
|
@@ -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
|
+
}
|
package/src/framework/fonts.js
CHANGED
|
@@ -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
|
}
|
package/src/framework/jobs.js
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
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
|
|
109
|
-
|
|
110
|
-
|
|
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
|
+
];
|