partforge 0.123.0 → 0.125.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
@@ -145,8 +145,9 @@ Restore is best-effort per field, so a state that no longer fits is dropped
145
145
  rather than fatal. The cut plane's world pose is restored exactly; its
146
146
  on-screen size is re-derived from the new geometry, since that is a property of
147
147
  the part rather than of the user's choice. Omit `viewerState` on a first mount
148
- — the viewer then restores its own persisted camera and projection, as it
149
- always has.
148
+ — the viewer then restores its own persisted camera, as it always has, in
149
+ perspective (the projection is automatic — orthographic only on a view cube
150
+ face view — so it is not persisted).
150
151
 
151
152
  **Park the viewer when you hide it.** A host that hides the canvas with
152
153
  `display: none` needs nothing — the container collapses and the ResizeObserver
@@ -309,8 +309,14 @@ parts: {
309
309
  meta: { title: "…", units: "mm", environment: "studio" },
310
310
  ```
311
311
 
312
- - `material` — a preset id from the table below. Without one the sub-part keeps
313
- the default look.
312
+ - `material` — a preset id from the table below. Without one (or with one the
313
+ library does not know) the sub-part keeps the CAD view's look — its `color`,
314
+ else the viewer's blue-grey — and **realistic mode shows it as a PLA print**
315
+ (`pla-print`'s finish and layer lines) in that same colour. So an untouched
316
+ part looks printed in realistic mode; name a material when it is made some
317
+ other way. The PLA look is realistic-only: the CAD view, 3MF colours and
318
+ `declaresMaterials` are unaffected, and a part that names no material is not
319
+ treated as declaring one.
314
320
  - `color` — the base colour (`0xRRGGBB`). With a preset it is the TINT. Presets
315
321
  marked tintable are normally coloured this way (anodizing, plastic, paint);
316
322
  the others have an intrinsic colour (brass) but still accept one.
@@ -2589,7 +2595,7 @@ stylesheet). `mount` looks up these element IDs:
2589
2595
  | `#part` | view-tab bar — leave the div **empty**; `mount` generates one button per entry in `part.views` and opens the resolved default (see the "Which view the viewer opens on" rule above) |
2590
2596
  | `#download-step` / `#download` / `#download-3mf` | STEP / STL / 3MF export buttons |
2591
2597
  | `#status`, `#busy`, `#phase` | status line + busy overlay |
2592
- | `#viewbar` with `#annotate` / `#measure` / `#cutaway` / `#reframe` / `#realistic` / `#environment` / `#theme` | optional viewer controls (omit any you don't want) |
2598
+ | `#viewbar` with `#annotate` / `#measure` / `#cutaway` / `#reframe` / `#theme` | optional viewer controls (omit any you don't want) |
2593
2599
  | `#panel` | the full-height controls rail (`class="pf-rail"`); programmatic hosts pass `elements.rail` instead |
2594
2600
  | `#rail-toggle` | optional — collapses/restores the rail; resolved the same way as `#reframe`/`#theme`. A sibling of `#viewbar`, not a child of it: give it `class="pf-float-rail-toggle"` and it floats at the stage's top right |
2595
2601
 
@@ -2597,16 +2603,27 @@ Copy `demo.html` and change the title, the panel heading, and the `<script src>`
2597
2603
  workers are spawned from your one worker entry (`name` = `"manifold"` for preview/STL/3MF,
2598
2604
  `"occt"` for STEP — handled for you).
2599
2605
 
2600
- **`#realistic` (a `<button>`) / `#environment` (an empty `<select>`) are the
2601
- realistic-mode viewbar controls**, resolved by id or as `elements.chrome.realistic`
2602
- / `elements.chrome.environment`. Both optional and independent: supply `#realistic`
2603
- alone for a bare toggle, or add `#environment` too and `mount` fills it with the
2604
- part's available environments (see "Materials and appearance" above) and keeps it
2605
- in sync — hidden while the view is in CAD mode, showing the environment in effect
2606
- once realistic lands. Omit either and drive `runtime.renderMode` /
2607
- `runtime.environment` from your own UI instead (see below). Both preferences
2608
- persist across reloads the same way the theme does, and are carried in
2609
- `viewerState` (below), which outranks what is stored.
2606
+ **The view style button needs no markup.** `mount` generates it (`#view-style`,
2607
+ an eye icon) into the stage's `#viewbar`, just before `#theme` behind a thin
2608
+ divider — the bar's "appearance" group — so a page that copies `demo.html` gets
2609
+ it in the bottom toolbar and it hides with the bar (Sketch mode). A stage with
2610
+ no `#viewbar` gets it over the view cube's bottom-right corner instead, where it
2611
+ hides whenever the cube does (Sketch mode, a crowded animation transport bar).
2612
+ Either way it replaces the old projection toggle. It opens a popover holding every control that
2613
+ changes *how* the part is drawn: the **style** — CAD or one of the realistic
2614
+ environments (see "Materials and appearance" above), each shown as a live
2615
+ thumbnail of the part, re-rendered on the next open after the part or theme
2616
+ changes. There is no projection control: the projection is **automatic** (see
2617
+ `runtime.projection` below). Feature
2618
+ lines are CAD-only and not a switch: they draw whenever the style is CAD and
2619
+ never in a realistic style. The old `#realistic` / `#environment` viewbar
2620
+ controls (`elements.chrome.realistic` / `.environment`) and the cube's own
2621
+ `#projection` button were retired with it (2026-09-24); a page that still
2622
+ carries `#realistic` / `#environment` markup just shows dead elements, so
2623
+ delete them. A host can still drive `runtime.renderMode` /
2624
+ `runtime.environment` / `runtime.projection` from its own UI (see below). The
2625
+ style preferences persist across reloads the same way the theme does, and are
2626
+ carried in `viewerState` (below), which outranks what is stored.
2610
2627
 
2611
2628
  **`#reframe` is supported but no longer shipped.** The framework's own pages dropped
2612
2629
  the button on 2026-08-20: clicking a face, edge or corner on the view cube reframes
@@ -2729,7 +2746,10 @@ pane's pixel size:
2729
2746
  target are restored after the render. Measurement-mode dimensions render directly
2730
2747
  in the scene, so a dimensioned capture needs no special handling — enable measure
2731
2748
  mode (`runtime.measure.setEnabled(true)`) and call `captureCurrent()`; the dims are
2732
- just part of the rendered frame.
2749
+ just part of the rendered frame. With the cutaway on, the capture shows the section
2750
+ the way the user sees it — the part clipped, its cut faces hatched — but never the
2751
+ cutaway's translucent plane or its handles, which are controls, not the part (the
2752
+ same holds for `captureViews` and the view style popover's thumbnails).
2733
2753
  `recenter: true` centres the part: the capture becomes the largest centred
2734
2754
  sub-window of the current framing that still holds every visible vertex (equal
2735
2755
  margins on both axes, rendered at the full `size` resolution through a view
@@ -2747,12 +2767,25 @@ pane's pixel size:
2747
2767
  ### `runtime.projection`
2748
2768
 
2749
2769
  `{ get(), set(mode), onChange(cb) }` where `mode` is `"perspective"` or
2750
- `"orthographic"`. Drives the **live view** and `captureCurrent` only —
2770
+ `"orthographic"`. The projection is **automatic**, the way Fusion 360's
2771
+ "Perspective with Ortho Faces" and Blender's "Auto Perspective" work: clicking
2772
+ one of the view cube's six **face** views (on the cube, or through its hidden
2773
+ per-face keyboard buttons) tweens there and settles into orthographic at the
2774
+ end of the tween, with no size jump; an edge, corner or iso view is perspective
2775
+ (an orthographic view switches back as that tween starts). In a face view,
2776
+ **pan and zoom keep it orthographic; the first rotation** (the view direction
2777
+ leaving the face axis by more than half a degree) swaps back to perspective,
2778
+ keeping the part's apparent size. Animation camera cues never switch into
2779
+ orthographic. There is no user control for it. `set("orthographic")` still
2780
+ works for a host, and is left the same way — by the first rotation — and
2781
+ `onChange` hears every automatic swap. It is **not persisted** across reloads
2782
+ (a reload opens in perspective); a remount carries it in `viewerState`, but
2783
+ only with a face-view camera — carried with any other camera it restores
2784
+ perspective. Drives the **live view** and `captureCurrent` only —
2751
2785
  `captureCanonicalViews`, `renderMeshPayloads`, and the CLI's `partforge render`
2752
- stay perspective unconditionally, so agent-facing output does not depend on a UI
2753
- toggle. The choice persists across reloads under `partforge:projection` and is
2754
- restored before the first framing. The orientation cube and its projection
2755
- button are hidden while Sketch (annotate) mode is active, but that only governs
2786
+ stay perspective unconditionally, so agent-facing output does not depend on the
2787
+ live view. The orientation cube and the view style
2788
+ button (in `#viewbar`, which Sketch hides) are hidden while Sketch (annotate) mode is active, but that only governs
2756
2789
  *user-driven* view changes — the framework does not police programmatic ones.
2757
2790
  The ink is a transparent overlay and the WebGL canvas keeps rendering beneath
2758
2791
  it, so a host that calls `runtime.projection.set()` mid-sketch **visibly
@@ -2765,7 +2798,7 @@ Deliberately unguarded, the same way it's always been free to call
2765
2798
  ### `runtime.renderMode`, `runtime.environment`, `runtime.renderViews`, `runtime.declaresMaterials`
2766
2799
 
2767
2800
  For an embedder driving realistic mode from its own UI instead of (or in
2768
- addition to) the `#realistic` / `#environment` viewbar controls above:
2801
+ addition to) the generated view style button above:
2769
2802
 
2770
2803
  - `runtime.renderMode` — `{ get(), set(mode), onChange(cb) }` where `mode` is
2771
2804
  `"cad"` or `"realistic"`. `set()` resolves to the mode actually in effect —
@@ -2791,8 +2824,11 @@ addition to) the `#realistic` / `#environment` viewbar controls above:
2791
2824
  revert too.
2792
2825
  - `runtime.declaresMaterials` — `true` when any sub-part names a
2793
2826
  `display.material`. A part with none still supports realistic mode (every
2794
- sub-part just renders under the library's `default` look), so use this to
2795
- decide whether to surface the realistic toggle at all, not whether it works.
2827
+ sub-part renders as a PLA print in its CAD colour), so use this to decide
2828
+ whether to surface your own realistic control at all, not whether it works.
2829
+ Feature lines are not a preference: they draw whenever `runtime.renderMode`
2830
+ reads `"cad"` and never while it reads `"realistic"` — there is no switch
2831
+ to drive independently of it.
2796
2832
  - `await runtime.renderViews(viewNames, { renderMode? })` — the appearance-aware
2797
2833
  sibling of `runtime.captureViews` (canonical angles, framed to the visible
2798
2834
  assembly, grid hidden): `{ renderMode: "cad" }` (the default) is exactly
@@ -2800,19 +2836,18 @@ addition to) the `#realistic` / `#environment` viewbar controls above:
2800
2836
  for the capture — waiting on the chosen environment's assets — **without**
