partforge 0.72.0 → 0.73.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.
@@ -225,9 +225,119 @@
225
225
  (background/border/radius/shadow) from app.css, so that chrome must live in
226
226
  app.css ungated — not be duplicated into a class the cloud never sets. This
227
227
  file owns where things sit; app.css owns what they look like. */
228
- .pf-float-tabs, .pf-float-viewbar { position: absolute; z-index: 15; }
229
- .pf-float-tabs { top: 12px; left: 50%; transform: translateX(-50%); }
230
- .pf-float-viewbar { bottom: 12px; right: 12px; }
228
+ .pf-float-tabs, .pf-float-viewbar, .pf-float-rail-toggle { position: absolute; z-index: 15; }
229
+ /* Every inset below is SHARED with whichever other float sits on that edge, so
230
+ the stage's margin is stated once per edge and not once per element. The rail
231
+ toggle (2026-08-20: out of #viewbar's pill, floating on its own at the top
232
+ right) is the tab strip's `top` with the viewbar's `right` — a corner
233
+ symmetric with the viewbar's, by construction rather than by two numbers that
234
+ happen to agree today. Retune the margin and all three corners follow.
235
+ Appearance, including the `[hidden]` guard that lets rail.js hide the toggle
236
+ below the narrow breakpoint, is in app.css. */
237
+ .pf-float-tabs, .pf-float-rail-toggle { top: 12px; }
238
+ .pf-float-viewbar, .pf-float-rail-toggle { right: 12px; }
239
+ .pf-float-tabs { left: 50%; transform: translateX(-50%); }
240
+ .pf-float-viewbar { bottom: 12px; }
241
+
242
+ /* ---- view cube stack: PLACEMENT ONLY (see the rule above) ----------------
243
+ Bottom-right, stacked above #viewbar. The offset is measured, not
244
+ hardcoded: mount.js publishes --pf-viewbar-clear on the stage from a
245
+ ResizeObserver on #viewbar, mirroring --pf-anim-clear. It sits there rather
246
+ than in viewcube-controls.js (the precedent being animation-controls.js,
247
+ which publishes its own --pf-anim-clear) because the value describes the
248
+ VIEWBAR's vertical claim, not the cube's, and mount owns both elements. The
249
+ 56px fallback is the standard viewbar's 12px bottom + 44px height, so the
250
+ stack still sits right if the observer never fires.
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.
270
+
271
+ The 8px that used to sit between the stack and the viewbar is now 3px
272
+ (2026-08-20: "lower the whole box so it's closer to the bottom bar"). 3
273
+ rather than 0 because the stack's own box MUST NOT overlap #viewbar: the
274
+ stack is a later sibling at the same z-index, and its canvas is
275
+ pointer-events:auto across its whole footprint, so any vertical overlap
276
+ would silently swallow clicks on the viewbar's rightmost buttons (both are
277
+ right: 12px). --pf-viewbar-clear is published rounded to whole px, so a
278
+ fractional viewbar height can move this by half a pixel either way; 3px
279
+ absorbs that and still reads as a deliberate gap rather than a collision.
280
+ The other ~5px of the lowering comes from inside the canvas — see
281
+ cube-geom.js's CUBE_DOWN_BIAS_PX; that is where the rest of the perceived
282
+ gap actually lives, and it is capped by the axis labels' clearance, so
283
+ this pair (3 + 5) is the whole slack that exists without either overlapping
284
+ the viewbar or shrinking the cube.
285
+
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
289
+ declaration stays because the stack's own box is still an element over the
290
+ viewer, and a stray hit on it (its padding-free edges, or a future child
291
+ before it opts in) should reach the model behind rather than be swallowed. */
292
+ .pf-viewcube-stack {
293
+ position: absolute;
294
+ right: 12px;
295
+ bottom: calc(var(--pf-viewbar-clear, 56px) + 3px);
296
+ z-index: 15;
297
+ display: flex;
298
+ pointer-events: none;
299
+ }
300
+ .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; }
305
+
306
+ /* The keyboard surface: six per-view buttons standing in for the DOM focus a
307
+ canvas cannot give us. Visually hidden rather than display:none — the latter
308
+ takes them out of the tab order, which is the whole point of them.
309
+
310
+ The hiding properties are on the BUTTONS, not on their wrapper, so that
311
+ :focus-visible can undo them. A non-`none` clip-path clips the element's
312
+ whole SUBTREE and makes the element a containing block for fixed-position
313
+ descendants, so with the clip on the wrapper no rule on a focused child
314
+ could escape it — the reveal below was dead CSS, and six buttons sat in the
315
+ tab order with no visible focus indicator at all. On the buttons themselves
316
+ there is no clipping ancestor to get out of.
317
+
318
+ The wrapper keeps only `position: absolute`, which takes it out of the
319
+ stack's flex flow. That mattered when the stack had a gap (a zero-height
320
+ flex item still earned it, pushing the cube out of place); it still matters
321
+ now that it does not, because an in-flow second item would widen the stack
322
+ past the canvas — and the stack's width is what the crowding rule reads. */
323
+ .pf-viewcube-key { position: absolute; }
324
+ .pf-viewcube-key button {
325
+ position: absolute;
326
+ width: 1px; height: 1px;
327
+ margin: -1px; padding: 0;
328
+ overflow: hidden;
329
+ clip-path: inset(50%);
330
+ white-space: nowrap;
331
+ border: 0;
332
+ }
333
+ .pf-viewcube-key button:focus-visible {
334
+ position: fixed;
335
+ width: auto; height: auto;
336
+ margin: 0; padding: 2px 6px;
337
+ overflow: visible;
338
+ clip-path: none;
339
+ pointer-events: auto; /* the stack is pointer-transparent; a revealed control is not */
340
+ }
231
341
 
