partforge 0.101.0 → 0.103.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/README.md CHANGED
@@ -113,6 +113,15 @@ await runtime.ready; // first successful build (rejects on a first-build error
113
113
  runtime.setHostPane("rail"); // narrow layout only: show just the controls
114
114
  // rail ('stage' | 'rail'), suppressing the
115
115
  // built-in tab bar. null hands selection back.
116
+ runtime.setRailLayout({ mode: "dock", inset: 380, railHeight: 316 });
117
+ // where the rail SITS when the host draws chrome over
118
+ // the frame: docked into its bottom sheet (inset = the
119
+ // sheet's height, railHeight = the slice of it the rail
120
+ // may paint into), or { mode: "overlay" } for a
121
+ // right-edge drawer over a full-width stage. null (or
122
+ // any shape partforge can't read) restores partforge's
123
+ // own layout; resize and collapse are suspended while
124
+ // a layout is leased.
116
125
  runtime.setActive(false); // park the viewer: stop the render loop, release the
117
126
  // drawing buffer. setActive(true) restores both.
118
127
  runtime.attachTooltips([{ element: myButton }]); // host chrome buttons join the mount's
@@ -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.103.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);
@@ -454,7 +454,74 @@
454
454
  .pf-anim-bar { width: calc(100% - 24px); max-width: 520px; bottom: 64px; }
455
455
  }
456
456
 
457
-
457
+ /* ---- host-driven rail layouts (partforge-cloud) ---------------------------
458
+ data-pf-rail-layout is written by mobile-tabs.js from the host's
459
+ setRailLayout. Two modes, both host-only (nothing in partforge's own UI can
460
+ enter them):
461
+
462
+ DOCK — the host draws a bottom sheet over the iframe. --pf-rail-inset is the
463
+ sheet's full height: the shell pads by it so the stage (and its bottom
464
+ chrome) clears the sheet. With a "rail" lease the rail renders INTO the
465
+ sheet region — but only its bottom --pf-rail-dock-h px, because the host's
466
+ opaque chrome strip (handle + tabs) covers the top of the region. The spec
467
+ (2026-08-31-responsive-layout-redesign) has the two-numbers reasoning. Note
468
+ that a dock lease ABOVE 719px does not enter the block below at all: the
469
+ rail stays side by side with the stage at its usual --pf-rail-w (rail.js
470
+ keeps the width there, and only suspends resize/collapse/toggle).
471
+
472
+ OVERLAY — the rail is a right-edge drawer OVER the stage, open while
473
+ data-pf-rail-open is present (rail.js's toggle sets it; mobile-tabs.js's
474
+ stage pointerdown clears it). The stage never resizes. Width is its own
475
+ min(288px, 85%) — NOT --pf-rail-w, which rail.js zeroes under an overlay
476
+ lease at any window width (and under any layout below the breakpoint).
477
+
478
+ These come after the narrow block above because they override its one-pane
479
+ hiding, and before the reduced-motion block below so that block's
480
+ transition:none wins the tie at equal specificity. */
481
+ .pf-shell[data-pf-rail-layout="dock"] { padding-bottom: var(--pf-rail-inset, 0px); }
482
+ @media (max-width: 719px) {
483
+ /* pane=rail on a phone: stage above, rail docked in the sheet region. The
484
+ three rules below override the narrow block's one-pane hiding. */
485
+ .pf-shell[data-pf-rail-layout="dock"][data-pf-pane="rail"] { padding-bottom: 0; }
486
+ .pf-shell[data-pf-rail-layout="dock"][data-pf-pane="rail"] .pf-stage { display: block; }
487
+ .pf-shell[data-pf-rail-layout="dock"][data-pf-pane="rail"] .pf-rail {
488
+ display: flex;
489
+ flex: none;
490
+ height: var(--pf-rail-dock-h, 0px);
491
+ width: auto;
492
+ border-left: 0;
493
+ border-top: 1px solid var(--pf-border);
494
+ box-shadow: none;
495
+ }
496
+ }
497
+ .pf-shell[data-pf-rail-layout="dock"] .pf-rail-seam { display: none; }
498
+
499
+ /* visibility, not just the transform: collapse is suppressed under a host
500
+ layout (rail.js), so nothing else makes a shut drawer inert — it would sit
501
+ off-canvas as a run of invisible tab stops. The delay is what keeps it on
502
+ screen for the 200ms it takes to slide out; opening flips it back at once,
503
+ which is why the open state restates the transition without it. */
504
+ .pf-shell[data-pf-rail-layout="overlay"] .pf-stage { display: block; }
505
+ .pf-shell[data-pf-rail-layout="overlay"] .pf-rail {
506
+ display: flex;
507
+ position: absolute;
508
+ top: 0; right: 0; bottom: 0;
509
+ flex: none;
510
+ width: min(288px, 85%);
511
+ border-left: 1px solid var(--pf-border);
512
+ box-shadow: var(--pf-shadow-rail), -16px 0 32px rgb(0 0 0 / .35);
513
+ transform: translateX(102%);
514
+ visibility: hidden;
515
+ transition: transform 200ms ease, visibility 0s 200ms;
516
+ z-index: 30;
517
+ }
518
+ .pf-shell[data-pf-rail-layout="overlay"][data-pf-rail-open] .pf-rail {
519
+ transform: translateX(0);
520
+ visibility: visible;
521
+ transition: transform 200ms ease;
522
+ }
523
+ .pf-shell[data-pf-rail-layout="overlay"] .pf-rail-seam,
524
+ .pf-shell[data-pf-rail-layout="overlay"] .pf-tabbar { display: none; }
458
525
 
