partforge 0.101.0 → 0.102.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.
@@ -731,9 +731,9 @@ Every control accepts `key`, `type`, `label`, `description`, `hidden`, `when` an
731
731
  | `"checkbox"` | an on/off box: ticked writes `on`, cleared writes `0` | `on` (default `1`) |
732
732
  | `"select"` | a dropdown | `options` |
733
733
  | `"radio"` | a segmented button row | `options` |
734
- | `"font"` | a typeface picker (or a URL field with no catalog), plus a drop target | `allow`, `preview` |
735
- | `"image"` | an image picker (or a URL field with no catalog), plus a drop target | `allow` |
736
- | `"vector"` | a URL field plus a drop target — no catalog exists | |
734
+ | `"font"` | a typeface picker with a catalog, else a drop target | `allow`, `preview`, `sourceField` |
735
+ | `"image"` | an image picker with a catalog, else a drop target showing the artwork | `allow`, `sourceField` |
736
+ | `"vector"` | a drop target showing the artwork — no catalog exists | `sourceField` |
737
737
 
738
738
  Numeric controls always show the number box: drag the slider *or* type an exact
739
739
  value. Typed values may be finer than `step` and clamp to `[min, max]` on commit.
@@ -748,6 +748,18 @@ each entry is both value and label — or the long form
748
748
  types, `12` is not `"12"`). An option's `description` surfaces as a hover tooltip
749
749
  on that one option, not as a ⓘ popover.
750
750
 
751
+ **`sourceField: true`** (font/image/vector) adds a raw source text box to the
752
+ control. It is **off by default**: the drop target already carries the preview,
753
+ the drag target and click-to-choose, and where a catalog is wired there is a
754
+ picker too, so on a 288 px rail a text box is the affordance earning its space
755
+ least. Turn it on when typing a source by hand is something your users will
756
+ actually do — pasting an `https:` URL they already have, or a host `pfc-asset:`
757
+ token. Hiding it changes nothing else: the same values are accepted by the same
758
+ allow list, and a source set in `defaults` or by the host still applies.
759
+
760
+ For a `"font"` control with no `fontCatalog` this is the only text entry there
761
+ is, so a standalone app that expects users to paste font URLs should set it.
762
+
751
763
  **`allow` and `preview`** (font) configure the typeface control. `allow` lists the
752
764
  source kinds a **param-supplied** value may use — what the picker writes, or what
753
765
  arrives in a share link:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.101.0",
3
+ "version": "0.102.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,101 @@ 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
+ /* ── file drop targets ──────────────────────────────────────────────────────
202
+ Two forms of the same widget. The LABELLED form is a tile the user is meant
203
+ to find: it doubles as the thumbnail once there is something to show, so a
204
+ preview, a drop zone and a click-to-choose occupy one box rather than three.
205
+ The AMBIENT form is for a control that already has a visible way in (the font
206
+ control's catalog button) — it covers the control invisibly and only appears
207
+ while a file is over it, so dropping stays available without spending rail
208
+ height advertising itself. */
209
+ .file-drop {
210
+ position: relative; display: flex; flex-direction: column;
211
+ align-items: center; justify-content: center; gap: 6px;
212
+ min-height: 56px; margin-top: 6px; padding: 8px;
213
+ background: var(--pf-input-bg); color: var(--pf-hint);
214
+ border: 1px dashed var(--pf-border); border-radius: var(--pf-radius-control);
215
+ text-align: center; cursor: pointer;
216
+ }
217
+ .file-drop:focus-visible { outline: none; border-color: var(--pf-accent);
218
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--pf-accent) 35%, transparent); }
219
+ .file-drop-hint { font: 11px/1.35 var(--pf-sans); }
220
+
221
+ /* `contain` rather than `cover` because artwork and depth maps are content, not
222
+ decoration — cropping one to fill the box would misrepresent what is about to
223
+ be built. The two preview kinds then diverge, because a photograph and a piece
224
+ of line art want opposite things from the box around them. */
225
+ .file-drop > svg, .file-drop > img {
226
+ display: block; max-width: 100%; max-height: 96px; object-fit: contain;
227
+ }
228
+ .file-drop > svg { fill: var(--pf-text-2); }
229
+ /* Once there IS a preview, the frame moves off the tile and onto the artwork.
230
+ The tile stays full width — it is the drop target, and a full-width one is
231
+ easier to hit — but goes invisible, so what the eye sees is a rounded box
232
+ hugging the picture with the hint underneath it, rather than a picture
233
+ floating inside a much larger box. The border stays at 1px and only loses its
234
+ colour, so nothing shifts when a thumbnail arrives. */
235
+ .file-drop.has-thumb {
236
+ border-color: transparent; background: none; padding: 0; gap: 4px;
237
+ }
238
+ .file-drop.has-thumb > img, .file-drop.has-thumb > svg {
239
+ border: 1px solid var(--pf-border);
240
+ border-radius: var(--pf-radius-control);
241
+ background: var(--pf-input-bg);
242
+ }
243
+ /* A RASTER is a filled rectangle, so the border sits directly on the pixels —
244
+ padding around one reads as a mount around a picture. `border-radius` on an
245
+ `<img>` clips the image itself, which is what rounds the corners. Sized by
246
+ `max-height` rather than `height` so a small source is shown at its own size
247
+ instead of being upscaled into a blur.
248
+ `width: auto` is load-bearing: this element also carries `.image-preview`,
249
+ which pins it to `width: 100%` and letterboxes the picture inside a
250
+ rail-width box with `object-fit`. That is the right behaviour for a preview
251
+ that fills its container and the wrong one for a frame meant to hug, and it
252
+ is invisible in the markup — the box just silently stops matching the image. */
253
+ .file-drop.has-thumb > img { max-height: 96px; max-width: 100%; width: auto; margin: 0; }
254
+ /* A VECTOR is line art: strokes running into the border would collide with it
255
+ rather than be framed by it, so it keeps padding inside its own frame. Sizing
256
+ by height with `width: auto` lets the viewBox's aspect supply the width, so
257
+ the frame hugs the drawing instead of the SVG's default 300x150 box. */
258
+ .file-drop.has-thumb > svg { height: 96px; width: auto; max-width: 100%; padding: 8px; }
259
+
260
+ /* The empty-state hint and the replace hint trade places: exactly one is visible
261
+ in either state, so a tile is never without an instruction. */
262
+ .file-drop-hint-replace { display: none; }
263
+ .file-drop.has-thumb .file-drop-hint { display: none; }
264
+ .file-drop.has-thumb .file-drop-hint-replace { display: block; }
265
+
266
+ /* Ambient: no box of its own. Laid over the control, inert to the pointer so
267
+ every click still reaches the button underneath — the one affordance this
268
+ form exists to protect. */
269
+ /* Only the controls that actually carry an ambient overlay become positioned —
270
+ `.slider` is shared by every widget in the rail, and making all of them a
271
+ containing block to serve one would be a wide change for a narrow need. */
272
+ .slider:has(> .file-drop-ambient) { position: relative; }
273
+
274
+ .file-drop-ambient {
275
+ position: absolute; inset: 0; margin: 0; padding: 0;
276
+ min-height: 0; background: none; border: 0; pointer-events: none;
277
+ }
278
+ /* `dragover` is the only thing that reveals it. Pointer events stay off even
279
+ then: the drop event still fires on the element, and turning them on would
280
+ swallow a click that merely passed through mid-drag. */
281
+ .file-drop-ambient.file-drop-over {
282
+ border: 1px dashed var(--pf-accent); border-radius: var(--pf-radius-control);
283
+ background: color-mix(in oklab, var(--pf-accent) 12%, transparent);
284
+ }
285
+
286
+ /* Shared drag feedback for the labelled form. */
287
+ .file-drop.file-drop-over {
288
+ border-color: var(--pf-accent);
289
+ background: color-mix(in oklab, var(--pf-accent) 12%, transparent);
290
+ }
291
+
292
+ .file-drop-error {
293
+ margin-top: 5px; font: 11px/1.35 var(--pf-sans); color: var(--pf-err);
294
+ }
295
+
201
296
  /* the `type: "font"` control — a button that shows the current face IN it */
