partforge 0.38.0 → 0.39.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
@@ -109,6 +109,9 @@ const runtime = mount(part, {
109
109
  onPick: ({ selection, label, prompt, token }) => {}, // programmatic click-to-select
110
110
  });
111
111
  await runtime.ready; // first successful build (rejects on a first-build error)
112
+ runtime.setHostPane("rail"); // narrow layout only: show just the controls
113
+ // rail ('stage' | 'rail'), suppressing the
114
+ // built-in tab bar. null hands selection back.
112
115
  runtime.dispose(); // stops loops, workers, observers, listeners; frees GPU resources
113
116
  ```
114
117
 
@@ -215,6 +215,12 @@ Variant literals under this entry: `extrude: unknown bevel option`, `extrude: be
215
215
  - **Cause:** Only pages listed in `build.rollupOptions.input` are compiled by the production build; other root `*.html` pages are dev-only conveniences Vite serves without building.
216
216
  - **Fix:** Add the page to `build.rollupOptions.input` in `vite.config.js` if it should ship. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Wiring a part into a runnable app".
217
217
 
218
+ ## cutaway-capture-hatch-flood
219
+
220
+ - **Symptom:** With cutaway enabled, a `captureCurrent`/`captureViews` image comes back with section hatch flooding a whole quad and burying the part, while the live viewer looks correct; a consumer may instead report the capture being rejected as too large, because full-frame hatch is worst-case JPEG content.
221
+ - **Cause:** Cutaway masks its section caps with the stencil buffer, and a `THREE.WebGLRenderTarget` has none unless it asks for one — so the mask no-ops in offscreen renders even though the visible canvas (created with `stencil: true`) is fine.
222
+ - **Fix:** Allocate offscreen render targets with `stencilBuffer: true` (`renderOffscreen` in `src/framework/viewer.js`); `scripts/check-app.mjs` measures hatch coverage in a real-GL capture to keep it that way.
223
+
218
224
  ## options-unknown-key
219
225
 
220
226
  - **Symptom:** `unknown option` — e.g. `cylinder: unknown option "radius" — did you mean r?`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.38.0",
3
+ "version": "0.39.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",
@@ -190,6 +190,12 @@ button.action:focus-visible, .adv-toggle:focus-visible, #viewbar button:focus-vi
190
190
  font-size: 15px; line-height: 1;
191
191
  display: flex; align-items: center; justify-content: center;
192
192
  }
193
+ /* the display: flex above is author-origin and beats the UA's [hidden] rule,
194
+ so any viewbar button hidden via the `hidden` property (today: #rail-toggle
195
+ below the narrow breakpoint, where the pane tab bar takes over) needs this
196
+ to actually disappear — the same trap .pf-cutaway-actions[hidden] below
197
+ already guards against. */
198
+ #viewbar button[hidden] { display: none; }
193
199
  #viewbar .pf-cutaway-actions { display: flex; gap: 4px; }
194
200
  #viewbar .pf-cutaway-actions[hidden] { display: none; }
195
201
  #viewbar .pf-cutaway-actions button { width: auto; min-width: 56px; padding: 0 8px; }
@@ -14,8 +14,8 @@
14
14
 
15
15
  Prerequisite: this sheet consumes --pf-* custom properties (--pf-rail-w,
16
16
  --pf-rail-pad, --pf-border, --pf-surface, --pf-bg, --pf-text, --pf-muted,
17
- --pf-shadow-rail) but does not import them — a standalone consumer must
18
- also load "partforge/tokens.css" (kept separate so the two stay
17
+ --pf-accent, --pf-shadow-rail) but does not import them — a standalone
18
+ consumer must also load "partforge/tokens.css" (kept separate so the two stay
19
19
  independently composable). The host is also responsible for giving
20
20
  .pf-shell a height (e.g. `height: 100%` on an ancestor chain rooted at
21
21
  `html, body`, or `position: absolute; inset: 0`); this sheet does not size
@@ -33,11 +33,21 @@
33
33
 
