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.
@@ -7,8 +7,9 @@ import { attachRail } from "./rail.js";
7
7
  import { declaredSourceLookup } from "./panel/declared-source.js";
8
8
  import { attachMobileTabs } from "./mobile-tabs.js";
9
9
  import { createTooltipPresenter, attachButtonTooltips } from "./tooltip.js";
10
- import { loadCamera, loadProjection, saveProjection, loadRenderMode, loadEnvironment } from "./view-state.js";
11
- import { attachRealisticControls } from "./realistic-controls.js";
10
+ import { loadCamera, loadRenderMode, loadEnvironment } from "./view-state.js";
11
+ import { isFaceAlignedState } from "./projection.js";
12
+ import { attachViewStyleControls } from "./view-style-controls.js";
12
13
  import { ENVIRONMENTS } from "./materials/environments.js";
13
14
  import { declaresMaterials, resolveMaterial } from "./materials/resolve.js";
14
15
  import { printFrameMatrix } from "./materials/print-frame.js";
@@ -200,8 +201,8 @@ export function makeHandle({ ready, dispose, viewer, setParams, listExportablePa
200
201
  // switch while realistic reverts to the one still shown, and onChange hears
201
202
  // both); list() is every environment as {id, label}, for a host picker.
202
203
  environment: environment ?? { get: () => "studio", set: async (id) => id, onChange: () => () => {}, list: () => [] },
203
- // True when any sub-part names a display.material — a host can offer the
204
- // realistic toggle only where it shows something a CAD view does not.
204
+ // True when any sub-part names a display.material — a host can offer
205
+ // realistic rendering only where it shows something a CAD view does not.
205
206
  declaresMaterials: declaresMaterials ?? false,
206
207
  };
207
208
  }
@@ -315,9 +316,18 @@ function createCleanupStack() {
315
316
  // // every stroke/undo/clear, which is what a host driving its
316
317
  // // own Send button gates that button on (strokeCount() > 0).
317
318
  // runtime.projection: { get, set, onChange }
318
- // // "perspective" | "orthographic". Drives the LIVE view
319
- // // and captureCurrent only — captureCanonicalViews,
320
- // // renderMeshPayloads and the CLI stay perspective.
319
+ // // "perspective" | "orthographic". AUTOMATIC in the UI
320
+ // // (Fusion 360's "Perspective with Ortho Faces"): a view
321
+ // // cube FACE click settles into orthographic, an edge or
322
+ // // corner click is perspective, and rotating off the face
323
+ // // returns to perspective (pan and zoom keep it). There
324
+ // // is no user control for it. set("orthographic") still
325
+ // // works for a host, and is left the same way — by the
326
+ // // first rotation. Not persisted across reloads; carried
327
+ // // in viewerState only with a face-view camera. Drives
328
+ // // the LIVE view and captureCurrent only —
329
+ // // captureCanonicalViews, renderMeshPayloads and the CLI
330
+ // // stay perspective.
321
331
  // runtime.renderMode: { get, set, onChange }
322
332
  // // "cad" | "realistic". set() resolves to the mode in
323
333
  // // effect ("cad" if the realistic assets failed);
@@ -325,7 +335,8 @@ function createCleanupStack() {
325
335
  // // projection. Drives the live view; captureCurrent
326
336
  // // follows it unless given { renderMode }. captureViews
327
337
  // // is always CAD — the live mode never changes what
328
- // // the agent sees.
338
+ // // the agent sees. Feature lines follow it directly:
339
+ // // CAD always draws them, realistic never does.
329
340
  // runtime.environment: { get, set, onChange, list }
330
341
  // // the realistic environment ("studio" | "workshop" |
331
342
  // // "print-bed" | "outdoor"); list() → [{id, label}].
@@ -358,8 +369,9 @@ function createCleanupStack() {
358
369
  // // KB of base64 apiece, so a host should not assume this
359
370
  // // payload is small, only that it is bounded.
360
371
  // viewerState: ViewerState // a previous mount's runtime.getViewerState(), handed back to
361
- // // resume the camera, projection, cutaway, render mode and
362
- // // environment where that mount
372
+ // // resume the camera, projection, cutaway, render mode,
373
+ // // environment and per-style feature-line preferences
374
+ // // where that mount
363
375
  // // left them. For a host that applies edits by REMOUNTING: the
364
376
  // // part changed, the user's view of it should not. Omit on a
365
377
  // // first mount — the viewer then restores its own persisted
@@ -390,13 +402,20 @@ function createCleanupStack() {
390
402
  // // not a degraded one). A rejection is reported through the
391
403
  // // control's own onError; the widget keeps the converted blob
392
404
  // // so a retry costs a network call, not a reconvert.
393
- // elements.chrome.realistic / .environment // optional realistic-mode viewbar controls — a
394
- // // <button> toggle and an (empty) <select> the mount
395
- // // fills with the environments; default ids #realistic
396
- // // and #environment. Without them the host drives
397
- // // runtime.renderMode / runtime.environment itself.
398
- // // Both preferences persist like the theme (and carry
399
- // // in viewerState, which outranks what is stored).
405
+ // (view style — no element) // the view style button (#view-style, an eye) is
406
+ // // GENERATED into the stage's #viewbar, before #theme
407
+ // // behind a divider (the bar's appearance group); a
408
+ // // stage with no #viewbar gets it over the view cube's
409
+ // // bottom-right corner instead. Its popover holds the
410
+ // // style (CAD or a realistic environment, as live
411
+ // // thumbnails). Hosts need no markup
412
+ // // for it — the old elements.chrome.realistic /
413
+ // // .environment (#realistic, #environment) are gone. A
414
+ // // host can still drive runtime.renderMode /
415
+ // // .environment itself. The preferences persist like the
416
+ // // theme (and carry in viewerState, which outranks what
417
+ // // is stored). There is no projection control: see
418
+ // // runtime.projection.
400
419
  // Every `elements` entry defaults to the legacy global-ID lookup (below), resolved
401
420
  // exactly once here — submodules take element refs and never query the document.
402
421
  // `container`/`controls` remain as deprecated aliases for elements.viewer/.controls.
@@ -439,8 +458,6 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
439
458
  measure: elements.chrome?.measure ?? byId("measure"),
440
459
  annotate: elements.chrome?.annotate ?? byId("annotate"),
441
460
  railToggle: elements.chrome?.railToggle ?? byId("rail-toggle"),
442
- realistic: elements.chrome?.realistic ?? byId("realistic"),
443
- environment: elements.chrome?.environment ?? byId("environment"),
444
461
  },
445
462
  };
446
463
 
@@ -646,13 +663,26 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
646
663
  }
647
664
  }));