202
297
  .font-btn { width: 100%; display: flex; align-items: center; gap: 8px; text-align: left; cursor: pointer;
203
298
  background: var(--pf-input-bg); color: var(--pf-text-strong);
@@ -4,6 +4,7 @@ import { createViewer } from "./viewer.js";
4
4
  import { attachViewerControls } from "./viewer-controls.js";
5
5
  import { attachCutawayControls } from "./cutaway-controls.js";
6
6
  import { attachRail } from "./rail.js";
7
+ import { declaredSourceLookup } from "./panel/declared-source.js";
7
8
  import { attachMobileTabs } from "./mobile-tabs.js";
8
9
  import { createTooltipPresenter, attachButtonTooltips } from "./tooltip.js";
9
10
  import { loadCamera, loadProjection, saveProjection } from "./view-state.js";
@@ -976,7 +977,11 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
976
977
  }, onParamsCommit
977
978
  ? (changed) => onParamsCommit({ changed, params: { ...params } })
978
979
  : undefined,
979
- { fontCatalog, imageCatalog, onAssetUpload });
980
+ // What the part is actually using for each asset key, so a control opens
981
+ // showing the bundled default instead of an empty tile. Rebuilt per panel
982
+ // build, because the declaration is a function of the current params.
983
+ { fontCatalog, imageCatalog, onAssetUpload,
984
+ declaredSource: declaredSourceLookup(part, params) });
980
985
  cleanup.defer(() => panel.dispose());
981
986
  panelRef = panel;