34
34
  /* ---- stage: the viewer column, which owns its floating chrome ------------
35
35
  min-width: 0 lets the column shrink past the canvas's intrinsic width, so
36
- dragging the rail wider actually narrows the viewer instead of overflowing. */
36
+ dragging the rail wider actually narrows the viewer instead of overflowing.
37
+
38
+ min-height: 0 is the same escape hatch for the OTHER axis, and it is what
39
+ the narrow layout needs: there the shell is a column, so the canvas's
40
+ height — a real pixel height the renderer wrote, not an intrinsic 150px —
41
+ becomes this flex item's automatic minimum size. Without it the stage
42
+ refuses to shrink, and the tab bar below it is pushed off the bottom of
43
+ the screen: still display:flex, still in the DOM, simply not on screen.
44
+ That failure survived a `display` assertion in the smoke check, which is
45
+ why that check now asserts the bar is inside the viewport instead. */
37
46
  .pf-stage {
38
47
  flex: 1;
39
48
  position: relative;
40
49
  min-width: 0;
50
+ min-height: 0;
41
51
  background: var(--pf-bg);
42
52
  }
43
53
 
@@ -151,23 +161,78 @@
151
161
  .pf-float-tabs { top: 12px; left: 50%; transform: translateX(-50%); }
152
162
  .pf-float-viewbar { bottom: 12px; right: 12px; }
153
163
 
154
- /* ---- stacked layout: no room for a rail beside the viewer ----------------
155
- The seam is hidden and resize is absent at this width (rail.js also refuses
156
- to start a drag); the #rail-toggle still collapses and restores. */
164
+ /* ---- the narrow-layout tab bar ------------------------------------------
165
+ Created by mobile-tabs.js (no host markup declares it). Hidden by default:
166
+ below the breakpoint the rail sits beside the viewer and needs no tab, so
167
+ the media query below is what reveals it.
168
+
169
+ The [hidden] rule is not redundant. `hidden` is how mobile-tabs.js stands
170
+ the bar down when a HOST owns pane selection, and the UA's
171
+ [hidden] { display: none } is a UA-origin rule that the media query's
172
+ author-origin display:flex would otherwise beat — leaving a second,
173
+ competing tab bar on screen inside the host's own. */
174
+ .pf-tabbar {
175
+ display: none;
176
+ flex: none;
177
+ border-top: 1px solid var(--pf-border);
178
+ background: var(--pf-surface);
179
+ color: var(--pf-text);
180
+ /* The home indicator on a notched phone. Zero everywhere else. */
181
+ padding-bottom: env(safe-area-inset-bottom, 0px);
182
+ }
183
+ .pf-tabbar[hidden] { display: none; }
184
+ .pf-tabbar button {
185
+ flex: 1;
186
+ display: flex;
187
+ flex-direction: column;
188
+ align-items: center;
189
+ justify-content: center;
190
+ gap: 2px;
191
+ padding: 6px 0;
192
+ border: 0;
193
+ background: transparent;
194
+ color: var(--pf-muted);
195
+ font: inherit;
196
+ font-size: 10px;
197
+ letter-spacing: 0.02em;
198
+ cursor: pointer;
199
+ }
200
+ .pf-tabbar button[aria-pressed="true"] { color: var(--pf-accent); }
201
+
202
+ /* ---- narrow layout: ONE pane at a time, chosen by a tab bar ---------------
203
+ The old layout here stacked the rail as a 45vh strip under the viewer, which
204
+ left both panes too cramped to use on a phone. Instead the shell becomes a
205
+ column of [pane][tab bar] and shows exactly one pane, full height, keyed on
206
+ data-pf-pane (written by mobile-tabs.js, or by a host through its
207
+ setHostPane).
208
+
209
+ Written as :not([data-pf-pane="rail"]) so a MISSING attribute falls to the
210
+ stage rather than showing both panes at once — the layout is correct from
211
+ first paint, before any JS runs.
212
+
213
+ The seam stays hidden and resize is absent at this width (rail.js also
214
+ refuses to start a drag). Collapse has no meaning here either: rail.js
215
+ suppresses it entirely below this breakpoint, so there is deliberately no
216
+ .pf-rail[inert] rule left — nothing sets inert at this width. */
157
217
  @media (max-width: 719px) {
158
218
  .pf-shell { flex-direction: column; }
219
+ .pf-shell:not([data-pf-pane="rail"]) .pf-rail { display: none; }
220
+ .pf-shell[data-pf-pane="rail"] .pf-stage { display: none; }
221
+ /* Whichever pane shows fills the column. --pf-rail-w, the left border, and
222
+ the inset shadow all existed to make the rail read as a set-back right
223
+ EDGE; none of that means anything when the rail IS the whole surface. */
159
224
  .pf-rail {
160
- width: auto; height: 45vh;
161
- border-left: 0; border-top: 1px solid var(--pf-border);
225
+ flex: 1;
226
+ width: auto;
227
+ border-left: 0;
162
228
  box-shadow: none;
163
229
  }
164
230
  .pf-rail-seam { display: none; }
165
- /* Collapsed, --pf-rail-w has no effect here (width is auto, height is 45vh),
166
- so hide the rail outright — otherwise the toggle's first tap leaves a
167
- full-size rail on screen that is inert and unresponsive. */
168
- .pf-rail[inert] { display: none; }
231
+ .pf-tabbar { display: flex; }
169
232
  }