232
342
  /* --- animation transport bar (generated by animation-controls.js) ----------
233
343
  PLACEMENT ONLY, per the rule above; appearance lives in app.css next to
@@ -44,7 +44,7 @@ const WHITE = new THREE.Color(0xffffff);
44
44
  export function createCutawayGizmo({
45
45
  scene,
46
46
  overlayScene,
47
- camera,
47
+ camera: initialCamera,
48
48
  domElement,
49
49
  orbitControls,
50
50
  onPoseChange = () => {},
@@ -53,6 +53,10 @@ export function createCutawayGizmo({
53
53
  onDragChange = () => {},
54
54
  pickHandle,
55
55
  }) {
56
+ // Reassignable: the viewer swaps cameras when the projection toggle flips,
57
+ // and this module holds fifteen references to it. One binding to move beats
58
+ // threading a getter through all of them.
59
+ let camera = initialCamera;
56
60
  const sceneGraph = buildGizmoScene(THEMES.dark);
57
61
  const {
58
62
  group,
@@ -535,6 +539,7 @@ export function createCutawayGizmo({
535
539
  setActiveAppearance,
536
540
  setTheme,
537
541
  updateForCamera,
542
+ setCamera(next) { if (next) camera = next; },
538
543
  dispose,
539
544
  };
540
545
  }
@@ -32,7 +32,7 @@ function validBounds(getBounds) {
32
32
  export function createCutaway({
33
33
  renderer,
34
34
  scene,
35
- camera,
35
+ camera: initialCamera,
36
36
  orbitControls,
37
37
  domElement,
38
38
  getBounds,
@@ -40,6 +40,9 @@ export function createCutaway({
40
40
  schedule = defaultSchedule,
41
41
  now,
42
42
  }) {
43
+ // Reassignable: a projection swap (perspective <-> ortho) hands the cutaway
44
+ // a new camera after construction, and the gizmo must follow it too.
45
+ let camera = initialCamera;
43
46
  let supported = false;
44
47
  try {
45
48
  supported = Boolean(
@@ -522,6 +525,11 @@ export function createCutaway({
522
525
  updateForCamera,
523
526
  renderOverlay,
524
527
  onHandleHoverChange,
528
+ setCamera(next) {
529
+ if (!next) return;
530
+ camera = next;
531
+ gizmo?.setCamera(next);
532
+ },
525
533
  dispose,
526
534
  _renderSetFor: (name) => renderSets.get(name)?.renderSet ?? null,
527
535
  _setDragging: setDragging,
@@ -58,6 +58,13 @@ export function worldPerPx(dist, fovDeg, viewportPx) {
58
58
  return (2 * dist * Math.tan((fovDeg * Math.PI) / 360)) / viewportPx;
59
59
  }
60
60
 
61
+ // The orthographic twin of worldPerPx. An ortho camera's scale is a property of
62
+ // its frustum and zoom alone — distance does not enter — which is exactly why
63
+ // the perspective formula cannot be reused with a substituted fov.
64
+ export function orthoWorldPerPx(top, bottom, zoom, viewportPx) {
65
+ return Math.abs(top - bottom) / Math.max(zoom, 1e-6) / Math.max(viewportPx, 1);
66
+ }
67
+
61
68
  // Kept for compatibility with earlier callers/tests: the world height that
62
69
  // renders as `targetPx` on screen.
63
70
  export function labelWorldHeight(dist, fovDeg, viewportPx, targetPx = LABEL_SCREEN_PX) {
@@ -319,8 +326,14 @@ export function createDimScene(viewer, { paintLabel = defaultPaintLabel } = {})
319
326
  // One shared reference distance — camera to the dim group's origin (the
320
327
  // recentred model centre) — sizes the whole drawing.
321
328
  group.getWorldPosition(_gp);
322
- const dist = viewer.camera.position.distanceTo(_gp);
323
- const wpp = worldPerPx(dist, viewer.camera.fov ?? 45, h);
329
+ // `viewer.camera.fov ?? 45` was the bug this branch removes: under an ortho
330
+ // camera fov is undefined, so the fallback produced a plausible-but-wrong
331
+ // scale and every label, arrow and standoff drifted as the user dollied.
332
+ // The ortho formula matches cutaway-gizmo.js:485's worldUnitsPerPixelAt.
333
+ const cam = viewer.camera;
334
+ const wpp = cam.isOrthographicCamera
335
+ ? orthoWorldPerPx(cam.top, cam.bottom, cam.zoom, h)
336
+ : worldPerPx(cam.position.distanceTo(_gp), cam.fov ?? 45, h);
324
337
  if (wpp > 0) {
325
338
  const hStar = LABEL_SCREEN_PX * wpp;
326
339
  const aw = ARROW_SCREEN_PX * wpp;
@@ -6,7 +6,7 @@ import { attachCutawayControls } from "./cutaway-controls.js";
6
6
  import { attachRail } from "./rail.js";
7
7
  import { attachMobileTabs } from "./mobile-tabs.js";
8
8
  import { createTooltipPresenter, attachButtonTooltips } from "./tooltip.js";
9
- import { loadCamera } from "./view-state.js";
9
+ import { loadCamera, loadProjection, saveProjection } from "./view-state.js";
10
10
  import { buildControls } from "./controls.js";
11
11
  import { relevantParamKeys } from "./param-deps.js";
12
12
  import { createMeshCache } from "./mesh-cache.js";
@@ -30,6 +30,7 @@ import { createMeasureMode } from "./measure/measure-mode.js";
30
30
  import { attachMeasureControls } from "./measure/measure-controls.js";
31
31
  import { createAnnotateMode } from "./annotate/annotate-mode.js";
32
32
  import { attachAnnotateControls } from "./annotate/annotate-controls.js";
33
+ import { attachViewcubeControls } from "./viewcube/viewcube-controls.js";
33
34
 
34
35
  // The mount handle, factored out so its shape is unit-testable without booting
35
36
  // the full mount() pipeline (WASM + workers + DOM).
@@ -59,7 +60,7 @@ const IMPORT_MESH_BROKEN_MESSAGE = "STEP import tessellation failed to satisfy t
59
60
  // carries the worker's own error text. See the correlated "error" case below.
60
61
  const importTessellateFailedMessage = (workerMessage) => `STEP import tessellation failed — ${workerMessage}`;
61
62
 
62
- export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, setHostPane, animation, getView, setView, captureView, attachTooltips, measure, annotate }) {
63
+ export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, setHostPane, animation, getView, setView, captureView, attachTooltips, measure, annotate, projection }) {
63
64
  return {
64
65
  ready, dispose, setParams,
65
66
  // Part-declared animation playback (spec 2026-08-02): animations are
@@ -106,6 +107,13 @@ export function makeHandle({ ready, dispose, viewer, setParams, listExportablePa
106
107
  // the built-in pencil button. send() delivers to onAnnotationSend and
107
108
  // returns false when there is no ink or the capture failed.
108
109
  annotate: annotate ?? NOOP_ANNOTATE,
110
+ // Projection is a viewer-wide display mode, not a part property — same
111
+ // shape as `measure` and `annotate` so a host reads one convention.
112
+ projection: projection ?? {
113
+ get: () => "perspective",
114
+ set: () => {},
115
+ onChange: () => () => {},
116
+ },
109
117
  };
110
118
  }
111
119
 
@@ -203,6 +211,10 @@ function createCleanupStack() {
203
211
  // // subscribes return an unsubscribe; onInkChange fires on
204
212
  // // every stroke/undo/clear, which is what a host driving its
205
213
  // // own Send button gates that button on (strokeCount() > 0).
214
+ // runtime.projection: { get, set, onChange }
215
+ // // "perspective" | "orthographic". Drives the LIVE view
216
+ // // and captureCurrent only — captureCanonicalViews,
217
+ // // renderMeshPayloads and the CLI stay perspective.
206
218
  // runtime.dispose(); // full teardown
207
219
  // onBuild fires per completed build, so it does NOT fire for a pose-only edit —
208
220
  // those are repaired in the viewer and produce no build at all.
@@ -412,6 +424,55 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
412
424
  annotate: els.chrome.annotate,
413
425
  }, { tooltip, escapeScope: els.viewer, send: annotateSend });
414
426
  cleanup.defer(() => annotateChrome.detach());
427
+ // Orientation cube + projection toggle. Generated chrome — no host markup
428
+ // declares it, so an embedder gets it for free. Restored BEFORE any framing
429
+ // happens so a reload into ortho frames once instead of framing in
430
+ // perspective and then visibly re-framing.
431
+ viewer.setProjection(loadProjection());
432
+ const viewcube = attachViewcubeControls(viewer, { stage: els.viewer }, { tooltip });
433
+ cleanup.defer(() => viewcube.detach());
434
+ cleanup.defer(viewer.onProjectionChange((mode) => saveProjection(mode)));
435
+ // setHidden takes one boolean, and there are two independent reasons to hide
436
+ // the cube: Sketch mode (below) and a crowded transport bar (wired into the
437
+ // animation controls further down). Applied straight, whichever fires last
438
+ // would win — leaving Sketch would reveal a cube that crowding still wants
439
+ // gone. Track a flag per reason and OR them through one place, the
440
+ // syncHoverSuppression precedent below.
441
+ let cubeHiddenForSketch = false;
442
+ let cubeHiddenForCrowding = false;
443
+ const syncViewcubeHidden = () =>
444
+ viewcube.setHidden(cubeHiddenForSketch || cubeHiddenForCrowding);
445
+ // Sketch freezes the view on purpose: ink is stored in screen space and is
446
+ // meaningful only against the pose it was drawn over. A live camera control
447
+ // on top of that — orbit OR a projection swap — invalidates the drawing.
448
+ if (annotateMode) {
449
+ cleanup.defer(annotateMode.onModeChange(() => {
450
+ cubeHiddenForSketch = annotateMode.isEnabled();
451
+ syncViewcubeHidden();
452
+ }));
453
+ }
454
+ // Publish the viewbar's vertical claim so chrome.css can stack the cube on
455
+ // top of it without hardcoding a height that cutaway/measure/annotate
456
+ // action rows can change.
457
+ const viewbarEl = els.viewer.querySelector("#viewbar");
458
+ if (viewbarEl && typeof ResizeObserver === "function") {
459
+ const publishViewbarClear = () => {
460
+ const stageRect = els.viewer.getBoundingClientRect();
461
+ const barRect = viewbarEl.getBoundingClientRect();
462
+ els.viewer.style.setProperty(
463
+ "--pf-viewbar-clear",
464
+ `${Math.max(0, Math.round(stageRect.bottom - barRect.top))}px`,
465
+ );
466
+ };
467
+ const viewbarObserver = new ResizeObserver(publishViewbarClear);
468
+ viewbarObserver.observe(viewbarEl);
469
+ viewbarObserver.observe(els.viewer);
470
+ publishViewbarClear();
471
+ cleanup.defer(() => {
472
+ viewbarObserver.disconnect();
473
+ els.viewer.style.removeProperty("--pf-viewbar-clear");
474
+ });
475
+ }
415
476
  // escapeScope: cutaway's Flip/Reset buttons are canvas SIBLINGS inside
416
477
  // #viewbar, not descendants of the canvas — attaching Escape to
417
478
  // viewer.domElement alone would leave a guarded Escape from those buttons
@@ -836,6 +897,17 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
836
897
  applyValues: applyAnimationValues,
837
898
  getParamValues: (keys) => Object.fromEntries(keys.map((k) => [k, params[k]])),
838
899
  getView: view,
900
+ // On a narrow stage the transport bar has to cap its own width to stay
901
+ // clear of the bottom-right cluster, and under that cap its controls fall
902
+ // below the 44px tap target. The cube gives way instead — it is the
903
+ // reclaimable half of that cluster. Nothing here keys on the viewport
904
+ // width: a part with no animations, or one whose bar fits, keeps its cube
905
+ // at every size. syncViewcubeHidden and the viewcube itself are both
906
+ // declared above this point, so this can never fire into a hole.
907
+ onCrowded: (crowded) => {
908
+ cubeHiddenForCrowding = crowded;
909
+ syncViewcubeHidden();
910
+ },
839
911
  });
840
912
  if (animCtl) cleanup.defer(() => animCtl.detach());
841
913
 
@@ -944,6 +1016,11 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
944
1016
  onInkChange: annotateMode.onInkChange,
945
1017
  onModeChange: annotateMode.onModeChange,
946
1018
  } : null,
1019
+ projection: {
1020
+ get: () => viewer.getProjection(),
1021
+ set: (mode) => viewer.setProjection(mode),
1022
+ onChange: (cb) => viewer.onProjectionChange(cb),
1023
+ },
947
1024
  });
948
1025
  } catch (error) {
949
1026
  try {
@@ -0,0 +1,19 @@
1
+ // The perspective <-> orthographic framing pair. Pure, so the swap's only
2
+ // interesting property — that the part does not change size the instant the
3
+ // user hits the toggle — is unit-testable without a renderer.
4
+ //
5
+ // Perspective frames by DISTANCE; orthographic frames by a frustum height plus
6
+ // a zoom (OrbitControls dollies an ortho camera by changing camera.zoom, not by
7
+ // moving it). These two functions convert between the two descriptions.
8
+
9
+ const halfHeightAt = (fovDeg, distance) => distance * Math.tan((fovDeg * Math.PI) / 360);
10
+
11
+ export function orthoFrustum({ fovDeg, distance, aspect = 1 }) {
12
+ const halfH = halfHeightAt(fovDeg, distance);
13
+ const halfW = halfH * aspect;
14
+ return { halfW, halfH, left: -halfW, right: halfW, top: halfH, bottom: -halfH };
15
+ }
16
+
17
+ export function perspectiveDistance({ halfH, zoom = 1, fovDeg }) {
18
+ return halfH / (zoom * Math.tan((fovDeg * Math.PI) / 360));
19
+ }
@@ -15,13 +15,81 @@ const DIRS = {
15
15
  right: { dir: [1, 0, 0], up: [0, 1, 0] },
16
16
  };
17
17
 
18
+ // The view cube's 26 orientations: 6 faces, 12 edges, 8 corners. Deliberately
19
+ // SEPARATE from CANONICAL_VIEWS, which stays at 7 — captureViewsFromScene
20
+ // slices against its length and the CLI names it, so growing that list would
21
+ // change contracts the cube has no business touching. The seven canonical
22
+ // names resolve to identical poses (iso === top-front-right).
23
+ //
24
+ // Face names are MODEL-frame (parts are authored Z-up); the world directions
25
+ // below already carry the pivot's rotation.x = -PI/2, which maps model
26
+ // (x, y, z) -> world (x, z, -y).
27
+ const FACE_DIRS = {
28
+ right: [1, 0, 0], // model +X
29
+ left: [-1, 0, 0], // model -X
30
+ top: [0, 1, 0], // model +Z
31
+ bottom: [0, -1, 0], // model -Z
32
+ front: [0, 0, 1], // model -Y
33
+ back: [0, 0, -1], // model +Y
34
+ };
35
+
36
+ // Canonical id ordering. A compound id always reads vertical, then depth, then
37
+ // side — "top-front-right", never "right-front-top" — so cube-geom.js can
38
+ // assemble an id from three independent axis choices and land on the same
39
+ // string every time.
40
+ const VERTICAL = ["top", "bottom"];
41
+ const DEPTH = ["front", "back"];
42
+ const SIDE = ["left", "right"];
43
+
44
+ // A pure top or bottom view is degenerate against a +Y up vector, so those two
45
+ // keep the special-cased ups DIRS already used. Every compound orientation has
46
+ // a well-defined +Y up.
47
+ //
48
+ // These ups matter to the OFFSCREEN capture path, which builds a temp camera and
49
+ // calls lookAt itself with no orbit frame to fall back on. The LIVE camera never
50
+ // needs them: it is driven through OrbitControls, whose polar frame derives the
51
+ // same roll on its own at azimuth 0 (a top cue lands with screen-up on world -Z,
52
+ // which is exactly [0, 0, -1]). Handing the live camera a non-+Y `up` would also
53
+ // re-base every subsequent orbit drag and would not survive getCameraState, so it
54
+ // deliberately keeps the default.
55
+ function upFor(parts) {
56
+ if (parts.length === 1 && parts[0] === "top") return [0, 0, -1];
57
+ if (parts.length === 1 && parts[0] === "bottom") return [0, 0, 1];
58
+ return [0, 1, 0];
59
+ }
60
+
61
+ function buildOrientations() {
62
+ const out = {};
63
+ const add = (parts) => {
64
+ const dir = [0, 0, 0];
65
+ for (const part of parts) {
66
+ const d = FACE_DIRS[part];
67
+ dir[0] += d[0];
68
+ dir[1] += d[1];
69
+ dir[2] += d[2];
70
+ }
71
+ const id = parts.join("-");
72
+ out[id] = { id, parts: [...parts], dir, up: upFor(parts) };
73
+ };
74
+ for (const face of Object.keys(FACE_DIRS)) add([face]);
75
+ for (const v of VERTICAL) for (const other of [...DEPTH, ...SIDE]) add([v, other]);
76
+ for (const d of DEPTH) for (const s of SIDE) add([d, s]);
77
+ for (const v of VERTICAL) for (const d of DEPTH) for (const s of SIDE) add([v, d, s]);
78
+ return out;
79
+ }
80
+
81
+ export const ORIENTATIONS = buildOrientations();
82
+ export const ORIENTATION_IDS = Object.keys(ORIENTATIONS);
83
+
18
84
  const norm = (v) => {
19
85
  const l = Math.hypot(v[0], v[1], v[2]) || 1;
20
86
  return [v[0] / l, v[1] / l, v[2] / l];
21
87
  };
22
88
 
23
89
  export function cameraPoseForView(view, { center, radius }) {
24
- const a = DIRS[view];
90
+ // DIRS first so the seven canonical names keep their exact existing poses;
91
+ // ORIENTATIONS covers the other nineteen the cube can reach.
92
+ const a = DIRS[view] ?? ORIENTATIONS[view];
25
93
  if (!a) throw new Error(`unknown canonical view "${view}"`);
26
94
  const d = norm(a.dir);
27
95
  const dist = radius * 2.6 + 6; // matches viewer.frameTo's framing distance
@@ -11,6 +11,7 @@
11
11
  const KEY = {
12
12
  camera: "partforge:camera",
13
13
  theme: "partforge:theme",
14
+ projection: "partforge:projection",
14
15
  };
15
16
 
16
17
  const viewKey = (partKey) => `partforge:view:${partKey}`;
@@ -55,6 +56,14 @@ export function saveTheme(mode) {
55
56
  if (mode === "light" || mode === "dark") write(KEY.theme, mode);
56
57
  }
57
58
 
59
+ export function loadProjection() {
60
+ return read(KEY.projection) === "orthographic" ? "orthographic" : "perspective";
61
+ }
62
+
63
+ export function saveProjection(mode) {
64
+ if (mode === "perspective" || mode === "orthographic") write(KEY.projection, mode);
65
+ }
66
+
58
67
  // `partKey` identifies the part — createViewTabs passes `meta.title`. Without one
59
68
  // there is nothing safe to key on, so both calls no-op rather than falling back to a
60
69
  // shared key (the cross-part bleed this scoping exists to remove).