982
987
  const updateRelevance = () => {
@@ -33,6 +33,17 @@ function authoredControl(c) {
33
33
  hidden: !!c.hidden,
34
34
  when: c.when,
35
35
  whenFalse: c.whenFalse,
36
+ // Asset-control fields. This list is an explicit allow-list, so anything
37
+ // missing from it is silently dropped rather than failing loudly — which is
38
+ // how `allow` came to be ignored: an authored `allow: ["asset"]` never
39
+ // reached the widget, so its own typed-URL check fell back to the default
40
+ // list and accepted values the worker then refused and reset underneath the
41
+ // user. The worker-side gate was never affected (imageControlAllows and its
42
+ // siblings walk the raw authored tree, not this node), so the enforcement
43
+ // held throughout — but the panel disagreed with it.
44
+ allow: c.allow,
45
+ preview: c.preview,
46
+ sourceField: c.sourceField,
36
47
  preserveOn: false,
37
48
  marksCustom: true,
38
49
  };
@@ -0,0 +1,128 @@
1
+ // What a part is ACTUALLY using for an asset key, when the control's own param
2
+ // is empty.
3
+ //
4
+ // The problem this solves is not cosmetic. A part's bundled default lives in the
5
+ // `images`/`vectors` DECLARATION, never in `defaults` — an author cannot move it
6
+ // there, because the allow list passes only `https` and a bundled asset resolves
7
+ // to a `file:`/dev URL, so `defaults: { relief: new URL(…) }` is refused and
8
+ // reset. The declaration is therefore the only home for it, and the panel could
9
+ // not see the declaration at all: `buildControls` receives `part.parameters`.
10
+ // The result was a control that opened empty while the part was plainly building
11
+ // from an image.
12
+ //
13
+ // Main-thread only, and deliberately import-free beyond what a lookup needs: it
14
+ // runs during panel construction, on every rebuild.
15
+
16
+ const arr = (v) => (Array.isArray(v) ? v : []);
17
+
18
+ // `images`/`vectors`/`fonts` may each be a plain map or a function of params —
19
+ // the function form is what a control drives. Resolving it can throw (it is
20
+ // author code running on every panel build), and a broken declaration must not
21
+ // take the panel down: the control simply shows nothing, which is the same thing
22
+ // it showed before this existed.
23
+ function resolveDecl(decl, params) {
24
+ try {
25
+ return typeof decl === "function" ? decl(params) : decl;
26
+ } catch {
27
+ return undefined;
28
+ }
29
+ }
30
+
31
+ const FIELD = { image: "images", vector: "vectors", font: "fonts" };
32
+
33
+ // URL-shaped for the same reason lint's probes are: a declaration may parse the
34
+ // value it is handed, and an arbitrary string would make it throw for reasons
35
+ // that have nothing to do with which asset the key feeds.
36
+ const SENTINEL = "pf-panel-sentinel://declared-source";
37
+
38
+ /**
39
+ * Build `(kind, key) => source | undefined` for one part and its current params.
40
+ *
41
+ * Returns `undefined` when the param already holds a value: the param IS the
42
+ * user's choice, and the declaration for that key is derived from it. Only an
43
+ * empty param falls through to whatever the part declared.
44
+ */
45
+ export function declaredSourceLookup(part, params) {
46
+ return (kind, key) => {
47
+ const own = params?.[key];
48
+ if (own !== undefined && own !== null && own !== "") return undefined;
49
+
50
+ const decl = part?.[FIELD[kind]];
51
+ const resolved = resolveDecl(decl, params ?? {});
52
+ if (!resolved || typeof resolved !== "object") return undefined;
53
+
54
+ // A control's param key is NOT necessarily the asset's name. emblem.js
55
+ // declares `vectors: (p) => ({ emblem: p.art || bundled })` — key `art`,
56
+ // asset `emblem`. relief.js happens to use the same word for both, which is
57
+ // what makes the assumption look safe until it is not.
58
+ //
59
+ // So probe, the way lint's *-control-not-in-* rules already do: resolve the
60
+ // declaration once with a sentinel in this key and see which asset name it
61
+ // came out under. A URL-shaped sentinel, because a declaration is free to
62
+ // parse what it is handed and an arbitrary string would make it throw for
63
+ // reasons unrelated to the mapping.
64
+ let name = key;
65
+ if (typeof decl === "function" && !(key in resolved)) {
66
+ const probe = resolveDecl(decl, { ...(params ?? {}), [key]: SENTINEL });
67
+ const hit = probe && typeof probe === "object"
68
+ && Object.keys(probe).find((n) => probe[n] === SENTINEL);
69
+ if (!hit) return undefined; // this key feeds nothing — not our asset
70
+ name = hit;
71
+ }
72
+
73
+ const source = resolved[name];
74
+ return source == null || source === "" ? undefined : source;
75
+ };
76
+ }
77
+
78
+ /**
79
+ * A declared image source -> something an `<img>` can load, or `undefined`.
80
+ *
81
+ * The allow list gates PARAMS, not author declarations, so a `file:` or dev URL
82
+ * is perfectly fine to display here — the browser is loading it, nothing is
83
+ * being accepted from an untrusted link.
84
+ */
85
+ export async function declaredImageUrl(source) {
86
+ try {
87
+ let v = typeof source === "function" ? await source() : source;
88
+ // A Vite `() => import("./x.png")` resolves to `{ default: url }`.
89
+ if (v && typeof v === "object" && !(v instanceof URL) && "default" in v) v = v.default;
90
+ if (v instanceof URL) return v.href;
91
+ if (typeof v === "string" && v) return v;
92
+ if (v instanceof ArrayBuffer || ArrayBuffer.isView(v)) {
93
+ return URL.createObjectURL(new Blob([v], { type: "image/png" }));
94
+ }
95
+ return undefined;
96
+ } catch {
97
+ return undefined; // a thunk that rejects shows nothing, it does not propagate
98
+ }
99
+ }
100
+
101
+ /**
102
+ * A declared vector source -> its parsed document, or `undefined`.
103
+ *
104
+ * Unlike an image there is nothing to point at: the thumbnail is drawn from the
105
+ * document's own contours, so the file has to be fetched and parsed. Results are
106
+ * memoised per source, because a panel rebuild would otherwise refetch on every
107
+ * slider drag.
108
+ */
109
+ const vectorDocs = new Map();
110
+ export async function declaredVectorDoc(source) {
111
+ if (source == null) return undefined;
112
+ if (typeof source === "object" && !(source instanceof URL) && !("default" in source)) return source;
113
+ if (vectorDocs.has(source)) return vectorDocs.get(source);
114
+ const p = (async () => {
115
+ try {
116
+ let v = typeof source === "function" ? await source() : source;
117
+ if (v && typeof v === "object" && !(v instanceof URL) && "default" in v) v = v.default;
118
+ if (v && typeof v === "object" && !(v instanceof URL)) return v; // already parsed
119
+ const res = await fetch(v instanceof URL ? v.href : v);
120
+ if (!res.ok) return undefined;
121
+ return await res.json();
122
+ } catch {
123
+ return undefined; // offline, 404, CORS, malformed JSON — all show nothing
124
+ }
125
+ })();
126
+ vectorDocs.set(source, p);
127
+ return p;
128
+ }
@@ -229,6 +229,7 @@ export function buildControls(root, parameters, params, onDirty, onCommit, opts
229
229
  fontCatalog: opts.fontCatalog,
230
230
  imageCatalog: opts.imageCatalog,
231
231
  onAssetUpload: opts.onAssetUpload,
232
+ declaredSource: opts.declaredSource,
232
233
  });
233
234
  nodeEls.set(node.id, widget.el);
234
235
  if (node.key && !keyToId.has(node.key)) keyToId.set(node.key, node.id);
@@ -36,9 +36,9 @@ 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"] },
40
- { type: "image", kind: "control", fields: [...AUTHOR_COMMON, "allow"] },
41
- { type: "vector", kind: "control", fields: [...AUTHOR_COMMON, "allow"] },
39
+ { type: "font", kind: "control", fields: [...AUTHOR_COMMON, "allow", "preview", "sourceField"] },
40
+ { type: "image", kind: "control", fields: [...AUTHOR_COMMON, "allow", "sourceField"] },
41
+ { type: "vector", kind: "control", fields: [...AUTHOR_COMMON, "allow", "sourceField"] },
42
42
  { type: "readout", kind: "display", fields: ["type", "label", "description", "unit", "derivedKey", "hidden", "when", "whenFalse"] },
43
43
  ];
44
44
 
@@ -57,9 +57,9 @@ const AUTHOR_EXTRAS = {
57
57
  checkbox: ["on"],
58
58
  select: ["options"],
59
59
  radio: ["options"],
60
- font: ["allow", "preview"],
61
- image: ["allow"],
62
- vector: ["allow"],
60
+ font: ["allow", "preview", "sourceField"],
61
+ image: ["allow", "sourceField"],
62
+ vector: ["allow", "sourceField"],
63
63
  };
64
64
  const AUTHOR_FIELDS = new Map(Object.entries(AUTHOR_EXTRAS).map(
65
65
  ([type, extra]) => [type, [...AUTHOR_COMMON, ...extra]]));
@@ -86,24 +86,47 @@ function el(tag, className, text) {
86
86
  return node;
87
87
  }