2801
2837
  switching the live view. Rejects if the realistic assets fail to load.
2802
2838
 
2803
- Both preferences round-trip through `mount()`'s `viewerState`: a
2804
- previous mount's `runtime.getViewerState()` carries `viewerState.renderMode`
2805
- (`"cad"` or `"realistic"`) and, only when the viewer's environment was
2806
- actually CHOSEN rather than merely defaulted from `meta.environment`,
2807
- `viewerState.environment`. `renderMode` reports the mode the user is **headed
2808
- for**, not only the one on screen: a realistic restore or switch that's still
2809
- loading reports `"realistic"`, so a host that remounts on every edit (as an
2810
- embedder applying edits by remounting typically does) doesn't drop the
2811
- in-flight choice — a load that ultimately fails still settles back to
2812
- `"cad"`. Pass `viewerState` back into the next `mount()` call to resume both
2813
- where the previous mount left them; omit it on a first mount and the viewer
2814
- restores its own persisted choice instead, the same way it does for the
2815
- camera and projection.
2839
+ Both preferences round-trip through `mount()`'s `viewerState`: a previous
2840
+ mount's `runtime.getViewerState()` carries `viewerState.renderMode` (`"cad"`
2841
+ or `"realistic"`) and, only when the viewer's environment was actually CHOSEN
2842
+ rather than merely defaulted from `meta.environment`, `viewerState.environment`.
2843
+ `renderMode` reports the mode the user is **headed for**, not only the one on
2844
+ screen: a realistic restore or switch that's still loading reports
2845
+ `"realistic"`, so a host that remounts on every edit (as an embedder applying
2846
+ edits by remounting typically does) doesn't drop the in-flight choice — a
2847
+ load that ultimately fails still settles back to `"cad"`. Pass `viewerState`
2848
+ back into the next `mount()` call to resume both where the previous mount
2849
+ left them; omit it on a first mount and the viewer restores its own persisted
2850
+ choice instead, the same way it does for the camera.
2816
2851
 