459
526
  /* ---- reduced motion -----------------------------------------------------
460
527
  Collapsing the rail slides 288px of layout across the screen — the first
@@ -465,6 +532,13 @@
465
532
  state indicator and is left alone. */
466
533
  @media (prefers-reduced-motion: reduce) {
467
534
  .pf-rail, .pf-rail-seam > span { transition: none; }
535
+ /* The overlay drawer states its own transition twice, at higher specificity
536
+ than the blanket rule above, so both states have to be named here or the
537
+ drawer would keep sliding. Killing the transition also drops the closing
538
+ visibility delay, which is right: with no slide there is nothing to wait
539
+ for, and the drawer leaves the a11y tree the moment it shuts. */
540
+ .pf-shell[data-pf-rail-layout="overlay"] .pf-rail,
541
+ .pf-shell[data-pf-rail-layout="overlay"][data-pf-rail-open] .pf-rail { transition: none; }
468
542
  }
469
543
 
470
544
  /* ---- annotation ink layer: a transparent 2D canvas over the viewer --------
@@ -4,7 +4,7 @@
4
4
  // this module writes onto .pf-shell; a missing attribute reads as "stage", so
5
5
  // the layout is already correct before any of this runs.
6
6
  //
7
- // Two independent reasons the bar can be absent, and they are deliberately
7
+ // Three independent reasons the bar can be absent, and they are deliberately
8
8
  // handled by different mechanisms:
9
9
  // * WIDTH — above the breakpoint the rail sits beside the viewer and no tab is
10
10
  // needed. That is pure CSS (.pf-tabbar { display: none }). There is no JS
@@ -12,6 +12,10 @@
12
12
  // * A HOST owns pane selection — partforge-cloud draws its own bottom bar at
13
13
  // the window level and drives this layout through setHostPane(). Then our
14
14
  // bar is `hidden` and only the host's choice writes data-pf-pane.
15
+ // * A HOST owns the rail's LAYOUT — setRailLayout() docks the rail into a
16
+ // bottom sheet or floats it as an overlay drawer, each with its own way in
17
+ // and out, so a pane tab would be a second, competing control. That lease
18
+ // is independent of the pane one: either alone hides the bar.
15
19
  //
16
20
  // State is in-memory: a fresh load starts on the stage, and nothing persists.
17
21
  // Which pane you are looking at right now is not a preference.
@@ -42,6 +46,26 @@ const ICONS = {
42
46
  const LABELS = { stage: "3D", rail: "Controls" };
43
47
  const PANES = ["stage", "rail"];
44
48
 
49
+ // A host-driven rail layout (partforge-cloud's bottom sheet / overlay drawer).
50
+ // null = partforge's own default layout. Normalization is strict: a shape this
51
+ // module does not recognize is a null, never a guess — the host is trusted to
52
+ // send well-formed layouts and a garbled one must not wedge the shell.
53
+ const MAX_RAIL_INSET = 4096;
54
+
55
+ function normalizeRailLayout(next) {
56
+ if (!next || typeof next !== "object") return null;
57
+ if (next.mode === "overlay") return { mode: "overlay" };
58
+ if (next.mode !== "dock") return null;
59
+ if (!Number.isInteger(next.inset) || next.inset < 0 || next.inset > MAX_RAIL_INSET) return null;
60
+ // A missing or unusable railHeight is a zero, not a rejection: the inset is
61
+ // what the stage lays out against, and a sheet with no visible collapsed
62
+ // height is a legitimate state.
63
+ const railHeight = Number.isInteger(next.railHeight)
64
+ ? Math.max(0, Math.min(next.inset, next.railHeight))
65
+ : 0;
66
+ return { mode: "dock", inset: next.inset, railHeight };
67
+ }
68
+
45
69
  function buildIcon(paths) {
46
70
  const svg = document.createElementNS(SVG_NS, "svg");
47
71
  svg.setAttribute("viewBox", "0 0 24 24");
@@ -69,15 +93,18 @@ function buildIcon(paths) {
69
93
  //
70
94
  // Everything is optional, like attachRail: with no shell to manage this returns
71
95
  // a no-op handle, so a legacy id-only page or a host that lays the framework out
72
- // itself (embed-test.html) is unaffected — including its setHostPane, which
73
- // stays a callable no-op so mount()'s handle shape never varies.
74
- export function attachMobileTabs({ shell, stage, rail } = {}) {
96
+ // itself (embed-test.html) is unaffected — including its setHostPane and
97
+ // setRailLayout, which stay callable no-ops so mount()'s handle shape never
98
+ // varies. `toggle` is rail.js's own rail toggle, passed only so the overlay duck
99
+ // below can recognize it; omitting it costs the drawer its close button.
100
+ export function attachMobileTabs({ shell, stage, rail, toggle, onRailLayout } = {}) {
75
101
  if (!shell || !stage || !rail) {
76
- return { setHostPane: () => {}, detach: () => {} };
102
+ return { setHostPane: () => {}, setRailLayout: () => {}, detach: () => {} };
77
103
  }
78
104
 
79
105
  let pane = "stage"; // the standalone user's choice
80
106
  let hostPane = null; // a host's lease over that choice, while non-null
107
+ let railLayout = null; // a host's lease over where the rail sits, while non-null
81
108
 
82
109
  const bar = document.createElement("div");
83
110
  bar.className = "pf-tabbar";
@@ -107,9 +134,23 @@ export function attachMobileTabs({ shell, stage, rail } = {}) {
107
134
  for (const [p, button] of buttons) {
108
135
  button.setAttribute("aria-pressed", String(p === active));
109
136
  }
110
- // A host that owns pane selection draws its own control; ours would be a
111
- // second, competing one.
112
- bar.hidden = hostPane !== null;
137
+ // chrome.css keys the docked/overlay rail off these; the two lengths are
138
+ // zero in overlay mode, where the drawer floats over a full-width stage.
139
+ if (railLayout) {
140
+ shell.dataset.pfRailLayout = railLayout.mode;
141
+ shell.style.setProperty("--pf-rail-inset", `${railLayout.mode === "dock" ? railLayout.inset : 0}px`);
142
+ shell.style.setProperty("--pf-rail-dock-h", `${railLayout.mode === "dock" ? railLayout.railHeight : 0}px`);
143
+ } else {
144
+ delete shell.dataset.pfRailLayout;
145
+ shell.style.removeProperty("--pf-rail-inset");
146
+ shell.style.removeProperty("--pf-rail-dock-h");
147
+ }
148
+ // The drawer-open flag only means anything in overlay mode; entering any
149
+ // other layout must not leave a stale open state behind.
150
+ if (railLayout?.mode !== "overlay") shell.removeAttribute("data-pf-rail-open");
151
+ // A host that owns EITHER pane selection or the layout draws its own
152
+ // controls; ours would compete.
153
+ bar.hidden = hostPane !== null || railLayout !== null;
113
154
  }
114
155
 
115
156
  // Delegated, so the icon <svg>/<span> inside a button resolve to the button.
@@ -120,6 +161,31 @@ export function attachMobileTabs({ shell, stage, rail } = {}) {
120
161
  apply();
121
162
  };
122
163
  bar.addEventListener("click", onClick);
164
+
165
+ // Overlay mode's "duck": any interaction with the stage slides the drawer
166
+ // back. Capture phase, because the canvas consumes pointer events.
167
+ //
168
+ // Two things the plain "remove the attribute" version got wrong, both because
169
+ // the rail toggle is a DESCENDANT of the stage (it floats at the stage's
170
+ // top-right):
171
+ // * its own tap arrives here first, so this would close the drawer a moment
172
+ // before rail.js's click handler reopened it — the toggle could open the
173
+ // drawer but never shut it. Hence the exemption, which needs the element
174
+ // itself: nothing in the DOM marks it out for a class/closest() test.
175
+ // * a real duck has to be REPORTED. The toggle's chevron, aria-expanded and
176
+ // label are derived from the open flag in rail.js's apply(), so a close it
177
+ // was never told about leaves the button announcing an open drawer. The
178
+ // callback is the same one setRailLayout uses (the host wires it to
179
+ // railChrome.layoutChanged), so this is a re-look, not a new channel.
180
+ const onStagePointerDown = (e) => {
181
+ if (toggle && (e.target === toggle || toggle.contains?.(e.target))) return;
182
+ if (shell.dataset.pfRailLayout !== "overlay") return;
183
+ // Nothing to duck: stay silent rather than nudge on every stage pointerdown.
184
+ if (!shell.hasAttribute("data-pf-rail-open")) return;
185
+ shell.removeAttribute("data-pf-rail-open");
186
+ onRailLayout?.(railLayout);
187
+ };
188
+ stage.addEventListener("pointerdown", onStagePointerDown, true);
123
189
  apply();
124
190
 
125
191
  return {
@@ -129,10 +195,24 @@ export function attachMobileTabs({ shell, stage, rail } = {}) {
129
195
  hostPane = PANES.includes(next) ? next : null;
130
196
  apply();
131
197
  },
198
+ // A dock/overlay layout takes the lease; anything this module cannot read —
199
+ // null included — releases it and restores partforge's own layout. The
200
+ // callback reports what was actually applied, so a host never has to guess
201
+ // whether its request survived normalization.
202
+ setRailLayout: (next) => {
203
+ railLayout = normalizeRailLayout(next);
204
+ apply();
205
+ onRailLayout?.(railLayout);
206
+ },
132
207
  detach: () => {
133
208
  bar.removeEventListener("click", onClick);
209
+ stage.removeEventListener("pointerdown", onStagePointerDown, true);
134
210
  bar.remove();
135
211
  delete shell.dataset.pfPane;
212
+ delete shell.dataset.pfRailLayout;
213
+ shell.removeAttribute("data-pf-rail-open");
214
+ shell.style.removeProperty("--pf-rail-inset");
215
+ shell.style.removeProperty("--pf-rail-dock-h");
136
216
  },
137
217
  };
138
218
  }
@@ -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";
@@ -61,7 +62,7 @@ const IMPORT_MESH_BROKEN_MESSAGE = "STEP import tessellation failed to satisfy t
61
62
  // carries the worker's own error text. See the correlated "error" case below.
62
63
  const importTessellateFailedMessage = (workerMessage) => `STEP import tessellation failed — ${workerMessage}`;
63
64
 
64
- export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, warmExportKernel, setHostPane, animation, getView, setView, captureView, attachTooltips, measure, annotate, projection, pickMarker }) {
65
+ export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, warmExportKernel, setHostPane, setRailLayout, animation, getView, setView, captureView, attachTooltips, measure, annotate, projection, pickMarker }) {
65
66
  return {
66
67
  ready, dispose, setParams,
67
68
  // Part-declared animation playback (spec 2026-08-02): animations are
@@ -121,6 +122,12 @@ export function makeHandle({ ready, dispose, viewer, setParams, listExportablePa
121
122
  // (partforge-cloud does, at the window level). Defaulted to a no-op so the
122
123
  // handle's shape never depends on whether this mount resolved a rail.
123
124
  setHostPane: setHostPane ?? (() => {}),
125
+ // Where the rail SITS, for a host that draws its own chrome around the
126
+ // frame: {mode:"dock", inset, railHeight} renders it into the host's bottom
127
+ // sheet, {mode:"overlay"} floats it as a right-edge drawer over the stage,
128
+ // and null hands the layout back to partforge. Same no-op default as
129
+ // setHostPane above, and the same lease semantics.
130
+ setRailLayout: setRailLayout ?? (() => {}),
124
131
  // Join host-owned chrome buttons to this mount's shared hover tooltip, so
125
132
  // a host's own viewbar/rail-foot buttons match the built-in ones. Entries
126
133
  // are [{ element, getLabel? }] (label falls back to the button's
@@ -210,6 +217,11 @@ function createCleanupStack() {
210
217
  // runtime.setHostPane("rail"); // narrow layout only: show just the controls
211
218
  // // rail ('stage' | 'rail'), suppressing the
212
219
  // // built-in tab bar. null hands selection back.
220
+ // runtime.setRailLayout({mode:"dock", inset, railHeight} | {mode:"overlay"} | null)
221
+ // // where the rail sits when the HOST draws chrome
222
+ // // over the frame: into its bottom sheet, or as a
223
+ // // right-edge drawer over the stage. null restores
224
+ // // partforge's own layout.
213
225
  // runtime.attachTooltips([{ element: myButton }]); // host chrome buttons join the
214
226
  // // mount's shared hover tooltip (the viewbar one).
215
227
  // // Label = the button's title (or aria-label), or a
@@ -378,6 +390,16 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
378
390
  shell: els.shell ?? els.rail?.parentElement,
379
391
  stage: els.viewer,
380
392
  rail: els.rail,
393
+ // The rail toggle floats INSIDE the stage, so the overlay duck has to know
394
+ // it by identity or the drawer's own close button would duck it shut and
395
+ // rail.js would reopen it on the same tap.
396
+ toggle: els.chrome.railToggle,
397
+ // rail.js reads the layout lazily in its handlers, but its toggle
398
+ // visibility (and, in overlay mode, its open/closed reporting) is written
399
+ // in apply() — tell it to re-look when the host changes the layout, or the
400
+ // duck closes the drawer. railChrome is created just above, so the closure
401
+ // is safe.
402
+ onRailLayout: () => railChrome.layoutChanged?.(),
381
403
  });
382
404
  cleanup.defer(() => paneTabs.detach());
383
405
  const hover = attachHoverLabels(viewer, { part, tooltip }); // always-on hover inspection (no-op on touch-only devices)
@@ -976,7 +998,11 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
976
998
  }, onParamsCommit
977
999
  ? (changed) => onParamsCommit({ changed, params: { ...params } })
978
1000
  : undefined,
979
- { fontCatalog, imageCatalog, onAssetUpload });
1001
+ // What the part is actually using for each asset key, so a control opens
1002
+ // showing the bundled default instead of an empty tile. Rebuilt per panel
1003
+ // build, because the declaration is a function of the current params.
1004
+ { fontCatalog, imageCatalog, onAssetUpload,
1005
+ declaredSource: declaredSourceLookup(part, params) });
980
1006
  cleanup.defer(() => panel.dispose());
981
1007
  panelRef = panel;
982
1008
  const updateRelevance = () => {
@@ -1149,6 +1175,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
1149
1175
  ready, dispose, viewer, setParams,
1150
1176
  attachTooltips: attachHostTooltips,
1151
1177
  setHostPane: paneTabs.setHostPane,
1178
+ setRailLayout: paneTabs.setRailLayout,
1152
1179
  getView: view, // () => tabsCtl.current()
1153
1180
  setView: (name) => tabsCtl.select(name),
1154
1181
  captureView,
@@ -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);