88
88
 
89
- export function makeFileDrop({ kind, onSource, onError, onAssetUpload }) {
89
+ // `ambient` is for a control that already has a visible way in — the font
90
+ // control's catalog button, say. A labelled drop zone under it would spend rail
91
+ // height repeating the same offer, so the ambient form carries no hint, no click
92
+ // target and no place in the tab order: it is an overlay that shows itself only
93
+ // while a file is over it (see `.file-drop-ambient` in app.css). Dropping still
94
+ // works, it is simply not advertised.
95
+ //
96
+ // The click path is dropped rather than hidden, deliberately: an invisible
97
+ // overlay that still swallowed clicks would eat the button underneath it, which
98
+ // is the one affordance ambient mode exists to protect.
99
+ export function makeFileDrop({ kind, onSource, onError, onAssetUpload, ambient = false }) {
90
100
  const row = rowFor(kind);
91
- const wrap = el("div", "file-drop");
92
- wrap.tabIndex = 0;
93
- wrap.setAttribute("role", "button");
94
- const hint = el("span", "file-drop-hint", `Drop ${row?.label ?? "a file"} here, or click to choose`);
95
- wrap.append(hint);
101
+ const wrap = el("div", ambient ? "file-drop file-drop-ambient" : "file-drop");
102
+ if (!ambient) {
103
+ wrap.tabIndex = 0;
104
+ wrap.setAttribute("role", "button");
105
+ // Two hints, swapped by CSS on `.has-thumb`. The empty-state one is the only
106
+ // thing in an empty tile; the replace one takes over once a preview fills it.
107
+ // Without the second, a tile showing a part's declared artwork — now the
108
+ // state a control OPENS in — carried no instruction at all, because the
109
+ // first is hidden the moment a thumbnail appears.
110
+ wrap.append(el("span", "file-drop-hint", `Drop ${row?.label ?? "a file"} here, or click to choose`));
111
+ wrap.append(el("span", "file-drop-hint file-drop-hint-replace", "Drop to replace, or click to choose"));
112
+ }
96
113
 
97
114
  // The click/keyboard path to the same handler a drop uses. Hidden rather
98
115
  // than absent: a real `<input type="file">` is what gives this a native
99
116
  // "Choose File" affordance and OS-level type filtering (`accept`), neither
100
117
  // of which is worth hand-rolling.
101
- const input = document.createElement("input");
102
- input.type = "file";
103
- input.className = "file-drop-input";
104
- input.hidden = true;
105
- if (row?.accepts?.length) input.accept = row.accepts.join(",");
106
- wrap.append(input);
118
+ // Ambient mode has no click path, so it gets no input at all — an unused one
119
+ // would still be focusable in some browsers and would show up to a screen
120
+ // reader as a second, unlabelled file control.
121
+ let input = null;
122
+ if (!ambient) {
123
+ input = document.createElement("input");
124
+ input.type = "file";
125
+ input.className = "file-drop-input";
126
+ input.hidden = true;
127
+ if (row?.accepts?.length) input.accept = row.accepts.join(",");
128
+ wrap.append(input);
129
+ }
107
130
 
108
131
  // The converted artifact from the most recently accepted drop — a Blob (or,
109
132
  // for a `convert: null` kind like font, the original File, which already IS
@@ -266,10 +289,15 @@ export function makeFileDrop({ kind, onSource, onError, onAssetUpload }) {
266
289
  wrap.addEventListener("drop", onDrop, { signal });
267
290
  wrap.addEventListener("dragover", onDragOver, { signal });
268
291
  wrap.addEventListener("dragleave", onDragLeave, { signal });
269
- wrap.addEventListener("click", onClick, { signal });
270
- wrap.addEventListener("keydown", onKeydown, { signal });
271
292
  wrap.addEventListener("paste", onPaste, { signal });
272
- input.addEventListener("change", onChange, { signal });
293
+ // Click, keyboard and the input's own change only exist when there is an input
294
+ // to open — ambient mode is drop-and-paste only, so that the control's real
295
+ // button keeps every click.
296
+ if (input) {
297
+ wrap.addEventListener("click", onClick, { signal });
298
+ wrap.addEventListener("keydown", onKeydown, { signal });
299
+ input.addEventListener("change", onChange, { signal });
300
+ }
273
301
 
274
302
  return {
275
303
  el: wrap,
@@ -295,12 +323,13 @@ export function makeFileDrop({ kind, onSource, onError, onAssetUpload }) {
295
323
  // Returns `{ el, errorEl, dispose }` rather than appending anything itself —
296
324
  // the caller still owns layout (where the drop target and error line sit
297
325
  // relative to the field/button), only the wiring is shared.
298
- export function mountDrop(kind, { params, node, onAssetUpload, onChange, onCommit, onRender }) {
326
+ export function mountDrop(kind, { params, node, onAssetUpload, onChange, onCommit, onRender, ambient = false }) {
299
327
  const errorEl = el("div", "file-drop-error");
300
328
  errorEl.hidden = true;
301
329
 
302
330
  const drop = makeFileDrop({
303
331
  kind,
332
+ ambient,
304
333
  onAssetUpload,
305
334
  onSource: (source) => {
306
335
  errorEl.hidden = true;
@@ -86,7 +86,11 @@ export function makeFont(node, params, { onChange, onCommit, info, fontCatalog,
86
86
  onCommit?.();
87
87
  });
88
88
  paintField();
89
- wrap.append(field);
89
+ // Same opt-in rule as image/vector: the URL box appears only for
90
+ // `sourceField: true`. This branch has no catalog button, so hiding it
91
+ // leaves the drop zone as the way in — which is why THIS branch keeps its
92
+ // labelled drop zone rather than going ambient like the catalog one below.
93
+ if (node.sourceField === true) wrap.append(field);
90
94
 
91
95
  const drop = mountDrop("font", {
92
96
  params, node, onAssetUpload, onChange, onCommit, onRender: paintField,
@@ -146,7 +150,11 @@ export function makeFont(node, params, { onChange, onCommit, info, fontCatalog,
146
150
  picker = openFontPicker?.({ node, params, allow, fontCatalog, anchor: wrap, onPicked: () => { paint(); onChange?.(); onCommit?.(); } }) ?? null;
147
151
  });
148
152
 
149
- const drop = mountDrop("font", { params, node, onAssetUpload, onChange, onCommit, onRender: paint });
153
+ // Ambient: this branch already has the catalog button as its visible way in, so
154
+ // the drop covers the control invisibly and reveals itself only while a file is
155
+ // over it. The no-catalog branch above stays labelled — there, the drop zone is
156
+ // the only affordance and hiding it would strand the user.
157
+ const drop = mountDrop("font", { params, node, onAssetUpload, onChange, onCommit, onRender: paint, ambient: true });
150
158
  wrap.append(drop.el, drop.errorEl);
151
159
 
152
160
  return {
@@ -18,6 +18,7 @@
18
18
  import { attachInfo } from "../info.js";
19
19
  import { IMAGE_ALLOW_DEFAULT, imageSourceAllowed } from "../../image-source.js";
20
20
  import { mountDrop } from "./file-drop.js";
21
+ import { declaredImageUrl } from "../declared-source.js";
21
22
 
22
23
  function el(tag, className, text) {
23
24
  const node = document.createElement(tag);
@@ -40,21 +41,70 @@ export function imageLabel(source) {
40
41
  return file || source;
41
42
  }
42
43
 
43
- // Point (or unpoint) the live preview. A byte-valued param has no URL to hand
44
- // the browser, so the image stays hidden rather than trying to render it or
45
- // showing a broken-image glyphsame rule an empty/unset value gets. `onerror`
46
- // covers the other broken-image case: a URL that 404s or that CORS refuses.
47
- function paintPreview(img, source) {
48
- if (typeof source === "string" && source) {
44
+ // An object URL is a real resource, not a string: the browser pins the blob
45
+ // behind it until it is revoked, and a panel rebuild constructs a fresh widget
46
+ // every time. This owns the whole lifetime one live URL at a time, the old one
47
+ // revoked before a new one replaces it, and everything released on dispose — so
48
+ // no caller has to remember. Returns `null` for a value that needs no URL.
49
+ function makeObjectUrlSlot() {
50
+ let current = null;
51
+ const release = () => {
52
+ if (current) URL.revokeObjectURL(current);
53
+ current = null;
54
+ };
55
+ return {
56
+ forBytes(source) {
57
+ release();
58
+ if (!isBytes(source)) return null;
59
+ // Always image/png: `imageToPng` is what produced these bytes, whatever the
60
+ // user dropped. The type matters — a Blob with none renders nothing.
61
+ current = URL.createObjectURL(new Blob([source], { type: "image/png" }));
62
+ return current;
63
+ },
64
+ dispose: release,
65
+ };
66
+ }
67
+
68
+ // Point (or unpoint) the live preview. A string source is used directly. Bytes —
69
+ // the partforge-cloud sandbox path, where the converted PNG travels in the param
70
+ // because that sandbox cannot fetch URLs — become an object URL, so the cloud
71
+ // gets the same thumbnail as everyone else rather than a blank tile. `onerror`
72
+ // still covers the remaining broken-image case: a URL that 404s or CORS refuses.
73
+ // Resolved ONCE per paint, never per image: the catalog rendering shows the same
74
+ // source in two <img>s, and asking the slot twice would revoke the URL it had
75
+ // just handed the first one, leaving it pointing at a dead blob.
76
+ function previewSrc(source, urls) {
77
+ return typeof source === "string" && source ? source : urls.forBytes(source);
78
+ }
79
+
80
+ // When the control's own param is empty, show what the PART is using: its
81
+ // bundled default lives in the `images` declaration, which is the only place it
82
+ // can live (the allow list passes only https, so a file:/dev URL cannot sit in
83
+ // `defaults`). Resolving it is async — a Vite thunk has to be called — so the
84
+ // tile paints empty first and fills in, and a source that never resolves simply
85
+ // leaves it empty. `seq` guards against a slow resolve landing after a newer one.
86
+ function paintDeclared(img, declaredSource, node, apply) {
87
+ if (!declaredSource) return;
88
+ const source = declaredSource("image", node.key);
89
+ if (source === undefined) return;
90
+ const seq = ++img._pfDeclaredSeq;
91
+ declaredImageUrl(source).then((url) => {
92
+ if (url && seq === img._pfDeclaredSeq) apply(url);
93
+ });
94
+ }
95
+
96
+ function applyPreview(img, src) {
97
+ if (src) {
49
98
  img.hidden = false;
50
- img.src = source;
99
+ img.src = src;
51
100
  } else {
52
101
  img.hidden = true;
53
102
  img.removeAttribute("src");
54
103
  }
55
104
  }
56
105
 
57
- export function makeImage(node, params, { onChange, onCommit, info, imageCatalog, onAssetUpload } = {}) {
106
+ export function makeImage(node, params, { onChange, onCommit, info, imageCatalog, onAssetUpload, declaredSource } = {}) {
107
+ const urls = makeObjectUrlSlot(); // one live preview URL per widget; see makeObjectUrlSlot
58
108
  const allow = Array.isArray(node.allow) && node.allow.length ? node.allow : IMAGE_ALLOW_DEFAULT;
59
109
  const wrap = el("div", "slider");
60
110
  const row = el("div", "row");
@@ -65,12 +115,12 @@ export function makeImage(node, params, { onChange, onCommit, info, imageCatalog
65
115
 
66
116
  const preview = document.createElement("img");
67
117
  preview.className = "image-preview";
118
+ preview._pfDeclaredSeq = 0;
68
119
  preview.alt = "";
69
120
  preview.hidden = true;
70
121
  // A URL that fails to load (404, CORS, revoked link) must degrade to hidden,
71
122
  // not the browser's broken-image glyph.
72
123
  preview.addEventListener("error", () => { preview.hidden = true; });
73
- wrap.append(preview);
74
124
 
75
125
  if (!imageCatalog) {
76
126
  // Degraded path: a URL field. Unlike `text`, it does NOT write on every
@@ -88,7 +138,13 @@ export function makeImage(node, params, { onChange, onCommit, info, imageCatalog
88
138
  field.value = isBytes(v) ? "" : String(v ?? "");
89
139
  field.placeholder = isBytes(v) ? "Uploaded image" : "";
90
140
  field.classList.remove("warn");
91
- paintPreview(preview, v);
141
+ const own = previewSrc(v, urls);
142
+ applyPreview(preview, own);
143
+ preview.parentElement?.classList.toggle("has-thumb", !preview.hidden);
144
+ if (!own) paintDeclared(preview, declaredSource, node, (url) => {
145
+ applyPreview(preview, url);
146
+ preview.parentElement?.classList.toggle("has-thumb", true);
147
+ });
92
148
  };
93
149
  field.addEventListener("change", () => {
94
150
  if (!imageSourceAllowed(field.value, allow)) { field.classList.add("warn"); return; }
@@ -98,14 +154,25 @@ export function makeImage(node, params, { onChange, onCommit, info, imageCatalog
98
154
  onCommit?.();
99
155
  });
100
156
  paintField();
101
- wrap.append(field);
157
+ // The URL box is OFF unless `sourceField: true`. The tile is already preview,
158
+ // drop target and click-to-choose in one, so on a 288 px rail a fourth
159
+ // affordance for the same job is the one earning its space least. Typing a
160
+ // source by hand is the rarer intent — a host token or an https URL someone
161
+ // already has — so it is the part that becomes opt-in, rather than the one
162
+ // every part pays rail height for.
163
+ if (node.sourceField === true) wrap.append(field);
102
164
 
103
165
  const drop = mountDrop("image", {
104
166
  params, node, onAssetUpload, onChange, onCommit, onRender: paintField,
105
167
  });
168
+ // The tile IS the preview: dropping, clicking to choose, and showing what is
169
+ // currently selected become one box rather than three stacked ones.
170
+ // `has-thumb` swaps the dashed empty-state border for a solid frame.
171
+ drop.el.setAttribute("data-pf-thumb", "");
172
+ drop.el.prepend(preview);
106
173
  wrap.append(drop.el, drop.errorEl);
107
174
 
108
- return { el: wrap, sync: paintField, dispose: () => drop.dispose() };
175
+ return { el: wrap, sync: paintField, dispose: () => { drop.dispose(); urls.dispose(); } };
109
176
  }
110
177
 
111
178
  const btn = el("button", "image-btn");
@@ -131,8 +198,15 @@ export function makeImage(node, params, { onChange, onCommit, info, imageCatalog
131
198
  const paint = () => {
132
199
  const src = params[node.key];
133
200
  const seq = ++paintSeq;
134
- paintPreview(preview, src);
135
- paintPreview(thumb, src);
201
+ const url = previewSrc(src, urls);
202
+ applyPreview(preview, url);
203
+ applyPreview(thumb, url);
204
+ preview.parentElement?.classList.toggle("has-thumb", !preview.hidden);
205
+ if (!url) paintDeclared(preview, declaredSource, node, (u) => {
206
+ applyPreview(preview, u);
207
+ applyPreview(thumb, u);
208
+ preview.parentElement?.classList.toggle("has-thumb", true);
209
+ });
136
210
  const show = ({ label: text, width, height }) => {
137
211
  if (seq !== paintSeq) return; // a newer paint already won
138
212
  iname.textContent = width && height ? `${text} (${width}×${height})` : text;
@@ -162,12 +236,16 @@ export function makeImage(node, params, { onChange, onCommit, info, imageCatalog
162
236
  });
163
237
 
164
238
  const drop = mountDrop("image", { params, node, onAssetUpload, onChange, onCommit, onRender: paint });
239
+ // Same merge as the degraded branch — the large preview lives in the drop tile;
240
+ // the catalog button keeps its own small thumb.
241
+ drop.el.setAttribute("data-pf-thumb", "");
242
+ drop.el.prepend(preview);
165
243
  wrap.append(drop.el, drop.errorEl);
166
244
 
167
245
  return {
168
246
  el: wrap,
169
247
  sync: paint,
170
- dispose: () => { picker?.close(); picker = null; drop.dispose(); },
248
+ dispose: () => { picker?.close(); picker = null; drop.dispose(); urls.dispose(); },
171
249
  };
172
250
  }
173
251
 
@@ -0,0 +1,136 @@
1
+ // A vector document -> a small inline <svg> preview, for the vector control's
2
+ // thumbnail. MAIN-THREAD ONLY (it builds DOM), but deliberately free of any
3
+ // heavy geometry import: `profile.js` is the one dependency and has no imports
4
+ // of its own. Reaching for `vector-format.js`'s expander instead would pull in
5
+ // contour-ops -> paper-bridge -> paper.js, ~1 MB of curve engine loaded on every
6
+ // page that merely SHOWS a vector control, whether or not anyone drops a file.
7
+ //
8
+ // `tessellateContour` is the geometry's OWN tessellator, which matters more than
9
+ // the saved bytes: a thumbnail that flattened curves its own way could show a
10
+ // shape the kernel would not build. Arcs are the specific trap — the format
11
+ // writes them as a point ON the arc, while SVG's `A` command wants radii and
12
+ // sweep flags, so "just map it to A" is a second interpretation waiting to
13
+ // diverge. At thumbnail size a tessellated arc is pixel-identical anyway.
14
+ import { tessellateContour } from "../../geometry/profile.js";
15
+
16
+ // Enough segments that a full circle reads as round at ~44 px, cheap enough that
17
+ // a document with hundreds of contours still renders in one frame.
18
+ const ARC_SEGS = 24;
19
+
20
+ const SVG_NS = "http://www.w3.org/2000/svg";
21
+
22
+ const finite = (n) => typeof n === "number" && Number.isFinite(n);
23
+ const finitePoint = (p) => Array.isArray(p) && p.length >= 2 && finite(p[0]) && finite(p[1]);
24
+
25
+ // The four contour kinds, reduced to a ring of points. The three primitives are
26
+ // sugar the format defines by expansion; `toInternalDocument` normally does this,
27
+ // but it lives behind the paper.js import described above — and these expansions
28
+ // are four lines each, so the thumbnail does them directly rather than paying
29
+ // that cost. `path` delegates to the canonical tessellator.
30
+ function ring(contour) {
31
+ if (!contour || typeof contour !== "object") return null;
32
+ switch (contour.kind) {
33
+ case "circle": {
34
+ const { center: c, r } = contour;
35
+ if (!finitePoint(c) || !finite(r) || r <= 0) return null;
36
+ return Array.from({ length: ARC_SEGS }, (_, i) => {
37
+ const t = (i / ARC_SEGS) * Math.PI * 2;
38
+ return [c[0] + Math.cos(t) * r, c[1] + Math.sin(t) * r];
39
+ });
40
+ }
41
+ case "rect": {
42
+ const { center: c, width: w, height: h } = contour;
43
+ if (!finitePoint(c) || !finite(w) || !finite(h) || w <= 0 || h <= 0) return null;
44
+ const hw = w / 2, hh = h / 2;
45
+ // Corner radius is ignored: at thumbnail scale the rounding is sub-pixel,
46
+ // and squaring it off never changes what the shape reads as.
47
+ return [[c[0] - hw, c[1] - hh], [c[0] + hw, c[1] - hh], [c[0] + hw, c[1] + hh], [c[0] - hw, c[1] + hh]];
48
+ }
49
+ case "polygon":
50
+ return Array.isArray(contour.points) && contour.points.length >= 3
51
+ && contour.points.every(finitePoint) ? contour.points.map((p) => [p[0], p[1]]) : null;
52
+ case "path":
53
+ default: {
54
+ if (!finitePoint(contour.start) || !Array.isArray(contour.segments)) return null;
55
+ // The FILE format names an arc's midpoint `through`; the internal contour
56
+ // IR names it `via`, and that is what `tessellateContour` reads. A document
57
+ // read off disk or returned by `ingestSvg` therefore speaks `through`, and
58
+ // a segment with neither key is treated as a straight line — so skipping
59
+ // this rename does not fail loudly, it silently replaces every curve with
60
+ // its chord. A circle becomes a triangle, which looks like a rendering bug
61
+ // rather than a parsing one.
62
+ const segments = contour.segments.map((seg) =>
63
+ seg && seg.through && !seg.via ? { ...seg, via: seg.through } : seg);
64
+ const pts = tessellateContour({ ...contour, segments }, ARC_SEGS);
65
+ return Array.isArray(pts) && pts.length >= 3 && pts.every(finitePoint) ? pts : null;
66
+ }
67
+ }
68
+ }
69
+
70
+ // One subpath. Y is negated because the model frame is y-up and SVG is y-down;
71
+ // the viewBox is negated to match, so the flip is a coordinate convention rather
72
+ // than a transform the caller has to know about.
73
+ const subpath = (pts) =>
74
+ `M ${pts.map(([x, y], i) => `${i ? "L " : ""}${+x.toFixed(3)} ${+(-y).toFixed(3)}`).join(" ")} Z`;
75
+
76
+ /**
77
+ * Render a partforge-vector document as an inline <svg>, or return `null` when
78
+ * there is nothing renderable — an empty document, or one whose coordinates are
79
+ * not finite. Returning null rather than throwing keeps a malformed document
80
+ * from taking the control down with it; the caller falls back to a placeholder.
81
+ */
82
+ export function vectorThumb(doc) {
83
+ const shapes = doc?.shapes;
84
+ if (!shapes || typeof shapes !== "object") return null;
85
+
86
+ // Every region from every shape lands in ONE path so `evenodd` composes them:
87
+ // a `subtract` shape's regions then cut the shapes they overlap, which is what
88
+ // the document means. The known limitation is that two overlapping regions of
89
+ // the SAME role also cancel — real composition is a boolean the panel has no
90
+ // business running. At preview size that trade is invisible, and a missing hole
91
+ // would be far more misleading than a rare cancelled overlap.
92
+ const subpaths = [];
93
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
94
+
95
+ for (const shape of Object.values(shapes)) {
96
+ // A shape is either an array of regions, or `{ role, regions }` — §2.3.
97
+ const regions = Array.isArray(shape) ? shape : shape?.regions;
98
+ if (!Array.isArray(regions)) continue;
99
+ for (const region of regions) {
100
+ for (const contour of [region?.outer, ...(region?.holes ?? [])]) {
101
+ if (contour === undefined) continue;
102
+ const pts = ring(contour);
103
+ if (!pts) return null; // a bad coordinate anywhere means the preview would lie
104
+ for (const [x, y] of pts) {
105
+ if (x < minX) minX = x;
106
+ if (x > maxX) maxX = x;
107
+ if (y < minY) minY = y;
108
+ if (y > maxY) maxY = y;
109
+ }
110
+ subpaths.push(subpath(pts));
111
+ }
112
+ }
113
+ }
114
+ if (!subpaths.length) return null;
115
+
116
+ // `bbox` is optional and "recomputed when absent" (§2.1), so a hand-authored
117
+ // document routinely has none. Trusting it when present keeps the preview
118
+ // framed the way the document says it should be; deriving it otherwise is what
119
+ // makes an authored file previewable at all.
120
+ const b = doc.bbox;
121
+ const box = b && [b.minX, b.minY, b.maxX, b.maxY].every(finite)
122
+ ? b : { minX, minY, maxX, maxY };
123
+ const w = box.maxX - box.minX, h = box.maxY - box.minY;
124
+ if (!(w > 0) || !(h > 0)) return null;
125
+
126
+ const svg = document.createElementNS(SVG_NS, "svg");
127
+ svg.setAttribute("viewBox", `${+box.minX.toFixed(3)} ${+(-box.maxY).toFixed(3)} ${+w.toFixed(3)} ${+h.toFixed(3)}`);
128
+ svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
129
+ svg.setAttribute("aria-hidden", "true"); // decorative; the control carries the label
130
+
131
+ const path = document.createElementNS(SVG_NS, "path");
132
+ path.setAttribute("d", subpaths.join(" "));
133
+ path.setAttribute("fill-rule", "evenodd");
134
+ svg.append(path);
135
+ return svg;
136
+ }
@@ -19,6 +19,8 @@
19
19
  import { attachInfo } from "../info.js";
20
20
  import { VECTOR_ALLOW_DEFAULT, vectorSourceAllowed } from "../../vector-source.js";
21
21
  import { mountDrop } from "./file-drop.js";
22
+ import { vectorThumb } from "./vector-thumb.js";
23
+ import { declaredVectorDoc } from "../declared-source.js";
22
24
 
23
25
  function el(tag, className, text) {
24
26
  const node = document.createElement(tag);
@@ -34,7 +36,7 @@ const isBytes = (v) => v instanceof ArrayBuffer || ArrayBuffer.isView(v);
34
36
  // byte-valued param.
35
37
  const isOpaque = (v) => isBytes(v) || (v != null && typeof v === "object");
36
38
 
37
- export function makeVector(node, params, { onChange, onCommit, info, onAssetUpload } = {}) {
39
+ export function makeVector(node, params, { onChange, onCommit, info, onAssetUpload, declaredSource } = {}) {
38
40
  const allow = Array.isArray(node.allow) && node.allow.length ? node.allow : VECTOR_ALLOW_DEFAULT;
39
41
  const wrap = el("div", "slider");
40
42
  const row = el("div", "row");
@@ -66,12 +68,53 @@ export function makeVector(node, params, { onChange, onCommit, info, onAssetUplo
66
68
  onCommit?.();
67
69
  });
68
70
  paintField();
69
- wrap.append(field);
71
+ // The URL box is OFF unless `sourceField: true` — same reasoning as
72
+ // widgets/image.js: the tile already carries preview, drop and click-to-choose,
73
+ // and typing a source by hand is the rarer intent.
74
+ if (node.sourceField === true) wrap.append(field);
70
75
 
76
+ // The thumbnail IS the drop target. A vector param holds a parsed document, so
77
+ // there is no URL an <img> could point at — the artwork is drawn inline
78
+ // instead, and that tile is what a file is dropped on and what opens the file
79
+ // picker. One element doing all three keeps the rail's 300 px from carrying a
80
+ // preview, a drop zone and a button that all mean the same thing.
71
81
  const drop = mountDrop("vector", {
72
- params, node, onAssetUpload, onChange, onCommit, onRender: paintField,
82
+ params, node, onAssetUpload, onChange, onCommit, onRender: () => { paintField(); paintThumb(); },
73
83
  });
74
- wrap.append(drop.el, drop.errorEl);
84
+ const thumb = drop.el;
85
+ thumb.setAttribute("data-pf-thumb", "");
75
86
 
76
- return { el: wrap, sync: paintField, dispose: () => drop.dispose() };
87
+ // `vectorThumb` returns null for a document it cannot draw — malformed, empty,
88
+ // or carrying a coordinate that is not finite — rather than throwing. The tile
89
+ // stays either way, because it is the drop target: losing it on a bad document
90
+ // would strand the user with no way to replace it.
91
+ let thumbSeq = 0;
92
+ function showThumb(doc) {
93
+ const art = thumb.querySelector("svg");
94
+ if (art) art.remove();
95
+ const svg = doc ? vectorThumb(doc) : null;
96
+ thumb.classList.toggle("has-thumb", !!svg);
97
+ if (svg) thumb.prepend(svg);
98
+ }
99
+ function paintThumb() {
100
+ const own = params[node.key];
101
+ const seq = ++thumbSeq;
102
+ if (isOpaque(own)) { showThumb(own); return; }
103
+ showThumb(null);
104
+ // Nothing in the param — fall back to what the PART declares, which is where
105
+ // a bundled default has to live (the allow list passes only https, so a
106
+ // file:/dev URL cannot sit in `defaults`). Unlike an image there is nothing
107
+ // to point at: the document must be fetched and parsed before it can be
108
+ // drawn, so this lands a tick or two later, and a source that never resolves
109
+ // just leaves the tile empty — it stays a drop target either way.
110
+ const source = declaredSource?.("vector", node.key);
111
+ if (source === undefined) return;
112
+ declaredVectorDoc(source).then((doc) => { if (doc && seq === thumbSeq) showThumb(doc); });
113
+ }
114
+ paintThumb();
115
+
116
+ wrap.append(thumb, drop.errorEl);
117
+
118
+ const sync = () => { paintField(); paintThumb(); };
119
+ return { el: wrap, sync, dispose: () => drop.dispose() };
77
120
  }
@@ -27,10 +27,14 @@ import plate from "./assets/plate.vector.json" with { type: "json" };
27
27
 
28
28
  export default {
29
29
  meta: { title: "Emblem", units: "mm", background: 0x15181d },
30
- vectors: {
31
- emblem: new URL("./assets/emblem.vector.json", import.meta.url),
30
+ // Function form, so the `type: "vector"` control below can drive the artwork.
31
+ // The bundled file is the default: dropping an SVG replaces it, clearing the
32
+ // control brings it back, and the part still builds with no network — which is
33
+ // what CI and `partforge measure` see.
34
+ vectors: (p) => ({
35
+ emblem: p.art || new URL("./assets/emblem.vector.json", import.meta.url),
32
36
  plate,
33
- },
37
+ }),
34
38
  parameters: [
35
39
  {
36
40
  id: "plate",
@@ -45,15 +49,24 @@ export default {
45
49
  id: "art",
46
50
  title: "Artwork",
47
51
  description: "The embossed vector art. `emblem.svg` carries a filled circle and a stroked bar, so both of ingest's geometry paths are exercised.",
48
- advanced: [
49
- { key: "emblem_w", label: "Emblem width", unit: "mm", min: 8, max: 70, step: 1,
50
- description: "Width of the artwork's **tight bounding box** in mm — not its `viewBox`. Stroke thickness scales with it." },
51
- { key: "emboss", label: "Emboss height", unit: "mm", min: 0.4, max: 4, step: 0.2,
52
- description: "How far the artwork stands proud of the plate." },
52
+ // Converted from the legacy `advanced` array to the new shape: a section is
53
+ // one or the other, never both, because mixing them makes render order
54
+ // arbitrary (partforge lint's `mixed-section-shape`). `advanced` becomes a
55
+ // nested group, which renders the same way.
56
+ controls: [
57
+ { key: "art", type: "vector", label: "Source",
58
+ description: "Drop an SVG to replace the bundled emblem — it is converted to "
59
+ + "partforge-vector once, in the browser, at drop time. Clear it to go back to the bundled file." },
60
+ { type: "group", title: "Advanced", collapsed: "auto", controls: [
61
+ { key: "emblem_w", label: "Emblem width", unit: "mm", min: 8, max: 70, step: 1,
62
+ description: "Width of the artwork's **tight bounding box** in mm — not its `viewBox`. Stroke thickness scales with it." },
63
+ { key: "emboss", label: "Emboss height", unit: "mm", min: 0.4, max: 4, step: 0.2,
64
+ description: "How far the artwork stands proud of the plate." },
65
+ ] },
53
66
  ],
54
67
  },
55
68
  ],
56
- defaults: { plate_t: 3, emblem_w: 30, emboss: 1 },
69
+ defaults: { art: "", plate_t: 3, emblem_w: 30, emboss: 1 },
57
70
  parts: {
58
71
  plate: {
59
72
  label: "Plate",