170
233
 
234
+
235
+
171
236
  /* ---- reduced motion -----------------------------------------------------
172
237
  Collapsing the rail slides 288px of layout across the screen — the first
173
238
  layout-scale animation in the framework, and the kind of movement a
@@ -0,0 +1,138 @@
1
+ // The narrow-layout pane tab bar. Below RAIL_NARROW_BREAKPOINT there is no room
2
+ // for a rail beside the viewer, so the shell shows exactly ONE pane and this bar
3
+ // chooses which. chrome.css owns the layout, keyed on the data-pf-pane attribute
4
+ // this module writes onto .pf-shell; a missing attribute reads as "stage", so
5
+ // the layout is already correct before any of this runs.
6
+ //
7
+ // Two independent reasons the bar can be absent, and they are deliberately
8
+ // handled by different mechanisms:
9
+ // * WIDTH — above the breakpoint the rail sits beside the viewer and no tab is
10
+ // needed. That is pure CSS (.pf-tabbar { display: none }). There is no JS
11
+ // media-query bookkeeping in this file at all, and no resize listener.
12
+ // * A HOST owns pane selection — partforge-cloud draws its own bottom bar at
13
+ // the window level and drives this layout through setHostPane(). Then our
14
+ // bar is `hidden` and only the host's choice writes data-pf-pane.
15
+ //
16
+ // State is in-memory: a fresh load starts on the stage, and nothing persists.
17
+ // Which pane you are looking at right now is not a preference.
18
+ //
19
+ // The DOM is created here, so no host markup declares it — same contract as
20
+ // rail.js's seam, and the reason this composes with partforge-cloud's
21
+ // hand-built sandbox scaffold without that scaffold knowing anything about it.
22
+ const SVG_NS = "http://www.w3.org/2000/svg";
23
+
24
+ // Hand-authored in lucide's 24x24 / 2px-stroke idiom so these sit beside
25
+ // rail.js's toggle icon without introducing a second icon vocabulary (and
26
+ // without a dependency for three glyphs). `stroke="currentColor"` is
27
+ // load-bearing: it is how each icon picks up its button's themed colour, and
28
+ // the accent colour of the pressed state, with no icon-specific CSS anywhere.
29
+ const ICONS = {
30
+ // A box: the part itself.
31
+ stage: [
32
+ "M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z",
33
+ "m3.3 7 8.7 5 8.7-5",
34
+ "M12 22V12",
35
+ ],
36
+ // Three horizontal sliders: the parameter controls.
37
+ rail: [
38
+ "M21 4h-7", "M10 4H3", "M21 12h-9", "M8 12H3", "M21 20h-5", "M12 20H3",
39
+ "M14 2v4", "M8 10v4", "M16 18v4",
40
+ ],
41
+ };
42
+ const LABELS = { stage: "3D", rail: "Controls" };
43
+ const PANES = ["stage", "rail"];
44
+
45
+ function buildIcon(paths) {
46
+ const svg = document.createElementNS(SVG_NS, "svg");
47
+ svg.setAttribute("viewBox", "0 0 24 24");
48
+ svg.setAttribute("width", "20");
49
+ svg.setAttribute("height", "20");
50
+ svg.setAttribute("fill", "none");
51
+ svg.setAttribute("stroke", "currentColor");
52
+ svg.setAttribute("stroke-width", "2");
53
+ svg.setAttribute("stroke-linecap", "round");
54
+ svg.setAttribute("stroke-linejoin", "round");
55
+ // Decorative: every button carries a visible text label already.
56
+ svg.setAttribute("aria-hidden", "true");
57
+ for (const d of paths) {
58
+ const path = document.createElementNS(SVG_NS, "path");
59
+ path.setAttribute("d", d);
60
+ svg.append(path);
61
+ }
62
+ return svg;
63
+ }
64
+
65
+ // role="group" + aria-pressed, NOT role="tablist": a real tablist owes its tabs
66
+ // aria-controls'd tabpanels, and .pf-stage / .pf-rail are host-built DOM this
67
+ // module does not own and must not relabel. Two pressed-state buttons describe
68
+ // what this actually is without promising semantics we cannot honour.
69
+ //
70
+ // Everything is optional, like attachRail: with no shell to manage this returns
71
+ // 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 } = {}) {
75
+ if (!shell || !stage || !rail) {
76
+ return { setHostPane: () => {}, detach: () => {} };
77
+ }
78
+
79
+ let pane = "stage"; // the standalone user's choice
80
+ let hostPane = null; // a host's lease over that choice, while non-null
81
+
82
+ const bar = document.createElement("div");
83
+ bar.className = "pf-tabbar";
84
+ bar.setAttribute("role", "group");
85
+ bar.setAttribute("aria-label", "Viewer panes");
86
+ const buttons = new Map();
87
+ for (const p of PANES) {
88
+ const button = document.createElement("button");
89
+ button.type = "button";
90
+ button.dataset.pfPaneTab = p;
91
+ button.title = LABELS[p];
92
+ button.append(buildIcon(ICONS[p]));
93
+ const label = document.createElement("span");
94
+ label.textContent = LABELS[p];
95
+ button.append(label);
96
+ bar.append(button);
97
+ buttons.set(p, button);
98
+ }
99
+ // Last child of the shell: in the narrow layout .pf-shell is a column, so the
100
+ // bar lands under whichever pane is showing. Above the breakpoint it is
101
+ // display:none and its position in the flex row is moot.
102
+ shell.append(bar);
103
+
104
+ function apply() {
105
+ const active = hostPane ?? pane;
106
+ shell.dataset.pfPane = active;
107
+ for (const [p, button] of buttons) {
108
+ button.setAttribute("aria-pressed", String(p === active));
109
+ }
110
+ // A host that owns pane selection draws its own control; ours would be a
111
+ // second, competing one.
112
+ bar.hidden = hostPane !== null;
113
+ }
114
+
115
+ // Delegated, so the icon <svg>/<span> inside a button resolve to the button.
116
+ const onClick = (e) => {
117
+ const next = e.target?.closest?.("[data-pf-pane-tab]")?.dataset?.pfPaneTab;
118
+ if (!PANES.includes(next)) return;
119
+ pane = next;
120
+ apply();
121
+ };
122
+ bar.addEventListener("click", onClick);
123
+ apply();
124
+
125
+ return {
126
+ // 'stage' | 'rail' takes the lease; anything else (including null) releases
127
+ // it and restores the standalone bar and the user's own last choice.
128
+ setHostPane: (next) => {
129
+ hostPane = PANES.includes(next) ? next : null;
130
+ apply();
131
+ },
132
+ detach: () => {
133
+ bar.removeEventListener("click", onClick);
134
+ bar.remove();
135
+ delete shell.dataset.pfPane;
136
+ },
137
+ };
138
+ }
@@ -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 { attachMobileTabs } from "./mobile-tabs.js";
7
8
  import { createTooltipPresenter } from "./tooltip.js";
8
9
  import { loadCamera } from "./view-state.js";
9
10
  import { buildControls } from "./controls.js";
@@ -25,13 +26,17 @@ import { createExportController } from "./export-controller.js";
25
26
 
26
27
  // The mount handle, factored out so its shape is unit-testable without booting
27
28
  // the full mount() pipeline (WASM + workers + DOM).
28
- export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts }) {
29
+ export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, setHostPane }) {
29
30
  return {
30
31
  ready, dispose, setParams,
31
32
  captureViews: (viewNames) => viewer.captureCanonicalViews(viewNames),
32
33
  captureCurrent: (opts) => viewer.captureCurrent(opts),
33
34
  listExportableParts,
34
35
  exportParts,
36
+ // Narrow-layout pane selection, for a host that draws its own tab bar
37
+ // (partforge-cloud does, at the window level). Defaulted to a no-op so the
38
+ // handle's shape never depends on whether this mount resolved a rail.
39
+ setHostPane: setHostPane ?? (() => {}),
35
40
  };
36
41
  }
37
42
 
@@ -64,7 +69,7 @@ function createCleanupStack() {
64
69
  // mesh-validity cache, and the geometry workers. The app supplies `createWorker(name)`
65
70
  // so Vite can bundle the worker (see geometry-service.js).
66
71
  //
67
- // Embedding contract (0.38.0):
72
+ // Embedding contract (0.39.0):
68
73
  // const runtime = mount(part, { createWorker, elements, onBuild, onPick, onDownload });
69
74
  // await runtime.ready; // first successful build of the default view
70
75
  // runtime.setParams({ openAngle: 45 }); // programmatic edit; pose-only changes apply instantly
@@ -78,6 +83,9 @@ function createCleanupStack() {
78
83
  // // headless export of a chosen subset; resolves when the file is
79
84
  // // written (handed to your onDownload sink, or downloaded directly
80
85
  // // if you don't supply one), rejects on failure
86
+ // runtime.setHostPane("rail"); // narrow layout only: show just the controls
87
+ // // rail ('stage' | 'rail'), suppressing the
88
+ // // built-in tab bar. null hands selection back.
81
89
  // runtime.dispose(); // full teardown
82
90
  // onBuild fires per completed build, so it does NOT fire for a pose-only edit —
83
91
  // those are repaired in the viewer and produce no build at all.
@@ -133,6 +141,16 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
133
141
  // framework itself (no #panel / no elements.rail).
134
142
  const railChrome = attachRail({ rail: els.rail, toggle: els.chrome.railToggle, shell: els.shell });
135
143
  cleanup.defer(() => railChrome.detach());
144
+ // Narrow-layout pane tabs. Below RAIL_NARROW_BREAKPOINT the rail cannot sit
145
+ // beside the viewer, so exactly one pane shows and this bar picks it. Same
146
+ // resolution and same no-op-when-absent contract as the rail above — the
147
+ // shell default mirrors attachRail's (rail.parentElement).
148
+ const paneTabs = attachMobileTabs({
149
+ shell: els.shell ?? els.rail?.parentElement,
150
+ stage: els.viewer,
151
+ rail: els.rail,
152
+ });
153
+ cleanup.defer(() => paneTabs.detach());
136
154
  const hover = attachHoverLabels(viewer, { part, tooltip }); // always-on hover inspection (no-op on touch-only devices)
137
155
  cleanup.defer(() => hover.detach());
138
156
  const ui = createStatusUi({ ...els.status, exports: [els.exports.stl, els.exports.step, els.exports.threeMf] });
@@ -448,6 +466,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
448
466
 
449
467
  return makeHandle({
450
468
  ready, dispose, viewer, setParams,
469
+ setHostPane: paneTabs.setHostPane,
451
470
  listExportableParts: () =>
452
471
  exportablePartNames(part, params).map((name) => ({ name, label: partLabel(part, name) })),
453
472
  exportParts: (opts) => exportCtl.exportParts(opts),
@@ -96,7 +96,9 @@ export function attachRail({ rail, toggle, shell = rail?.parentElement, storage
96
96
  // hand back a plain, unwired button rather than a dead "Show controls" one.
97
97
  // innerHTML (not textContent) so a host's original content - markup, not
98
98
  // just text - genuinely round-trips; the icon apply() writes is markup too.
99
- const toggleOriginal = toggle ? { html: toggle.innerHTML, title: toggle.title } : null;
99
+ const toggleOriginal = toggle
100
+ ? { html: toggle.innerHTML, title: toggle.title, hidden: toggle.hidden }
101
+ : null;
100
102
  // Built once here rather than inside apply() (which reruns on every resize/
101
103
  // key/drag tick); apply() only ever swaps toggleChevron's `d`.
102
104
  let toggleChevron = null;
@@ -123,7 +125,17 @@ export function attachRail({ rail, toggle, shell = rail?.parentElement, storage
123
125
  // flush, not a cached value.
124
126
  function apply({ persist = false, shellW } = {}) {
125
127
  const sw = shellW ?? shellWidth();
126
- const width = state.collapsed ? 0 : clampRailWidth(state.width, sw);
128
+ // Below the breakpoint the shell shows exactly ONE pane (chrome.css, keyed
129
+ // on data-pf-pane) and mobile-tabs.js picks it, so collapse has no meaning:
130
+ // a persisted `collapsed: true` must not make the Controls tab land on an
131
+ // inert, invisible rail. `state` is deliberately NOT rewritten — the stored
132
+ // preference applies again the moment the window widens.
133
+ const narrow = window.innerWidth < RAIL_NARROW_BREAKPOINT;
134
+ const collapsed = state.collapsed && !narrow;
135
+ // Narrow: the rail is either the whole surface or absent, so it reserves no
136
+ // width beside the viewer — and anything centring itself against the rail
137
+ // (app.css's #pf-pick-banner) must not be offset by a stale 288px.
138
+ const width = collapsed || narrow ? 0 : clampRailWidth(state.width, sw);
127
139
  // Written on :root, not the rail/shell, so body-appended overlays (the
128
140
  // pick banner, the ?debug overlay) inherit it — see spec §4.4. This
129
141
  // assumes ONE rail per document: attachRail is written for a single
@@ -133,17 +145,20 @@ export function attachRail({ rail, toggle, shell = rail?.parentElement, storage
133
145
  // multiple mount() calls in general (e.g. cross-fade swaps), which is
134
146
  // fine as long as at most one has a resolvable rail at a time.
135
147
  root.style.setProperty("--pf-rail-w", `${width}px`);
136
- rail.toggleAttribute("inert", state.collapsed);
137
- seam.toggleAttribute("data-collapsed", state.collapsed);
148
+ rail.toggleAttribute("inert", collapsed);
149
+ seam.toggleAttribute("data-collapsed", collapsed);
138
150
  seam.setAttribute("aria-valuenow", String(width));
139
151
  seam.setAttribute("aria-valuemax", String(railMaxWidth(sw)));
140
152
  if (toggle) {
141
- toggleChevron?.setAttribute("d", state.collapsed ? CHEVRON_RAIL_COLLAPSED : CHEVRON_RAIL_OPEN);
142
- const label = state.collapsed ? "Show controls" : "Hide controls";
143
- toggle.setAttribute("aria-expanded", String(!state.collapsed));
153
+ // The tab bar owns pane selection below the breakpoint, so a second
154
+ // collapse affordance in the viewbar would be a competing control.
155
+ toggle.hidden = narrow;
156
+ toggleChevron?.setAttribute("d", collapsed ? CHEVRON_RAIL_COLLAPSED : CHEVRON_RAIL_OPEN);
157
+ const label = collapsed ? "Show controls" : "Hide controls";
158
+ toggle.setAttribute("aria-expanded", String(!collapsed));
144
159
  toggle.setAttribute("aria-label", label);
145
160
  toggle.title = label;
146
- toggle.classList.toggle("on", state.collapsed);
161
+ toggle.classList.toggle("on", collapsed);
147
162
  }
148
163
  if (persist) writeRailPref(state, storage);
149
164
  }
@@ -312,6 +327,7 @@ export function attachRail({ rail, toggle, shell = rail?.parentElement, storage
312
327
  if (toggle) {
313
328
  toggle.innerHTML = toggleOriginal.html;
314
329
  toggle.title = toggleOriginal.title;
330
+ toggle.hidden = toggleOriginal.hidden;
315
331
  toggle.removeAttribute("aria-expanded");
316
332
  toggle.removeAttribute("aria-label");
317
333
  toggle.classList.remove("on");
@@ -379,12 +379,19 @@ export function createViewer(container, part) {
379
379
  // fov 45, quality 0.9). A custom size (captureCurrent) gets a fresh render
380
380
  // target, disposed after the read — those captures are rare, so per-call
381
381
  // allocation beats caching one target per size ever requested.
382
+ //
383
+ // stencilBuffer is NOT optional: cutaway masks its section caps with the
384
+ // stencil buffer, and a WebGLRenderTarget defaults to not having one (the
385
+ // visible canvas does, via the `stencil: true` renderer above). Without it
386
+ // the mask silently no-ops and every cap floods its whole plane with hatch —
387
+ // no error, live view unaffected, wrong only in the capture.
388
+ const RT_OPTIONS = { samples: 4, stencilBuffer: true };
382
389
  function renderOffscreen({ position, up, target },
383
390
  { width = _rtSize, height = _rtSize, fov = 45, quality = 0.9 } = {}) {
384
391
  const cachedSize = width === _rtSize && height === _rtSize;
385
392
  const rt = cachedSize
386
- ? (_rt = _rt ?? new THREE.WebGLRenderTarget(_rtSize, _rtSize, { samples: 4 }))
387
- : new THREE.WebGLRenderTarget(width, height, { samples: 4 });
393
+ ? (_rt = _rt ?? new THREE.WebGLRenderTarget(_rtSize, _rtSize, RT_OPTIONS))
394
+ : new THREE.WebGLRenderTarget(width, height, RT_OPTIONS);
388
395
  _capLights = _capLights ?? createCaptureLights();
389
396
  const cam = new THREE.PerspectiveCamera(fov, width / height, 0.1, 1000);
390
397
  cam.position.set(position[0], position[1], position[2]);