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 +3 -2
- package/docs/AUTHORING-PARTS.md +72 -36
- package/package.json +1 -1
- package/src/framework/annotate/annotate-mode.js +6 -1
- package/src/framework/app.css +69 -57
- package/src/framework/chrome.css +38 -25
- package/src/framework/cutaway-gizmo.js +2 -1
- package/src/framework/cutaway.js +33 -0
- package/src/framework/geometry/threemf.js +3 -2
- package/src/framework/lint/rules-materials.js +3 -3
- package/src/framework/materials/assets/SOURCES.md +1 -1
- package/src/framework/materials/assets/ground-pei.jpg +0 -0
- package/src/framework/materials/contact-shadow.js +28 -1
- package/src/framework/materials/environment.js +16 -4
- package/src/framework/materials/patterns.js +58 -7
- package/src/framework/materials/physical.js +1 -0
- package/src/framework/materials/presets.js +9 -9
- package/src/framework/materials/print-bed.js +17 -0
- package/src/framework/materials/resolve.js +38 -14
- package/src/framework/mount.js +111 -44
- package/src/framework/projection.js +48 -1
- package/src/framework/view-state.js +4 -10
- package/src/framework/view-style-controls.js +298 -0
- package/src/framework/view-style-state.js +27 -0
- package/src/framework/viewcube/viewcube-controls.js +23 -64
- package/src/framework/viewcube/viewcube-mode.js +5 -3
- package/src/framework/viewer.js +637 -51
- package/src/parts/material-swatches.js +8 -1
- package/types/index.d.ts +14 -8
- package/src/framework/realistic-controls.js +0 -78
package/src/framework/mount.js
CHANGED
|
@@ -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,
|
|
11
|
-
import {
|
|
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
|
|
204
|
-
// realistic
|
|
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".
|
|
319
|
-
// //
|
|
320
|
-
// //
|
|
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
|
|
362
|
-
// // environment
|
|
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
|
-
//
|
|
394
|
-
// //
|
|
395
|
-
// //
|
|
396
|
-
// //
|
|
397
|
-
// //
|
|
398
|
-
// //
|
|
399
|
-
// //
|
|
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
|
|
650
|
-
// declares it, so an embedder gets it for free.
|
|
651
|
-
// happens so a
|
|
652
|
-
// perspective and then visibly re-framing.
|
|
653
|
-
//
|
|
654
|
-
//
|
|
655
|
-
|
|
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 }
|
|
704
|
+
const viewcube = attachViewcubeControls(viewer, { stage: els.viewer });
|
|
675
705
|
cleanup.defer(() => viewcube.detach());
|
|
676
|
-
|
|
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
|
|
874
|
-
//
|
|
875
|
-
// persisted pose is only written at
|
|
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.
|
|
895
|
-
//
|
|
896
|
-
//
|
|
897
|
-
//
|
|
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
|
-
|
|
906
|
-
|
|
907
|
-
|
|
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
|
-
//
|
|
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.
|
|
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
|
+
}
|