2817
2852
  ### The annotation payload's camera block
2818
2853
 
@@ -3276,7 +3311,8 @@ nothing either way and stays silent, matching `animation-track-rebuilds`'s own
3276
3311
  trust handling.
3277
3312
 
3278
3313
  **Appearance** (all warnings) — `unknown-material` (a `display.material` the
3279
- library does not know; the viewer shows the default look), `unknown-environment`
3314
+ library does not know; the viewer draws it as if it named none — a PLA print
3315
+ in realistic mode), `unknown-environment`
3280
3316
  (`meta.environment` not known; realistic mode uses `studio`),
3281
3317
  `material-key-unknown` (a `display` key that is not colour, opacity, material
3282
3318
  or one of the six overrides; ignored), `material-override-clamped` (an override
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.123.0",
3
+ "version": "0.125.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",
@@ -515,8 +515,13 @@ export function createAnnotateMode(viewer, { stage, getContext, onSend, createCa
515
515
  }
516
516
 
517
517
  function cameraBlock(inv) {
518
- const { pos, target } = viewer.getCameraState();
518
+ // The target from getCameraState, the position from the LIVE camera: under
519
+ // ortho getCameraState reports a perspective-equivalent position (for
520
+ // restoring a framing), while this block describes the camera that
521
+ // actually drew the frame.
522
+ const { target } = viewer.getCameraState();
519
523
  const cam = viewer.camera;
524
+ const pos = cam.position.toArray();
520
525
  const ortho = !!cam.isOrthographicCamera;
521
526
  // 4 decimals ≈ 0.1 µm at mm scale — far below anything a sketch encodes,
522
527
  // and it keeps float dust (1e-16 up-vector components) out of what an LLM
@@ -476,8 +476,8 @@ button.action:disabled { opacity: .5; cursor: default; }
476
476
  /* keyboard focus ring shared across the panel's interactive controls */
477
477
  .seg button:focus-visible, select.preset:focus-visible, .dl-row button:focus-visible,
478
478
  button.action:focus-visible, .adv-toggle:focus-visible, .sec-title:focus-visible, #viewbar button:focus-visible,
479
- #viewbar select:focus-visible, .pf-view-select:focus-visible,
480
- .pf-viewcube-toggle:focus-visible, .pf-float-rail-toggle:focus-visible, .pf-sketch-toolbar button:focus-visible {
479
+ .pf-view-select:focus-visible,
480
+ .pf-float-rail-toggle:focus-visible, .pf-sketch-toolbar button:focus-visible {
481
481
  outline: none;
482
482
  box-shadow: 0 0 0 3px color-mix(in oklab, var(--pf-accent) 35%, transparent);
483
483
  }
@@ -595,21 +595,6 @@ button.action:focus-visible, .adv-toggle:focus-visible, .sec-title:focus-visible
595
595
  #viewbar button:disabled:hover { color: var(--pf-muted-2); background: transparent; }
596
596
  #viewbar button:hover { color: var(--pf-text); background: var(--pf-surface-2); }
597
597
  #viewbar button.on { background: var(--pf-accent); color: var(--pf-on-accent); }
598
- /* Realistic view (realistic-controls.js). The toggle is an ordinary viewbar
599
- button that pulses while its environment loads; the environment picker is a
600
- native <select> — the accessible choice on a phone — dressed as a viewbar
601
- button, shown only while realistic. Keyed on `#viewbar select` rather than
602
- an id so a host's own element in the pill gets the same look. */
603
- #viewbar button[data-busy] { animation: pf-busy-pulse .8s ease-in-out infinite alternate; }
604
- @keyframes pf-busy-pulse { from { opacity: 1; } to { opacity: .45; } }
605
- @media (prefers-reduced-motion: reduce) { #viewbar button[data-busy] { animation: none; opacity: .6; } }
606
- #viewbar select {
607
- height: 34px; padding: 0 8px; border: 0; border-radius: var(--pf-radius-control);
608
- background: transparent; color: var(--pf-muted-2); cursor: pointer;
609
- font: inherit; font-size: 13px;
610
- }
611
- #viewbar select:hover { color: var(--pf-text); background: var(--pf-surface-2); }
612
- #viewbar select[hidden] { display: none; }
613
598
 
614
599
  /* Sketch-mode toolbar: the #viewbar pill idiom, top-centre, owning the mode
615
600
  while the viewbar itself is hidden (mount toggles both).
@@ -733,57 +718,84 @@ button.action:focus-visible, .adv-toggle:focus-visible, .sec-title:focus-visible
733
718
  APPEARANCE only (placement lives in chrome.css, per the split at the top of
734
719
  this file).
735
720
 
721
+ The VIEW STYLE button replaced the cube's projection toggle
722
+ (view-style-controls.js, 2026-09-24); it now lives in #viewbar, and only a
723
+ host without one gets it over the cube's corner (its appearance is in the
724
+ next section, its placement in chrome.css). The toggle's history there ran:
725
+
736
726
  Through 2026-08-19 the projection toggle sat in its own `.pf-viewcube-pill`
737
727
  card below the cube, deliberately borrowing #viewbar's chrome so it read as
738
728
  one of the viewer controls. The 2026-08-20 revision moved it beside the
739
729
  cube instead, at a size and a bottom-aligned baseline that a #viewbar-style
740
- card could not shrink to — so it is now a small bare circle in its own
741
- right (`.pf-viewcube-toggle`), no card, no border, no shadow.
742
-
743
- A same-day follow-up moved it again, from beside the cube to OVER its
744
- bottom-right corner (placement in chrome.css, as ever). Nothing here
745
- changed: a bare transparent circle was already what made that legal — it
746
- has no card edge to cut across the drawing underneath it, so overlapping
747
- costs the cube nothing but the 24px it covers.
748
-
749
- A later revision that same day took the resting fill away entirely: now
750
- that the button sits ON the cube's drawing, a persistent accent-tinted
751
- disc in orthographic mode read as a blob over the geometry rather than as
752
- a control. A background appears on HOVER only, in both states.
753
-
754
- The last revision of the day took the accent COLOUR away too, so the
755
- orthographic state now carries exactly ONE visual signal: the icon GLYPH (a
756
- perspective frustum becomes an axonometric box). The button is grey in both
757
- projections, resting and hovered, and there is deliberately no `.on` rule
758
- below — viewcube-controls.js still adds the class, and `aria-pressed` still
759
- carries the state for assistive tech. That is a choice, not an oversight: a
760
- blue icon sitting on the cube's own drawing read as a stray tint rather than
761
- as a mode, and the glyph is the tell that survives any colour scheme anyway.
762
- Do not reintroduce a tint without asking. */
730
+ card could not shrink to — so it became a small bare 24px circle in its own
731
+ right (`.pf-viewcube-toggle`), no card, no border, no shadow. A same-day
732
+ follow-up moved it OVER the cube's bottom-right corner, took its resting
733
+ fill away (a tinted disc on the drawing read as a blob, not a control) and
734
+ then its accent colour, leaving the glyph as the one signal of the
735
+ projection. The view style button went the other way on purpose: the
736
+ request was for a control that is clearly SEEN — first #viewbar's card
737
+ chrome over the corner, now a button IN #viewbar. The projection itself has
738
+ no control at all since 2026-09-24: it is automatic (orthographic on a view
739
+ cube face view, perspective otherwise — viewer.js's tweenCameraTo). */
763
740
  .pf-viewcube-canvas {
764
741
  display: block;
765
742
  touch-action: none;
766
743
  }
767
- .pf-viewcube-toggle {
768
- /* 24px: the low end of "small" so the bare circle reads as understated next
769
- to the cube rather than as a second focal point competing with it — a
770
- card's border/background used to do that visual quieting; a transparent
771
- shape has nothing else to lean on but size. */
772
- width: 24px; height: 24px;
773
- border: 0; border-radius: 50%;
774
- background: transparent; box-shadow: none;
744
+
745
+ /* ---- view style button + popover (view-style-controls.js) -------------------
746
+ In #viewbar the button is a plain `#viewbar button` (34px, transparent,
747
+ the bar's hover / `.on` / focus) and needs nothing here beyond the
748
+ divider that opens the appearance group. The card chrome below is the
749
+ FALLBACK's alone — the button over the cube's corner, for a host with no
750
+ #viewbar — where it has to read as a control on top of the drawing: 30px,
751
+ the viewbar's own compact button size (at 34px it covered a third of the
752
+ 101px narrow cube). Scoped to the stack so none of it (the float shadow in
753
+ particular, which `#viewbar button` does not reset) leaks into the bar. */
754
+ .pf-viewbar-divider {
755
+ width: 1px; align-self: stretch; margin: 6px 2px; flex: none;
756
+ background: var(--pf-border);
757
+ }
758
+ .pf-viewbar-divider[hidden] { display: none; }
759
+ .pf-viewcube-stack > .pf-view-style-button {
760
+ width: 30px; height: 30px; border: 1px solid var(--pf-border); border-radius: var(--pf-radius-control);
761
+ background: var(--pf-surface); box-shadow: var(--pf-shadow-float);
775
762
  color: var(--pf-muted-2); cursor: pointer;
776
- font-size: 14px; line-height: 1;
777
763
  display: flex; align-items: center; justify-content: center;
778
764
  }
779
- .pf-viewcube-toggle:hover { color: var(--pf-text); background: var(--pf-surface-2); }
780
- /* The focus ring is NOT restated here: the toggle joins the shared
781
- :focus-visible selector list above, beside #viewbar's own.
782
-
783
- No narrow-width shrink rule here: unlike the old pill (which borrowed
784
- #viewbar's chrome and had to shrink in lockstep with it, below), a bare
785
- 24px circle has no card to keep in step with a smaller viewbar — it stays
786
- its one fixed size at every width. */
765
+ .pf-view-style-button[hidden] { display: none; }
766
+ .pf-viewcube-stack > .pf-view-style-button:hover, .pf-viewcube-stack > .pf-view-style-button.on { color: var(--pf-text); background: var(--pf-surface-2); }
767
+ .pf-view-style-popover {
768
+ position: absolute; z-index: 30;
769
+ width: min(300px, calc(100% - 24px)); overflow: auto;
770
+ padding: 12px; display: flex; flex-direction: column; gap: 12px;
771
+ background: var(--pf-surface); border: 1px solid var(--pf-border);
772
+ border-radius: 12px; box-shadow: var(--pf-shadow-float);
773
+ color: var(--pf-text); font-size: 12px;
774
+ }
775
+ .pf-view-style-popover[hidden] { display: none; }
776
+ .pf-view-style-heading { margin: 0; font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--pf-muted-2); }
777
+ .pf-view-style-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
778
+ .pf-view-style-tile {
779
+ display: flex; flex-direction: column; gap: 4px; padding: 0; border: 0;
780
+ background: none; color: var(--pf-muted-2); cursor: pointer; font: inherit; text-align: center;
781
+ }
782
+ .pf-view-style-thumb {
783
+ display: block; aspect-ratio: 1; border-radius: 8px; overflow: hidden;
784
+ background: var(--pf-surface-2); border: 1px solid var(--pf-border);
785
+ }
786
+ .pf-view-style-thumb img { display: block; width: 100%; height: 100%; object-fit: cover; }
787
+ .pf-view-style-thumb img[hidden] { display: none; }
788
+ .pf-view-style-tile[aria-pressed="true"] { color: var(--pf-text); }
789
+ .pf-view-style-tile[aria-pressed="true"] .pf-view-style-thumb { border-color: var(--pf-accent); box-shadow: 0 0 0 1px var(--pf-accent); }
790
+ .pf-view-style-tile[data-busy] .pf-view-style-thumb { opacity: 0.6; }
791
+ /* A tile whose environment is still loading pulses (the retired viewbar ✦
792
+ toggle's signal, moved onto the thing that was clicked). */
793
+ .pf-view-style-tile[data-busy] .pf-view-style-thumb { animation: pf-busy-pulse .8s ease-in-out infinite alternate; }
794
+ @keyframes pf-busy-pulse { from { opacity: 1; } to { opacity: .45; } }
795
+ @media (prefers-reduced-motion: reduce) { .pf-view-style-tile[data-busy] .pf-view-style-thumb { animation: none; } }
796
+ .pf-view-style-button:focus-visible, .pf-view-style-tile:focus-visible {
797
+ outline: none; box-shadow: 0 0 0 3px color-mix(in oklab, var(--pf-accent) 35%, transparent);
798
+ }
787
799
 
788
800
  /* At very narrow widths the full-size viewbar (5 icon buttons + cutaway's
789
801
  Flip/Reset, ~314px) is wider than a small phone's viewport minus the
@@ -249,24 +249,36 @@
249
249
  56px fallback is the standard viewbar's 12px bottom + 44px height, so the
250
250
  stack still sits right if the observer never fires.
251
251
 
252
- The projection toggle has moved twice. Through 2026-08-19 it sat in its own
253
- `.pf-viewcube-pill` card BELOW the cube, making the stack a column. Earlier
254
- on 2026-08-20 it became a bare circle BESIDE the cube, making the stack a
255
- `row-reverse` flex row (`canvas + gap + button`, 167px wide at the full
256
- 135px cube). It now sits OVER the cube's bottom-right corner instead:
257
- absolutely positioned within the stack, right edge on the stack's right edge
258
- and bottom edge on its bottom baseline, so the stack's width collapses back
259
- to the canvas alone (135px, or 101 below the narrow breakpoint).
260
-
261
- That is also what satisfies "aligned with the right of the toolbar" without a
262
- second offset: `.pf-float-viewbar` above is `right: 12px` too, so making the
263
- button's right edge the STACK's right edge lines it up with the viewbar's by
264
- construction — and it keeps doing so if that margin is ever retuned.
265
-
266
- The button is a later sibling than the cube's wrapper, so it paints over the
267
- canvas with no z-index of its own; 24px over the corner cell — the least
268
- informative part of the drawing — is small enough not to hide anything worth
269
- clicking, and the canvas keeps receiving pointer events everywhere else.
252
+ The VIEW STYLE button (view-style-controls.js) now lives in #viewbar, the
253
+ bottom toolbar, in its APPEARANCE group beside #theme (2026-09-24, at the
254
+ user's request after a live mock, following Fusion 360's
255
+ display-settings-in-the-nav-bar convention) — the cube's corner is clear
256
+ again. Only a host with NO #viewbar still gets it here, over the cube's
257
+ bottom-right corner (the `.pf-viewcube-stack > .pf-view-style-button`
258
+ rule below is scoped to exactly that fallback). Its history: the
259
+ PROJECTION TOGGLE it replaced had moved three times (through 2026-08-19 in
260
+ its own `.pf-viewcube-pill` card BELOW the cube, the stack a column;
261
+ earlier on 2026-08-20 a bare circle BESIDE the cube, the stack a
262
+ `row-reverse` flex row 167px wide at the full cube; later that day OVER
263
+ the cube's bottom-right corner). The view style button took that corner;
264
+ for a few hours on 2026-09-24 it moved out beside the cube, to its left
265
+ (at 34px and card-chromed it covered a third of the 101px narrow cube, and
266
+ beside the cube it had to dodge the animation transport bar); later that
267
+ day it came back over the corner ("to the bottom right of the direction
268
+ widget"), at 30px; and later still it moved into the bottom toolbar.
269
+
270
+ In the fallback it sits inside the stack's box, so it cannot change the
271
+ stack's measured size — the size viewcube-controls.js publishes as
272
+ data-pf-w/h and the transport bar's crowding rule reads is still the
273
+ canvas alone (135px, or 101 below the narrow breakpoint). It is a later
274
+ sibling than the cube's wrapper, so it paints over the canvas with no
275
+ z-index of its own.
276
+
277
+ The stack keeps its right edge on the viewbar's (`.pf-float-viewbar` above
278
+ is `right: 12px` too), so the cube still lines up with the toolbar by
279
+ construction. The popover is NOT in the stack: it is appended to the stage
280
+ and placed at open time (above the toolbar pill, or above the fallback
281
+ button).
270
282
 
271
283
  The 8px that used to sit between the stack and the viewbar is now 3px
272
284
  (2026-08-20: "lower the whole box so it's closer to the bottom bar"). 3
@@ -283,9 +295,9 @@
283
295
  this pair (3 + 5) is the whole slack that exists without either overlapping
284
296
  the viewbar or shrinking the cube.
285
297
 
286
- The stack itself is pointer-transparent. With the button back inside the
287
- canvas's footprint there is no dead gap left at all: the stack's box IS the
288
- canvas's box, and both the canvas and the button opt back in below. The
298
+ The stack itself is pointer-transparent. Its box IS the canvas's box, so
299
+ there is no dead gap in it; the canvas (and a fallback view style
300
+ button) opt back in below. The
289
301
  declaration stays because the stack's own box is still an element over the
290
302
  viewer, and a stray hit on it (its padding-free edges, or a future child
291
303
  before it opts in) should reach the model behind rather than be swallowed. */
@@ -298,10 +310,11 @@
298
310
  pointer-events: none;
299
311
  }
300
312
  .pf-viewcube-stack[hidden] { display: none; }
301
- .pf-viewcube-canvas, .pf-viewcube-toggle { pointer-events: auto; }
302
- /* Over the cube's bottom-right corner. Absolute against the stack, which is
303
- itself absolute and so already the containing block. */
304
- .pf-viewcube-toggle { position: absolute; right: 0; bottom: 0; }
313
+ .pf-viewcube-canvas, .pf-viewcube-stack > .pf-view-style-button { pointer-events: auto; }
314
+ /* The fallback only (no #viewbar): over the cube's bottom-right corner.
315
+ Absolute against the stack, which is itself absolute and so already the
316
+ containing block. In #viewbar the button is an ordinary flex item. */
317
+ .pf-viewcube-stack > .pf-view-style-button { position: absolute; right: 0; bottom: 0; }
305
318
 
306
319
  /* The keyboard surface: six per-view buttons standing in for the DOM focus a
307
320
  canvas cannot give us. Visually hidden rather than display:none — the latter
@@ -53,7 +53,8 @@ export function createCutawayGizmo({
53
53
  onDragChange = () => {},
54
54
  pickHandle,
55
55
  }) {
56
- // Reassignable: the viewer swaps cameras when the projection toggle flips,
56
+ // Reassignable: the viewer swaps cameras when the projection changes (a view
57
+ // cube face view settling into ortho, a rotation leaving it),
57
58
  // and this module holds fifteen references to it. One binding to move beats
58
59
  // threading a getter through all of them.
59
60
  let camera = initialCamera;
@@ -144,6 +144,29 @@ export function createCutaway({
144
144
  handleHoverSubscribers.clear();
145
145
  }
146
146
 
147
+ // "What the section looks like changed": the mode went on or off, or the
148
+ // plane moved (a drag, flip, reset or restore). For anything that caches a
149
+ // picture of the part — the view style popover's thumbnails — which would
150
+ // otherwise keep showing a section the user has since moved or closed.
151
+ // Fires often during a drag, so a listener must be cheap; no argument, since
152
+ // every caller only needs to know that it happened. A throwing listener is
153
+ // reported, never allowed to interrupt the controller work that fired it.
154
+ const changeListeners = new Set();
155
+ function notifyChange() {
156
+ for (const listener of [...changeListeners]) {
157
+ try {
158
+ listener();
159
+ } catch (error) {
160
+ try { console.error("Cutaway change listener failed", error); } catch { /* reporting only */ }
161
+ }
162
+ }
163
+ }
164
+ function onChange(listener) {
165
+ if (disposed || typeof listener !== "function") return () => {};
166
+ changeListeners.add(listener);
167
+ return () => changeListeners.delete(listener);
168
+ }
169
+
147
170
  function selected(name) {
148
171
  return selectedNames == null || selectedNames.has(name);
149
172
  }