648
665
  }
649
- // Orientation cube + projection toggle. Generated chrome — no host markup
650
- // declares it, so an embedder gets it for free. Restored BEFORE any framing
651
- // happens so a reload into ortho frames once instead of framing in
652
- // perspective and then visibly re-framing.
653
- // A carried state outranks the persisted preference: it is this session's
654
- // live answer, where the stored one is the last page-reload's.
655
- viewer.setProjection(viewerState?.projection ?? loadProjection());
666
+ // Orientation cube (and the view style button). Generated chrome — no host markup
667
+ // declares it, so an embedder gets it for free. The projection is restored
668
+ // BEFORE any framing happens so a remount into ortho frames once instead of
669
+ // framing in perspective and then visibly re-framing.
670
+ //
671
+ // The projection is AUTOMATIC (viewer.js's tweenCameraTo): orthographic
672
+ // only on a face view, so it is not a preference and is not persisted —
673
+ // a page reload opens in perspective. What IS restored is a carried
674
+ // viewerState, because a host that remounts per edit (partforge-cloud)
675
+ // would otherwise drop the user out of the face view they were in. Only
676
+ // when the carried camera is itself a face view, though: an orthographic
677
+ // state carried with a free-orbit camera (an older host's token, from
678
+ // when projection was a manual toggle) comes back perspective. The camera
679
+ // itself lands later (showView); setCameraState re-arms the ortho view on
680
+ // it, so the first rotation still returns to perspective.
681
+ viewer.setProjection(
682
+ viewerState?.projection === "orthographic" && isFaceAlignedState(viewerState.camera)
683
+ ? "orthographic"
684
+ : "perspective",
685
+ );
656
686
  // The environment by the same precedence (carried, stored, then the part's
657
687
  // own meta.environment — which the viewer already starts on, unchosen). A
658
688
  // carried or stored one was chosen, so it stays chosen and carries on. In
@@ -671,9 +701,21 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
671
701
  // getViewerState carries; an explicit mode change supersedes it, and a
672
702
  // failed load settles to CAD.
673
703
  if ((viewerState?.renderMode ?? loadRenderMode() ?? "cad") === "realistic") viewer.setRenderMode("realistic");
674
- const viewcube = attachViewcubeControls(viewer, { stage: els.viewer }, { tooltip });
704
+ const viewcube = attachViewcubeControls(viewer, { stage: els.viewer });
675
705
  cleanup.defer(() => viewcube.detach());
