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.
@@ -0,0 +1,201 @@
1
+ // The view cube's orchestrator — the only viewcube file that touches both the
2
+ // viewer and the DOM (the annotate-mode.js / measure-mode.js stance). Owns the
3
+ // frame subscription, the dirty check that keeps idle frames free, pointer
4
+ // input, and the drag/click split.
5
+ import { projectCube, hitRegion, CUBE_DOWN_BIAS_PX } from "./cube-geom.js";
6
+ import { createCubeCanvas, CUBE_SIZE, CUBE_SIZE_NARROW, CUBE_RENDER } from "./cube-canvas.js";
7
+ import { RAIL_NARROW_BREAKPOINT } from "../rail-state.js";
8
+ import { runCleanupSteps } from "../teardown.js";
9
+
10
+ // Past this many px of travel a press is an orbit, not a click. 4px is the
11
+ // usual "did they mean to drag" threshold and comfortably above the jitter a
12
+ // trackpad tap produces.
13
+ const DRAG_THRESHOLD_PX = 4;
14
+
15
+ export function createViewcubeMode(viewer, {
16
+ host,
17
+ createCanvas = createCubeCanvas,
18
+ dragThreshold = DRAG_THRESHOLD_PX,
19
+ matchMedia = (typeof window !== "undefined" && typeof window.matchMedia === "function")
20
+ ? window.matchMedia.bind(window)
21
+ : null,
22
+ } = {}) {
23
+ const wrap = document.createElement("div");
24
+ wrap.className = "pf-viewcube";
25
+ host.appendChild(wrap);
26
+
27
+ // Below the rail's narrow breakpoint the shell shows one pane at a time
28
+ // (rail.js) and the stage is a lot tighter, so the cube shrinks back to its
29
+ // previous size. This is a BREAKPOINT, not an element size, so it is a media
30
+ // query rather than a ResizeObserver — this renderer deliberately owns no
31
+ // observer of its own.
32
+ const narrowQuery = matchMedia ? matchMedia(`(max-width: ${RAIL_NARROW_BREAKPOINT}px)`) : null;
33
+ const sizeForViewport = () => (narrowQuery?.matches ? CUBE_SIZE_NARROW : CUBE_SIZE);
34
+
35
+ const canvas = createCanvas(wrap, { size: sizeForViewport() });
36
+ canvas.setTheme?.(viewer.getTheme?.() ?? "dark");
37
+
38
+ let hidden = false;
39
+ let hover = null;
40
+ let projected = null;
41
+ // The dirty check. An unchanged camera must cost nothing — no clear, no
42
+ // fills — because this runs inside the viewer's rAF callback alongside the
43
+ // cutaway's outline re-slice and the main render.
44
+ let lastKey = null;
45
+
46
+ const cameraKey = () => {
47
+ const cam = viewer.camera;
48
+ const q = cam.quaternion;
49
+ // Zoom is part of the key because an ortho dolly changes camera.zoom
50
+ // without touching the quaternion, and the cube's scale follows neither —
51
+ // but the projection SWAP repaints, and a zoom change is the cheapest
52
+ // signal that one happened.
53
+ return `${q.x.toFixed(6)},${q.y.toFixed(6)},${q.z.toFixed(6)},${q.w.toFixed(6)},${cam.isOrthographicCamera ? cam.zoom : 0}`;
54
+ };
55
+
56
+ function redraw() {
57
+ if (hidden) return;
58
+ const cam = viewer.camera;
59
+ const q = cam.quaternion;
60
+ // outerPad reserves screen-pixel room for everything cube-canvas.js draws
61
+ // past the cube's model geometry: the fixed-size arrowhead, the gap
62
+ // beyond it, and the axis label glyph (drawn centred on its anchor, so it
63
+ // still sticks out a few px past that point — one font-size's worth of
64
+ // slack comfortably covers a single uppercase character at this size).
65
+ const outerPad = CUBE_RENDER.headLengthPx + CUBE_RENDER.labelGapPx + CUBE_RENDER.labelPx;
66
+ // downBias spends part of that same reservation on ONE side, so the cube
67
+ // sits lower in its box and reads closer to the viewbar (the other half of
68
+ // that change is chrome.css's stack offset). Passed rather than defaulted
69
+ // inside projectCube because the two knobs are one budget: the bias is only
70
+ // safe against the pad this call just reserved. Hit-testing rides along for
71
+ // free — it reads the same projection.
72
+ projected = projectCube([q.x, q.y, q.z, q.w], {
73
+ size: canvas.size,
74
+ outerPad,
75
+ downBias: CUBE_DOWN_BIAS_PX,
76
+ });
77
+ canvas.draw(projected, { hover });
78
+ }
79
+
80
+ function onFrame() {
81
+ if (hidden) return;
82
+ const key = cameraKey();
83
+ if (key === lastKey) return;
84
+ lastKey = key;
85
+ redraw();
86
+ }
87
+
88
+ // --- pointer ---------------------------------------------------------------
89
+ let press = null; // { x, y, dragging, id }
90
+
91
+ const localPoint = (event) => {
92
+ const rect = canvas.element.getBoundingClientRect();
93
+ return [event.clientX - rect.left, event.clientY - rect.top];
94
+ };
95
+
96
+ const onPointerDown = (event) => {
97
+ if (event.isPrimary === false || hidden) return;
98
+ press = { x: event.clientX, y: event.clientY, dragging: false, id: event.pointerId };
99
+ canvas.element.setPointerCapture?.(event.pointerId);
100
+ };
101
+
102
+ const onPointerMove = (event) => {
103
+ if (event.isPrimary === false || hidden) return;
104
+ if (press) {
105
+ const dx = event.clientX - press.x;
106
+ const dy = event.clientY - press.y;
107
+ if (!press.dragging && Math.hypot(dx, dy) < dragThreshold) return;
108
+ press.dragging = true;
109
+ press.x = event.clientX;
110
+ press.y = event.clientY;
111
+ // Hover is meaningless mid-drag and would flicker as the cube spins.
112
+ if (hover !== null) hover = null;
113
+ viewer.orbitBy(dx, dy);
114
+ return;
115
+ }
116
+ const next = hitRegion(...localPoint(event), projected);
117
+ if (next === hover) return;
118
+ hover = next;
119
+ redraw();
120
+ };
121
+
122
+ const onPointerUp = (event) => {
123
+ if (!press || event.isPrimary === false) return;
124
+ const wasDrag = press.dragging;
125
+ canvas.element.releasePointerCapture?.(press.id);
126
+ press = null;
127
+ if (wasDrag) return;
128
+ const id = hitRegion(...localPoint(event), projected);
129
+ // refit: clicking a region is now the only reframe control the framework's
130
+ // own pages ship, so it has to actually refit — under orthographic a tween
131
+ // alone changes the angle and leaves the user's dolly in place. See
132
+ // viewer.js's tweenCameraTo.
133
+ if (id) viewer.tweenCameraTo(id, { duration: 0.6, refit: true });
134
+ };
135
+
136
+ const onPointerLeave = () => {
137
+ if (press || hover === null) return;
138
+ hover = null;
139
+ redraw();
140
+ };
141
+
142
+ canvas.element.addEventListener("pointerdown", onPointerDown);
143
+ canvas.element.addEventListener("pointermove", onPointerMove);
144
+ canvas.element.addEventListener("pointerup", onPointerUp);
145
+ canvas.element.addEventListener("pointercancel", onPointerUp);
146
+ canvas.element.addEventListener("pointerleave", onPointerLeave);
147
+
148
+ const offFrame = viewer.onFrame(onFrame);
149
+ const offTheme = viewer.onThemeChange((mode) => {
150
+ canvas.setTheme(mode);
151
+ redraw();
152
+ });
153
+
154
+ const onNarrowChange = () => {
155
+ canvas.setSize(sizeForViewport());
156
+ // The projection itself is size-dependent (the scale), so the dirty-check
157
+ // key alone won't force a real reproject here — clear it and redraw.
158
+ lastKey = null;
159
+ redraw();
160
+ };
161
+ narrowQuery?.addEventListener?.("change", onNarrowChange);
162
+
163
+ lastKey = cameraKey();
164
+ redraw(); // never show a blank box before the first camera movement
165
+
166
+ function setHidden(flag) {
167
+ const next = !!flag;
168
+ if (next === hidden) return;
169
+ hidden = next;
170
+ wrap.hidden = hidden;
171
+ if (!hidden) {
172
+ // The camera almost certainly moved while we were away, and the dirty
173
+ // check would otherwise hold the stale drawing until it moves again.
174
+ lastKey = cameraKey();
175
+ redraw();
176
+ }
177
+ }
178
+
179
+ let detached = false;
180
+ return {
181
+ element: wrap,
182
+ setHidden,
183
+ isHidden: () => hidden,
184
+ detach() {
185
+ if (detached) return;
186
+ detached = true;
187
+ runCleanupSteps([
188
+ offFrame,
189
+ offTheme,
190
+ () => narrowQuery?.removeEventListener?.("change", onNarrowChange),
191
+ () => canvas.element.removeEventListener("pointerdown", onPointerDown),
192
+ () => canvas.element.removeEventListener("pointermove", onPointerMove),
193
+ () => canvas.element.removeEventListener("pointerup", onPointerUp),
194
+ () => canvas.element.removeEventListener("pointercancel", onPointerUp),
195
+ () => canvas.element.removeEventListener("pointerleave", onPointerLeave),
196
+ () => canvas.dispose(),
197
+ () => wrap.remove(),
198
+ ], "viewcube mode cleanup failed");
199
+ },
200
+ };
201
+ }