@@ -213,6 +236,7 @@ export function createCutaway({
213
236
  gizmo.setFlipped(flipped);
214
237
  gizmo.setPose(pose);
215
238
  if (activeAppearance) showActive();
239
+ notifyChange();
216
240
  }
217
241
 
218
242
  function onPoseChange(nextPose) {
@@ -314,6 +338,7 @@ export function createCutaway({
314
338
  } finally {
315
339
  disabling = false;
316
340
  }
341
+ if (wasEnabled) notifyChange();
317
342
  if (firstError) throw firstError;
318
343
  return true;
319
344
  }
@@ -340,6 +365,7 @@ export function createCutaway({
340
365
  gizmo.setVisible(true);
341
366
  gizmo.updateForCamera();
342
367
  showActive();
368
+ notifyChange(); // applyPose above fired too, but before `enabled` meant on
343
369
  return true;
344
370
  }
345
371
 
@@ -360,6 +386,7 @@ export function createCutaway({
360
386
  planeFromPose(plane, planeNormal, pose.position, pose.quaternion, flipped);
361
387
  gizmo.setFlipped(flipped);
362
388
  showActive();
389
+ notifyChange();
363
390
  return true;
364
391
  }
365
392
 
@@ -579,6 +606,7 @@ export function createCutaway({
579
606
  publishHandleHover(null);
580
607
  drainHandleHoverPublications();
581
608
  clearHandleHoverSubscribers();
609
+ changeListeners.clear();
582
610
  attempt(() => overlayScene.clear());
583
611
  attempt(() => capGeometry.dispose());
584
612
  if (firstError) throw firstError;
@@ -605,6 +633,11 @@ export function createCutaway({
605
633
  updateForCamera,
606
634
  renderOverlay,
607
635
  onHandleHoverChange,
636
+ onChange,
637
+ // The gizmo's in-scene half (the ghost plane and its border). It is
638
+ // interactive UI drawn in the part's own scene, so the viewer keeps it out
639
+ // of every offscreen render (viewer.js's captureHidden).
640
+ captureExcluded: [gizmo.group],
608
641
  setCamera(next) {
609
642
  if (!next) return;
610
643
  camera = next;
@@ -69,8 +69,9 @@ function weld(positions, indices) {
69
69
  }
70
70
 
71
71
  // Default for an uncoloured object in an export where some other object IS
72
- // coloured: the viewer's own default look (presets.js "default"). Written as a
73
- // literal so this module keeps its fflate-only import closure.
72
+ // coloured: the viewer's own no-material colour (resolve.js
73
+ // NO_MATERIAL_COLOR). Written as a literal so this module keeps its
74
+ // fflate-only import closure; viewer-materials.test.js pins the copies equal.
74
75
  const DEFAULT_DISPLAY_COLOR = 0x9fb4cc;
75
76
  // Base-material group id: outside the object id range (objects are 1..n).
76
77
  const BASE_MATERIALS_ID = 1000;
@@ -3,12 +3,12 @@
3
3
  // block geometry, and the viewer already falls back (resolve.js). Pure — imports
4
4
  // only the three-free materials data (test/lint-purity.test.js).
5
5
  import { warn } from "./finding.js";
6
- import { PRESETS, DEFAULT_PRESET_ID } from "../materials/presets.js";
6
+ import { PRESETS } from "../materials/presets.js";
7
7
  import { ENVIRONMENTS } from "../materials/environments.js";
8
8
  import { resolveMaterial, resolveEnvironmentId, OVERRIDE_RANGES } from "../materials/resolve.js";
9
9
  import { suggest } from "../geometry/op-options.js";
10
10
 
11
- const listedPresets = () => Object.keys(PRESETS).filter((id) => id !== DEFAULT_PRESET_ID);
11
+ const listedPresets = () => Object.keys(PRESETS);
12
12
  const subParts = (part) => Object.entries(part?.parts ?? {}).filter(([, sp]) => sp && typeof sp === "object");
13
13
  const issuesOf = (part, kind) => subParts(part).flatMap(([name, sp]) =>
14
14
  resolveMaterial(sp.display).issues.filter((i) => i.kind === kind).map((i) => ({ name, ...i })));
@@ -20,7 +20,7 @@ export const MATERIAL_RULES = [
20
20
  const near = typeof value === "string" ? suggest(value, listedPresets()) : null;
21
21
  return warn("unknown-material",
22
22
  `sub-part "${name}" names material ${JSON.stringify(value)}, which is not in the library`,
23
- `${near ? `Did you mean "${near}"? ` : ""}The viewer shows the default look instead. Known materials: ${listedPresets().join(", ")}.`,
23
+ `${near ? `Did you mean "${near}"? ` : ""}The viewer draws it as if it named no material instead (a PLA print in realistic mode). Known materials: ${listedPresets().join(", ")}.`,
24
24
  `parts.${name}.display.material`);
25
25
  }),
26
26
  },
@@ -68,7 +68,7 @@ the sliced primary and gain-map buffers now start with SOI (`FF D8`) and end wit
68
68
  | `pattern-walnut-rough.jpg` | Poly Haven "Dark Wood" (roughness; API key `Rough`) | https://polyhaven.com/a/dark_wood — file https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/dark_wood/dark_wood_rough_1k.jpg | 2026-09-24 | 1K JPG, grayscale data -> `node scripts/bake-environments.mjs --texture dark_wood_rough_1k.jpg pattern-walnut-rough.jpg --gray` (source is neutral grey; channel means unchanged). |
69
69
  | `ground-maple-color.jpg` | Poly Haven "Oak Veneer 01" (colour; API key `Diffuse`) | https://polyhaven.com/a/oak_veneer_01 — file https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/oak_veneer_01/oak_veneer_01_diff_1k.jpg | 2026-09-24 | Workshop floor, "unfinished maple": `node scripts/bake-environments.mjs --texture oak_veneer_01_diff_1k.jpg ground-maple-color.jpg --modulate 1.5,0.7 --quality 85` (lightened 1.5x, saturation 0.7). |
70
70
  | `ground-maple-normal.jpg` | Poly Haven "Oak Veneer 01" (OpenGL normal map; API key `nor_gl`) | https://polyhaven.com/a/oak_veneer_01 — file https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/oak_veneer_01/oak_veneer_01_nor_gl_1k.jpg | 2026-09-24 | Workshop floor relief: `node scripts/bake-environments.mjs --texture oak_veneer_01_nor_gl_1k.jpg ground-maple-normal.jpg --data --quality 92`. A separate file from `pattern-oak-normal.jpg` so the floor and the oak material never share (and dispose) one texture. |
71
- | `ground-pei.jpg` | None — procedural | n/a | n/a | Gold-brown fine-grain speckle, no external source. First attempt tinted ambientCG "Plastic 010"'s colour map (near-uniform, stdev < 1/channel — visibly flat, no grain, when actually viewed) gold-brown; that colour map carries no usable grain at all (its roughness map does, which is why `ground-pei-rough.jpg` below is still Plastic010's roughness). Checked ambientCG alternatives (Metal049A — also flat; Granite006A — real speckle but reads as rock, not powder-coat) and found nothing closer. Generated in-baker instead, same procedural precedent as `pattern-carbon.jpg`: per-pixel triangular-distributed noise around a `196,154,82` gold-brown base (asymmetric per channel so flecks warm rather than grey), then a 0.5px blur to turn single-pixel noise into soft grains -> `node scripts/bake-environments.mjs --speckle ground-pei.jpg --tint 196,154,82`. |
71
+ | `ground-pei.jpg` | None — procedural | n/a | n/a | Gold-brown fine-grain speckle, no external source. First attempt tinted ambientCG "Plastic 010"'s colour map (near-uniform, stdev < 1/channel — visibly flat, no grain, when actually viewed) gold-brown; that colour map carries no usable grain at all (its roughness map does, which is why `ground-pei-rough.jpg` below is still Plastic010's roughness). Checked ambientCG alternatives (Metal049A — also flat; Granite006A — real speckle but reads as rock, not powder-coat) and found nothing closer. Generated in-baker instead, same procedural precedent as `pattern-carbon.jpg`: per-pixel triangular-distributed noise around a `168,134,82` bronze-gold base (asymmetric per channel so flecks warm rather than grey), then a 0.5px blur to turn single-pixel noise into soft grains -> `node scripts/bake-environments.mjs --speckle ground-pei.jpg --tint 168,134,82`. (Re-baked 2026-09-24 from `196,154,82`: darker and less saturated, closer to a Bambu textured-PEI plate's muted bronze-gold.) |
72
72
  | `ground-pei-rough.jpg` | ambientCG "Plastic 010" (Roughness) | https://ambientcg.com/a/Plastic010 | 2026-09-24 | 1K JPG, grayscale -> `node scripts/bake-environments.mjs --texture Plastic010_1K-JPG_Roughness.jpg ground-pei-rough.jpg --gray` |
73
73
  | `ground-concrete.jpg` | ambientCG "Concrete 047 A" (Color) | https://ambientcg.com/a/Concrete047A | 2026-09-24 | 2K JPG -> `node scripts/bake-environments.mjs --texture Concrete047A_2K-JPG_Color.jpg ground-concrete.jpg --size 1024 --modulate 1.35,0 --flatten 0.7 --quality 82` (grey, brightened to the old map's mean so the concrete material preset keeps its tone; `--flatten 0.7` is 30% less grain contrast). Replaced ambientCG "Concrete 034", whose colour map was nearly flat (stdev ~5/255) and read as blur up close; 047 A's exposed aggregate keeps fine grain. |
74
74
  | `ground-concrete-rough.jpg` | ambientCG "Concrete 047 A" (Roughness) | https://ambientcg.com/a/Concrete047A | 2026-09-24 | 2K JPG, grayscale -> `node scripts/bake-environments.mjs --texture Concrete047A_2K-JPG_Roughness.jpg ground-concrete-rough.jpg --gray --size 1024` |