676
- cleanup.defer(viewer.onProjectionChange((mode) => saveProjection(mode)));
706
+ // The view style button + popover (the style grid). With a #viewbar
707
+ // (looked up the same way sketchHides does) it joins the bottom toolbar's
708
+ // appearance group, before #theme behind a divider, and its popover
709
+ // closes when the bar hides (Sketch). Without one it falls back to the
710
+ // cube's bottom-right corner, in the stack, hiding with the cube. It is
711
+ // a #viewbar child, so --pf-viewbar-clear below measures it for free (the
712
+ // bar grows wider, not taller).
713
+ const viewStyle = attachViewStyleControls(viewer, {
714
+ stage: els.viewer,
715
+ toolbar: els.viewer.querySelector("#viewbar"),
716
+ anchor: viewcube.element,
717
+ }, { tooltip });
718
+ cleanup.defer(() => viewStyle.detach());
677
719
  // setHidden takes one boolean, and there are two independent reasons to hide
678
720
  // the cube: Sketch mode (below) and a crowded transport bar (wired into the
679
721
  // animation controls further down). Applied straight, whichever fires last
@@ -870,9 +912,11 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
870
912
  if (frame) {
871
913
  framedView = view();
872
914
  if (!cameraRestored) {
873
- // A carried camera beats the persisted one for the same reason the
874
- // projection above does — and it is also the accurate one, since the
875
- // persisted pose is only written at the end of an orbit drag.
915
+ // A carried camera beats the persisted one: it is this session's live
916
+ // answer, where the stored one is the last page-reload's — and it is
917
+ // also the accurate one, since the persisted pose is only written at
918
+ // the end of an orbit drag. An orthographic restore above was made
919
+ // only for a face-view camera, and this is the call that re-arms it.
876
920
  const cam = viewerState?.camera ?? loadCamera();
877
921
  if (cam) viewer.setCameraState(cam);
878
922
  // The cutaway goes back on AFTER the camera and only here, on the
@@ -883,6 +927,14 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
883
927
  // fallback pose from the wrong view.)
884
928
  if (viewerState?.cutaway?.enabled && viewer.setCutawayState?.(viewerState.cutaway)) {
885
929
  cutawayChrome.sync(); // the button was not what turned it on
930
+ // …and place the camera again now the section is back. An ortho
931
+ // face view's size is matched at the surface on screen, which the
932
+ // cut decides (the cap, or whatever survives it): the carried pose
933
+ // was taken WITH the cut, and the first placement above measured
934
+ // against the uncut part, so without this a sectioned face view
935
+ // changes zoom on every remount. Same pose, so a perspective
936
+ // camera does not move.
937
+ if (cam) viewer.setCameraState(cam);
886
938
  }
887
939
  cameraRestored = true;
888
940
  }
@@ -891,22 +943,42 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
891
943
 
892
944
  // Print frames for the layer-line pattern: the display → export map of the
893
945
  // geometry just delivered, so layers run the way the part is printed rather
894
- // than the way it is displayed. Computed at delivery (the frame describes
895
- // the delivered mesh, which a later pose-only repair only moves) and only
896
- // for sub-parts whose material draws layer lines — each one costs two
897
- // geometry-free probe builds.
946
+ // than the way it is displayed. Each frame describes the delivered mesh
947
+ // (which a later pose-only repair only moves), and only sub-parts whose
948
+ // material draws layer lines have one — which includes every
949
+ // sub-part naming no material, since realistic mode shows those as PLA
950
+ // (resolve.js). One without a place() is identity at once; one with it
951
+ // costs two geometry-free probe builds.
898
952
  const layerLined = new Set(Object.keys(part.parts).filter((n) => {
899
953
  try { return resolveMaterial(part.parts[n].display).params.pattern === "layer-lines"; } catch { return false; }
900
954
  }));
955
+ // LAZY: a delivery only records the view and params it was built at; the
956
+ // viewer pulls the frames (computePrintFrames) when it is about to draw the
957
+ // realistic look — live, loading, or borrowed by a capture — so a CAD-only
958
+ // session never probes (viewer.js setPrintFrameSource). The snapshot keeps
959
+ // a late computation describing the mesh actually delivered.
901
960
  const printFrames = {};
961
+ const undrawnFrames = new Map(); // sub-part -> { view, params } at its delivery
902
962
  function recordPrintFrames(names) {
903
963
  const wanted = names.filter((n) => layerLined.has(n));
904
964
  if (!wanted.length) return;
905
- let resolved;
906
- try { resolved = resolveParams(part, params); } catch { return; } // diagnosed by the build
907
- for (const n of wanted) printFrames[n] = printFrameMatrix(part.parts[n], { view: view(), ...resolved });
908
- viewer.setPrintFrames({ ...printFrames });
965
+ const at = { view: view(), params: { ...params } };
966
+ for (const n of wanted) undrawnFrames.set(n, at);
967
+ viewer.invalidatePrintFrames?.();
909
968
  }
969
+ function computePrintFrames() {
970
+ const resolvedFor = new Map(); // one resolveParams per delivery, not per sub-part
971
+ for (const [n, at] of undrawnFrames) {
972
+ if (!resolvedFor.has(at)) {
973
+ try { resolvedFor.set(at, resolveParams(part, at.params)); } catch { resolvedFor.set(at, null); } // diagnosed by the build
974
+ }
975
+ const resolved = resolvedFor.get(at);
976
+ if (resolved) printFrames[n] = printFrameMatrix(part.parts[n], { view: at.view, ...resolved });
977
+ }
978
+ undrawnFrames.clear();
979
+ return { ...printFrames };
980
+ }
981
+ viewer.setPrintFrameSource?.(computePrintFrames);
910
982
 
911
983
  // Sub-parts whose latest fresh delivery had zero triangles (see the `meshes` case).
912
984
  const emptySubParts = new Set();
@@ -1263,11 +1335,6 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
1263
1335
  // Optional host-page viewer chrome (reframe / theme) + camera persistence.
1264
1336
  const chrome = attachViewerControls(viewer, els.chrome, { tooltip });
1265
1337
  cleanup.defer(() => chrome.detach());
1266
- // Optional realistic-mode chrome: the toggle and the environment picker.
1267
- const realisticChrome = attachRealisticControls(viewer, {
1268
- toggle: els.chrome.realistic, envMenu: els.chrome.environment,
1269
- }, { tooltip });
1270
- cleanup.defer(() => realisticChrome.detach());
1271
1338
 
1272
1339
  // Full teardown of everything this mount created. Idempotent. A disposed runtime
1273
1340
  // can never surface a late build result (workers are terminated, the loop is
@@ -1,6 +1,7 @@
1
1
  // The perspective <-> orthographic framing pair. Pure, so the swap's only
2
2
  // interesting property — that the part does not change size the instant the
3
- // user hits the toggle — is unit-testable without a renderer.
3
+ // projection swaps (a view cube face click settling into ortho, a rotation
4
+ // leaving it) — is unit-testable without a renderer.
4
5
  //
5
6
  // Perspective frames by DISTANCE; orthographic frames by a frustum height plus
6
7
  // a zoom (OrbitControls dollies an ortho camera by changing camera.zoom, not by
@@ -17,3 +18,49 @@ export function orthoFrustum({ fovDeg, distance, aspect = 1 }) {
17
18
  export function perspectiveDistance({ halfH, zoom = 1, fovDeg }) {
18
19
  return halfH / (zoom * Math.tan((fovDeg * Math.PI) / 360));
19
20
  }
21
+
22
+ // --- automatic projection (Fusion 360's "Perspective with Ortho Faces") -----
23
+ // The live view is orthographic only while it looks straight down one of the
24
+ // six world axes — a view cube FACE view. These are the tests that decide
25
+ // "straight down an axis" and "the user has rotated off it".
26
+ //
27
+ // Half a degree: comfortably above what OrbitControls itself leaves behind (it
28
+ // clamps the polar angle a hair — 1e-6 rad — off the poles, so a settled top
29
+ // view is never exactly on +Y) and far below any rotation a person could make
30
+ // on purpose and expect to see.
31
+ export const FACE_ALIGN_EPS_DEG = 0.5;
32
+ export const FACE_ALIGN_COS = Math.cos((FACE_ALIGN_EPS_DEG * Math.PI) / 180);
33
+ const COS_EPS = FACE_ALIGN_COS;
34
+
35
+ function unit(v) {
36
+ const l = Math.hypot(v[0], v[1], v[2]);
37
+ return l > 0 ? [v[0] / l, v[1] / l, v[2] / l] : null;
38
+ }
39
+
40
+ // `dir` is any non-zero vector (the target -> camera offset is what callers
41
+ // pass). True when it lies within FACE_ALIGN_EPS_DEG of +/-X, +/-Y or +/-Z.
42
+ export function isFaceAligned(dir) {
43
+ const d = Array.isArray(dir) ? unit(dir) : null;
44
+ if (!d || !d.every(Number.isFinite)) return false;
45
+ return Math.max(Math.abs(d[0]), Math.abs(d[1]), Math.abs(d[2])) >= COS_EPS;
46
+ }
47
+
48
+ // The same test on a { pos, target } camera state (getCameraState's shape,
49
+ // and what a host carries in viewerState.camera). A missing or malformed state
50
+ // is not aligned — the caller then restores perspective, the safe default.
51
+ export function isFaceAlignedState(state) {
52
+ const p = state?.pos, t = state?.target;
53
+ if (!Array.isArray(p) || !Array.isArray(t) || p.length !== 3 || t.length !== 3) return false;
54
+ return isFaceAligned([p[0] - t[0], p[1] - t[1], p[2] - t[2]]);
55
+ }
56
+
57
+ // True once `dir` has turned more than FACE_ALIGN_EPS_DEG away from `axis`
58
+ // (both non-zero vectors; neither need be unit length). A pan moves the camera
59
+ // and its target together and an orthographic zoom moves neither, so only a
60
+ // ROTATION trips this — which is why direction, not OrbitControls' "start"
61
+ // event (fired for pan and zoom too), is what ends an ortho face view.
62
+ export function hasLeftAxis(dir, axis) {
63
+ const a = unit(dir), b = unit(axis);
64
+ if (!a || !b) return false;
65
+ return a[0] * b[0] + a[1] * b[1] + a[2] * b[2] < COS_EPS;
66
+ }
@@ -6,7 +6,10 @@
6
6
  // and a fresh session opens on the part's own default (see default-view.js).
7
7
  // Reads/writes are guarded: if storage is unavailable (private mode, disabled) or a
8
8
  // value is corrupt, reads return the documented default and writes are no-ops —
9
- // persistence never throws. `renderMode` and `environment` are viewer
9
+ // persistence never throws. The projection is not stored: it is automatic
10
+ // (orthographic only on a view cube face view — viewer.js's tweenCameraTo), so
11
+ // a reload opens in perspective; a stale `partforge:projection` key from when it
12
+ // was a manual toggle is simply never read. `renderMode` and `environment` are viewer
10
13
  // preferences like `theme`; in the cloud's opaque-origin sandbox storage throws,
11
14
  // so there the host carries them in viewerState instead.
12
15
  import { ENVIRONMENTS } from "./materials/environments.js";
@@ -14,7 +17,6 @@ import { ENVIRONMENTS } from "./materials/environments.js";
14
17
  const KEY = {
15
18
  camera: "partforge:camera",
16
19
  theme: "partforge:theme",
17
- projection: "partforge:projection",
18
20
  renderMode: "partforge:renderMode",
19
21
  environment: "partforge:environment",
20
22
  };
@@ -61,14 +63,6 @@ export function saveTheme(mode) {
61
63
  if (mode === "light" || mode === "dark") write(KEY.theme, mode);
62
64
  }
63
65
 
64
- export function loadProjection() {
65
- return read(KEY.projection) === "orthographic" ? "orthographic" : "perspective";
66
- }
67
-
68
- export function saveProjection(mode) {
69
- if (mode === "perspective" || mode === "orthographic") write(KEY.projection, mode);
70
- }
71
-
72
66
  // null = nothing (valid) stored — the caller falls back to "cad".
73
67
  export function loadRenderMode() {
74
68
  const raw = read(KEY.renderMode);
@@ -0,0 +1,298 @@
1
+ // src/framework/view-style-controls.js
2
+ // The view style button and its popover: every control that changes HOW the
3
+ // part is drawn, in one place — the style (CAD or a realistic environment, as
4
+ // live thumbnails of this part). Generated into the
5
+ // stage, not declared by the host (the view cube / mobile-tabs.js
6
+ // precedent), so an embedder gets it with no markup.
7
+ //
8
+ // WHERE the button goes is chosen once, at attach time:
9
+ //
10
+ // - toolbar mode (a `toolbar` was passed — the stage's #viewbar): the
11
+ // button joins the bottom toolbar's APPEARANCE group, inserted just
12
+ // before #theme (appended when there is none) behind a divider
13
+ // (`.pf-viewbar-divider`), so the pill reads "tools | appearance". It is a
14
+ // plain `#viewbar button` there — no card chrome of its own — and it does
15
+ // not hide with the view cube (it is not on the cube any more). The
16
+ // popover opens ABOVE the pill, its right edge flush with the PILL's
17
+ // right edge. Moved here 2026-09-24 at the user's request after a live
18
+ // mock, following Fusion 360's display-settings-in-the-nav-bar convention.
19
+ // - fallback (no toolbar — a host that supplies no #viewbar): the button is
20
+ // a card-chromed child of the view cube's stack (`anchor`), over the
21
+ // cube's bottom-right corner (chrome.css), hiding whenever the cube does;
22
+ // the popover opens above the BUTTON.
23
+ //
24
+ // Either way the popover closes whenever the button's host (#viewbar, or the
25
+ // cube's stack) is hidden — Sketch mode hides both — whether by the `hidden`
26
+ // attribute or by CSS (partforge-cloud hides #viewbar with a class, which is
27
+ // display:none and no attribute at all). One code path for the popover; only
28
+ // its reference rect and the observed host differ.
29
+ //
30
+ // Through 2026-09-24 the popover also held a Projection row (a Perspective /
31
+ // Orthographic segmented control). It went when the projection became
32
+ // AUTOMATIC — orthographic on a view cube face view, perspective everywhere
33
+ // else (Fusion 360's "Perspective with Ortho Faces"; viewer.js's
34
+ // tweenCameraTo) — so the popover is the style grid alone.
35
+ //
36
+ // The state shown is always the viewer's: a tile is pressed once the viewer
37
+ // reports that style, and a runtime change made elsewhere shows up here.
38
+ import { STYLES, styleFor, createThumbnailCache } from "./view-style-state.js";
39
+ import { saveRenderMode, saveEnvironment } from "./view-state.js";
40
+ import { attachButtonTooltips } from "./tooltip.js";
41
+
42
+ // An eye (lucide "eye"): the button is about how the part is SEEN — a
43
+ // palette read as a paint/colour picker, and a movie camera (its icon until
44
+ // the move into the toolbar) as recording. One icon in both states: the
45
+ // open popover is signalled by `.on` and aria-expanded, never an eye-off.
46
+ const ICON = `<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></svg>`;
47
+ const LABEL = "View style";
48
+
49
+ function el(tag, className, attrs = {}) {
50
+ const e = document.createElement(tag);
51
+ if (className) e.className = className;
52
+ for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, v);
53
+ return e;
54
+ }
55
+
56
+ export function attachViewStyleControls(viewer, { stage, toolbar = null, anchor = null } = {}, { tooltip } = {}) {
57
+ const button = el("button", "pf-view-style-button", {
58
+ type: "button", id: "view-style", "aria-label": LABEL,
59
+ "aria-haspopup": "dialog", "aria-expanded": "false", "aria-controls": "pf-view-style-popover",
60
+ });
61
+ button.innerHTML = ICON;
62
+ if (!tooltip) button.title = LABEL;
63
+ // The toolbar wins; see the header. `host` is the element whose `hidden`
64
+ // closes the popover, `placeRef` the rect the popover is placed from.
65
+ let divider = null;
66
+ const host = toolbar ?? anchor;
67
+ if (toolbar) {
68
+ divider = el("span", "pf-viewbar-divider", { "aria-hidden": "true" });
69
+ const theme = [...toolbar.children].find((c) => c.id === "theme") ?? null;
70
+ toolbar.insertBefore(divider, theme);
71
+ toolbar.insertBefore(button, theme);
72
+ } else {
73
+ (anchor ?? stage).append(button);
74
+ }
75
+ const placeRef = toolbar ?? button;
76
+
77
+ const pop = el("div", "pf-view-style-popover", { id: "pf-view-style-popover", role: "dialog", "aria-label": LABEL });
78
+ pop.hidden = true;
79
+
80
+ const styleHead = el("p", "pf-view-style-heading");
81
+ styleHead.textContent = "Style";
82
+ const grid = el("div", "pf-view-style-grid", { role: "group", "aria-label": "Style" });
83
+ const tiles = new Map();
84
+ for (const s of STYLES) {
85
+ const t = el("button", "pf-view-style-tile", { type: "button", "aria-pressed": "false" });
86
+ t.dataset.style = s.id;
87
+ const thumb = el("span", "pf-view-style-thumb");
88
+ const img = el("img", "", { alt: "" });
89
+ img.hidden = true;
90
+ thumb.append(img);
91
+ const label = el("span", "pf-view-style-label");
92
+ label.textContent = s.label;
93
+ t.append(thumb, label);
94
+ t.addEventListener("click", () => choose(s.id));
95
+ grid.append(t);
96
+ tiles.set(s.id, { tile: t, img });
97
+ }
98
+
99
+ pop.append(styleHead, grid);
100
+ stage.append(pop);
101
+
102
+ const tooltipBinding = tooltip ? attachButtonTooltips(tooltip, [{ element: button }]) : null;
103
+
104
+ let detached = false;
105
+
106
+ // --- state -----------------------------------------------------------------
107
+ let pendingStyle = null; // a tile clicked whose switch is still loading
108
+ function render() {
109
+ const current = styleFor(viewer.getRenderMode(), viewer.getEnvironment());
110
+ for (const [id, { tile }] of tiles) {
111
+ tile.setAttribute("aria-pressed", String(id === current));
112
+ if (id === pendingStyle && id !== current) tile.dataset.busy = "true"; else delete tile.dataset.busy;
113
+ }
114
+ tooltipBinding?.sync();
115
+ }
116
+
117
+ // What persists is what the viewer says took effect (the realistic-controls
118
+ // rule this replaces): a failed load settles to CAD and CAD is saved.
119
+ async function choose(id) {
120
+ pendingStyle = id;
121
+ render();
122
+ try {
123
+ if (id === "cad") {
124
+ saveRenderMode(await viewer.setRenderMode("cad"));
125
+ } else {
126
+ saveEnvironment(await viewer.setEnvironment(id));
127
+ saveRenderMode(await viewer.setRenderMode("realistic"));
128
+ }
129
+ } catch { /* the viewer reports failures through its mode event */ }
130
+ if (pendingStyle === id) pendingStyle = null;
131
+ render();
132
+ }
133
+
134
+ // --- thumbnails (spec: live, cached; re-rendered on the next OPEN after a change)
135
+ const cache = createThumbnailCache();
136
+ function paint(id) {
137
+ const { img } = tiles.get(id);
138
+ const url = cache.get(id);
139
+ if (url) { img.src = url; img.hidden = false; } else { img.hidden = true; }
140
+ }
141
+ // A part change WHILE open (a playing animation re-shows the assembly every
142
+ // frame) does not abort the loop: it only marks the cache stale for the
143
+ // next open. Aborting on it — the first version — left every tile after the
144
+ // first empty for as long as an animation played (seen on hinged-box.html).
145
+ // A slightly older pose is fine here: a thumbnail shows the style, not a
146
+ // live mirror (spec). What does stop a loop is a close (below), detach, or a
147
+ // newer loop started by a reopen.
148
+ let loopToken = 0;
149
+ async function refreshThumbnails() {
150
+ if (!cache.isStale()) return;
151
+ cache.markFresh();
152
+ const token = ++loopToken;
153
+ let missed = false;
154
+ // One at a time: each realistic style may load an environment.
155
+ for (const s of STYLES) {
156
+ if (detached || token !== loopToken) return;
157
+ // Spec: thumbnails render on open, never while closed. A close mid-loop
158
+ // stops it here and marks the cache stale, so the next open resumes
159
+ // (re-rendering from the top; the finished ones are cheap to repeat and
160
+ // keep one freshness flag rather than per-style bookkeeping).
161
+ if (!isOpen()) { cache.invalidate(); return; }
162
+ let url = null;
163
+ try { url = await viewer.renderStyleThumbnail(s.id, { size: 256 }); } catch { url = null; }
164
+ if (detached || token !== loopToken) return;
165
+ // A failed or empty render (null) blanks its tile rather than leaving an
166
+ // older picture up, and leaves the cache stale so the next open tries
167
+ // again: caching it made one bad moment permanent until a part or theme
168
+ // change came along.
169
+ cache.set(s.id, url);
170
+ if (!url) missed = true;
171
+ paint(s.id);
172
+ }
173
+ if (missed && token === loopToken) cache.invalidate();
174
+ }
175
+
176
+ // --- open / close ------------------------------------------------------------
177
+ const isOpen = () => !pop.hidden;
178
+ const onOutside = (e) => {
179
+ if (!pop.contains(e.target) && !button.contains(e.target)) close({ focus: false });
180
+ };
181
+ // Escape is ours only while focus is in the popover or on the button: the
182
+ // listener sits on those two elements rather than on document, so it runs
183
+ // BEFORE the stage-level Escape handlers (measure, cutaway — they listen on
184
+ // the stage and #viewbar) and stopPropagation keeps them from also treating
185
+ // this key as their exit. Escape pressed anywhere else is left alone.
186
+ const onKey = (e) => {
187
+ if (e.key !== "Escape" || !isOpen()) return;
188
+ e.stopPropagation();
189
+ e.preventDefault();
190
+ close();
191
+ };
192
+ button.addEventListener("keydown", onKey);
193
+ pop.addEventListener("keydown", onKey);
194
+ // Above the reference (the toolbar pill, else the button), right edges
195
+ // aligned, 8px clear of its top, never past the stage's top — and clamped
196
+ // so it never runs off the stage's left edge either: a stage narrower than
197
+ // the popover plus the reference's inset (12px for either placement today)
198
+ // would otherwise push it out.
199
+ function place() {
200
+ const s = stage.getBoundingClientRect(), b = placeRef.getBoundingClientRect();
201
+ const width = pop.offsetWidth;
202
+ let right = Math.max(8, s.right - b.right);
203
+ if (width && s.width - right - width < 8) right = Math.max(8, s.width - width - 8);
204
+ pop.style.right = `${right}px`;
205
+ pop.style.bottom = `${Math.max(8, s.bottom - b.top + 8)}px`;
206
+ pop.style.maxHeight = `${Math.max(160, b.top - s.top - 16)}px`;
207
+ }
208
+ function open() {
209
+ if (isOpen() || button.hidden || host?.hidden) return;
210
+ // Unhidden before placing so its width can be measured (same frame: no paint between).
211
+ pop.hidden = false;
212
+ place();
213
+ button.setAttribute("aria-expanded", "true");
214
+ button.classList.add("on");
215
+ render();
216
+ document.addEventListener("pointerdown", onOutside, true);
217
+ refreshThumbnails();
218
+ }
219
+ function close({ focus = true } = {}) {
220
+ if (!isOpen()) return;
221
+ pop.hidden = true;
222
+ button.setAttribute("aria-expanded", "false");
223
+ button.classList.remove("on");
224
+ document.removeEventListener("pointerdown", onOutside, true);
225
+ if (focus) button.focus();
226
+ }
227
+ const onButton = () => (isOpen() ? close() : open());
228
+ button.addEventListener("click", onButton);
229
+
230
+ const offs = [
231
+ viewer.onRenderModeChange(() => render()),
232
+ viewer.onEnvironmentChange(() => render()),
233
+ viewer.onAssemblyChange?.(() => cache.invalidate()) ?? (() => {}),
234
+ viewer.onThemeChange?.(() => cache.invalidate()) ?? (() => {}),
235
+ // A thumbnail shows the section as it was when it was taken, so turning
236
+ // the cutaway on or off, or moving its plane, stales them all — without
237
+ // this a set taken with the cutaway on outlived the cutaway itself.
238
+ viewer.onCutawayChange?.(() => cache.invalidate()) ?? (() => {}),
239
+ ];
240
+ render();
241
+ // The popover closes whenever the button's host is hidden: #viewbar hides
242
+ // for Sketch mode; the cube's stack (fallback) for Sketch and for a crowded
243
+ // transport bar. Observing the host actually holding the button means the
244
+ // toolbar button does NOT go away when the cube hides for crowding.
245
+ //
246
+ // Two observers, because there are two ways to hide it. The `hidden`
247
+ // attribute is what this framework sets; a HOST may instead hide the bar with
248
+ // its own CSS (partforge-cloud toggles a class on #viewbar), which sets no
249
+ // attribute. That one is caught by size: a display:none element — or one
250
+ // inside a display:none ancestor — has no layout box, which a ResizeObserver
251
+ // reports as a 0x0 resize. Read off the element (getClientRects is empty for
252
+ // an element with no box) rather than the entry, so the test does not depend
253
+ // on which box the observer measured. An element that has simply not been
254
+ // laid out yet is never OPEN, so the early callback a ResizeObserver makes on
255
+ // observe() closes nothing.
256
+ const hostHidden = () => host.hidden || host.getClientRects().length === 0;
257
+ const hideObserver = host && typeof MutationObserver === "function"
258
+ ? new MutationObserver(() => { if (host.hidden) close({ focus: false }); })
259
+ : null;
260
+ hideObserver?.observe(host, { attributes: true, attributeFilter: ["hidden"] });
261
+ const sizeObserver = host && typeof ResizeObserver === "function"
262
+ ? new ResizeObserver(() => { if (isOpen() && hostHidden()) close({ focus: false }); })
263
+ : null;
264
+ sizeObserver?.observe(host);
265
+
266
+ return {
267
+ element: button,
268
+ placement: toolbar ? "toolbar" : "anchor",
269
+ popover: pop,
270
+ isOpen,
271
+ open,
272
+ close,
273
+ // The divider belongs to the button (it separates the appearance group
274
+ // the button opens), so it goes with it — else a hidden button leaves a
275
+ // stray rule at the end of the tools group.
276
+ setHidden(flag) {
277
+ if (flag) close({ focus: false });
278
+ button.hidden = !!flag;
279
+ if (divider) divider.hidden = !!flag;
280
+ },
281
+ sync: render,
282
+ detach() {
283
+ if (detached) return;
284
+ detached = true;
285
+ close({ focus: false });
286
+ button.removeEventListener("click", onButton);
287
+ button.removeEventListener("keydown", onKey);
288
+ pop.removeEventListener("keydown", onKey);
289
+ for (const off of offs) { try { off(); } catch { /* already gone */ } }
290
+ tooltipBinding?.detach();
291
+ hideObserver?.disconnect();
292
+ sizeObserver?.disconnect();
293
+ button.remove();
294
+ divider?.remove();
295
+ pop.remove();
296
+ },
297
+ };
298
+ }