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.
- package/docs/AUTHORING-PARTS.md +47 -2
- package/package.json +1 -1
- package/src/framework/animation-controls.js +171 -16
- package/src/framework/annotate/annotate-mode.js +31 -3
- package/src/framework/app.css +122 -4
- package/src/framework/camera-orbit.js +84 -0
- package/src/framework/camera-tween.js +22 -10
- package/src/framework/chrome.css +113 -3
- package/src/framework/cutaway-gizmo.js +6 -1
- package/src/framework/cutaway.js +9 -1
- package/src/framework/measure/dim3-scene.js +15 -2
- package/src/framework/mount.js +79 -2
- package/src/framework/projection.js +19 -0
- package/src/framework/view-angles.js +69 -1
- package/src/framework/view-state.js +9 -0
- package/src/framework/viewcube/cube-canvas.js +410 -0
- package/src/framework/viewcube/cube-geom.js +367 -0
- package/src/framework/viewcube/viewcube-controls.js +157 -0
- package/src/framework/viewcube/viewcube-mode.js +201 -0
- package/src/framework/viewer.js +289 -23
package/src/framework/chrome.css
CHANGED
|
@@ -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
|
-
|
|
230
|
-
|
|
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
|
}
|
package/src/framework/cutaway.js
CHANGED
|
@@ -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
|
-
|
|
323
|
-
|
|
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;
|
package/src/framework/mount.js
CHANGED
|
@@ -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
|
-
|
|
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).
|