partforge 0.71.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 +71 -3
- 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/jobs.js +22 -5
- package/src/framework/measure/dim3-scene.js +15 -2
- package/src/framework/mount.js +79 -2
- package/src/framework/oracle/gates.js +50 -0
- package/src/framework/oracle/measure.js +21 -4
- package/src/framework/oracle/min-wall.js +17 -0
- package/src/framework/oracle/verify.js +63 -22
- 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
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
// The view cube's geometry, hit model, and projection — the pure leaf of the
|
|
2
|
+
// viewcube trio (cf. ink.js under annotate/, dim3-place.js under measure/).
|
|
3
|
+
// No DOM, no three, no node:. Everything here is plain numbers so the whole
|
|
4
|
+
// widget's correctness is testable without a GL context.
|
|
5
|
+
//
|
|
6
|
+
// The cube is modelled as 6 faces x a 3x3 grid = 54 surface cells. The centre
|
|
7
|
+
// cell of a face IS that face; the 4 edge cells belong to the 12 edges (each
|
|
8
|
+
// edge shows up on 2 faces); the 4 corner cells belong to the 8 corners (each
|
|
9
|
+
// corner shows up on 3 faces). 6 + 24 + 24 = 54 cells over 26 ids.
|
|
10
|
+
//
|
|
11
|
+
// Coordinates are MODEL space (parts are authored Z-up), so a cell's id can be
|
|
12
|
+
// assembled directly from the model axes it touches. PIVOT_QUAT carries the
|
|
13
|
+
// viewer's fixed pivot (rotation.x = -PI/2) so the drawing always agrees with
|
|
14
|
+
// the part on screen.
|
|
15
|
+
|
|
16
|
+
// Tuning block. Locked by the look-and-feel spike (see the plan's Task 4);
|
|
17
|
+
// every visual proportion the widget has lives here and nowhere else.
|
|
18
|
+
//
|
|
19
|
+
// The reshape (2026-08-19) moved the axis arrows off the cube's centre and
|
|
20
|
+
// onto the edge running from the corner where +X/+Y/+Z originate — model
|
|
21
|
+
// corner (-1,-1,-1) — to the far face. A second pass then moved the
|
|
22
|
+
// arrowHEAD and its label off model space entirely: the shaft now ends
|
|
23
|
+
// exactly ON the far face (model coordinate 1, no overshoot), and the head +
|
|
24
|
+
// label are built in fixed SCREEN pixels by cube-canvas.js so they read the
|
|
25
|
+
// same size at every rotation (see CUBE_RENDER's headLengthPx/headHalfWidthPx
|
|
26
|
+
// /labelGapPx). That leaves `faceHalf` as the only model-space proportion left
|
|
27
|
+
// to tune here — the old `arrowOvershoot`/`labelOffset`/`tailFraction` no
|
|
28
|
+
// longer have anything to mean and are gone.
|
|
29
|
+
export const CUBE_CONSTANTS = {
|
|
30
|
+
faceHalf: 0.62, // half-width of the centre (face) cell, cube half-extent = 1
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// How far DOWN its canvas the drawing sits, in CSS px — the second half of the
|
|
34
|
+
// 2026-08-20 "lower the cube toward the viewbar" change (the first half was
|
|
35
|
+
// chrome.css taking most of the 8px the stack cleared #viewbar by). Kept out of
|
|
36
|
+
// CUBE_CONSTANTS on purpose: everything in there is a model-space proportion,
|
|
37
|
+
// and this is pixels.
|
|
38
|
+
//
|
|
39
|
+
// It buys room from the padding the cube already leaves inside its box, so its
|
|
40
|
+
// ceiling is arithmetic, not taste. `projectCube` scales the cube to
|
|
41
|
+
// (size/2 - outerPad) of reach from the box centre, and the renderer then paints
|
|
42
|
+
// headLengthPx + labelGapPx + the axis glyph past that — so the only slack that
|
|
43
|
+
// exists is `labelPx` minus how far the glyph actually paints past its anchor
|
|
44
|
+
// (about 3.2px down for `600 10px ui-sans-serif`), i.e. ~6.8px. 5 leaves nearly
|
|
45
|
+
// 2px of that unspent, which is the margin for a platform whose system font is
|
|
46
|
+
// heavier than Chromium's. Spending more clips an axis label whenever that axis
|
|
47
|
+
// points straight down the screen — see viewcube-mode.test.js's orientation
|
|
48
|
+
// sweep, which is the guard, and note that raising this WILL fail it.
|
|
49
|
+
//
|
|
50
|
+
// The cube is NOT re-centred as it turns (a bias that tracked the projected
|
|
51
|
+
// bounding box would win another ~14px at most poses): the cube would then bob
|
|
52
|
+
// up and down as you orbit, which is a worse artefact than a smaller gap.
|
|
53
|
+
export const CUBE_DOWN_BIAS_PX = 5;
|
|
54
|
+
|
|
55
|
+
// The viewer's pivot is rotation.x = -PI/2: quaternion (sin(-PI/4), 0, 0, cos(-PI/4)).
|
|
56
|
+
const HALF_SQRT2 = Math.SQRT1_2;
|
|
57
|
+
export const PIVOT_QUAT = [-HALF_SQRT2, 0, 0, HALF_SQRT2];
|
|
58
|
+
|
|
59
|
+
// Model axis -> face name for each sign. Mirrors FACE_DIRS in view-angles.js;
|
|
60
|
+
// kept here as the model-space twin so this module needs no import.
|
|
61
|
+
const AXIS_FACE = {
|
|
62
|
+
x: { 1: "right", "-1": "left" },
|
|
63
|
+
y: { 1: "back", "-1": "front" },
|
|
64
|
+
z: { 1: "top", "-1": "bottom" },
|
|
65
|
+
};
|
|
66
|
+
const AXIS_INDEX = { x: 0, y: 1, z: 2 };
|
|
67
|
+
|
|
68
|
+
// The reverse of AXIS_FACE: face name -> the axis and sign it stands for.
|
|
69
|
+
const FACE_AXIS = {};
|
|
70
|
+
for (const axis of ["x", "y", "z"]) {
|
|
71
|
+
for (const sign of [1, -1]) FACE_AXIS[AXIS_FACE[axis][String(sign)]] = { axis, sign };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Canonical id ordering: vertical, then depth, then side (see view-angles.js).
|
|
75
|
+
const ORDER = { top: 0, bottom: 0, front: 1, back: 1, left: 2, right: 2 };
|
|
76
|
+
const idFor = (parts) => [...parts].sort((a, b) => ORDER[a] - ORDER[b]).join("-");
|
|
77
|
+
|
|
78
|
+
// The two in-plane axes for each face normal, in a fixed order so cell
|
|
79
|
+
// enumeration is deterministic.
|
|
80
|
+
const IN_PLANE = { x: ["y", "z"], y: ["x", "z"], z: ["x", "y"] };
|
|
81
|
+
|
|
82
|
+
// The direction, IN MODEL SPACE, that has to point up the screen for a face's
|
|
83
|
+
// NAME to read the right way round. Declared per face rather than inferred from
|
|
84
|
+
// a cell's corner ordering, which is the bug the 2026-08-20 fix removed: a
|
|
85
|
+
// basis built from corner order was only ever checked for MIRRORING, and a
|
|
86
|
+
// basis rotated 180 degrees is non-mirrored too, so LEFT and BACK read upside
|
|
87
|
+
// down (see cube-canvas.js's faceLabelBasis).
|
|
88
|
+
//
|
|
89
|
+
// These are MODEL-space vectors, because that is the frame this module's cells
|
|
90
|
+
// are expressed in and parts are authored Z-UP (see the file header). Do NOT
|
|
91
|
+
// copy view-angles.js's FACE_DIRS here: that table is Y-up because it names
|
|
92
|
+
// CAMERA poses in the viewer's world frame, and the two disagree.
|
|
93
|
+
//
|
|
94
|
+
// The four side faces therefore get model +Z — the model's own up.
|
|
95
|
+
//
|
|
96
|
+
// TOP and BOTTOM have no natural up: whichever in-plane direction is chosen,
|
|
97
|
+
// the label reads upright from some azimuths and rotated from others. The
|
|
98
|
+
// choice made here is the standard CAD one, and it is not arbitrary — model
|
|
99
|
+
// +Y / -Y are exactly the ups view-angles.js's upFor() gives the pure top and
|
|
100
|
+
// bottom camera poses (world [0,0,-1] / [0,0,1]). So TOP reads upright when the
|
|
101
|
+
// camera is above and looking from the front, BOTTOM when it is below looking
|
|
102
|
+
// from the front, and in particular both read upright right after you click
|
|
103
|
+
// that face on the cube. Away from that azimuth they read rotated. That is
|
|
104
|
+
// ACCEPTED, deliberately: re-choosing the axis as the camera orbits would snap
|
|
105
|
+
// the label through 90-degree jumps mid-drag, which is worse than a label lying
|
|
106
|
+
// on its side.
|
|
107
|
+
export const FACE_LABEL_UP = {
|
|
108
|
+
right: [0, 0, 1],
|
|
109
|
+
left: [0, 0, 1],
|
|
110
|
+
front: [0, 0, 1],
|
|
111
|
+
back: [0, 0, 1],
|
|
112
|
+
top: [0, 1, 0],
|
|
113
|
+
bottom: [0, -1, 0],
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// The sign of a face's declared label up along that face's own local V axis —
|
|
117
|
+
// the in-plane axis a projected cell's (p3 - p0) edge runs along (cubeCells
|
|
118
|
+
// winds corners from IN_PLANE, so p3 - p0 is always +vAxis).
|
|
119
|
+
//
|
|
120
|
+
// Always exactly +1 or -1, because every vector in FACE_LABEL_UP IS one of its
|
|
121
|
+
// face's two in-plane axes: the side faces' up is model Z, the V axis of both
|
|
122
|
+
// the X and the Y face pair; TOP/BOTTOM's is model Y, the V axis of the Z pair.
|
|
123
|
+
// That is what lets the renderer turn its already-projected (and so already
|
|
124
|
+
// foreshortened) v edge into the label's up direction with one multiply,
|
|
125
|
+
// instead of needing a second projection of its own.
|
|
126
|
+
export function faceLabelUpSign(face) {
|
|
127
|
+
const entry = FACE_AXIS[face];
|
|
128
|
+
if (!entry) throw new Error(`unknown cube face "${face}"`);
|
|
129
|
+
const vAxis = IN_PLANE[entry.axis][1];
|
|
130
|
+
return Math.sign(FACE_LABEL_UP[face][AXIS_INDEX[vAxis]]);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Cell bounds along one in-plane axis for grid index -1 / 0 / +1.
|
|
134
|
+
function span(index, half) {
|
|
135
|
+
if (index < 0) return [-1, -half];
|
|
136
|
+
if (index > 0) return [half, 1];
|
|
137
|
+
return [-half, half];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// The 54 cells, in model space. Recomputed per call rather than cached: it is a
|
|
141
|
+
// few hundred arithmetic ops, and a cached array would be mutable shared state
|
|
142
|
+
// in a module the rest of the framework expects to be pure.
|
|
143
|
+
export function cubeCells({ faceHalf = CUBE_CONSTANTS.faceHalf } = {}) {
|
|
144
|
+
const cells = [];
|
|
145
|
+
for (const axis of ["x", "y", "z"]) {
|
|
146
|
+
for (const sign of [1, -1]) {
|
|
147
|
+
const [uAxis, vAxis] = IN_PLANE[axis];
|
|
148
|
+
const n = AXIS_INDEX[axis], u = AXIS_INDEX[uAxis], v = AXIS_INDEX[vAxis];
|
|
149
|
+
const normal = [0, 0, 0];
|
|
150
|
+
normal[n] = sign;
|
|
151
|
+
for (const i of [-1, 0, 1]) {
|
|
152
|
+
for (const j of [-1, 0, 1]) {
|
|
153
|
+
const [u0, u1] = span(i, faceHalf);
|
|
154
|
+
const [v0, v1] = span(j, faceHalf);
|
|
155
|
+
const parts = [AXIS_FACE[axis][String(sign)]];
|
|
156
|
+
if (i !== 0) parts.push(AXIS_FACE[uAxis][String(Math.sign(i))]);
|
|
157
|
+
if (j !== 0) parts.push(AXIS_FACE[vAxis][String(Math.sign(j))]);
|
|
158
|
+
// Wound consistently so the projected polygon is convex in order.
|
|
159
|
+
const corners = [[u0, v0], [u1, v0], [u1, v1], [u0, v1]].map(([uu, vv]) => {
|
|
160
|
+
const p = [0, 0, 0];
|
|
161
|
+
p[n] = sign;
|
|
162
|
+
p[u] = uu;
|
|
163
|
+
p[v] = vv;
|
|
164
|
+
return p;
|
|
165
|
+
});
|
|
166
|
+
cells.push({ id: idFor(parts), axis, sign, normal, corners });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return cells;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// The shared corner the three axis edges radiate from: model (-1,-1,-1), the
|
|
175
|
+
// vertex where +X, +Y, +Z all originate.
|
|
176
|
+
const AXIS_ORIGIN_CORNER = [-1, -1, -1];
|
|
177
|
+
|
|
178
|
+
// The cube's 12 edges, as model-space vertex pairs. Exactly 3 are tagged with
|
|
179
|
+
// the axis they carry — the ones touching AXIS_ORIGIN_CORNER and running
|
|
180
|
+
// toward +X / +Y / +Z — because those three are drawn as the labelled arrows;
|
|
181
|
+
// the other 9 are plain (untagged) and drawn as quiet cube edges instead.
|
|
182
|
+
//
|
|
183
|
+
// Each edge also carries `faceNormals`: the outward normals of the two faces
|
|
184
|
+
// that meet along it (an edge running along `n` at fixed (su, sv) sits on the
|
|
185
|
+
// boundary of the uAxis face signed `su` and the vAxis face signed `sv`).
|
|
186
|
+
// projectCube rotates those into view space to decide whether the edge is on
|
|
187
|
+
// the visible silhouette — the renderer has no other way to know which faces
|
|
188
|
+
// an edge belongs to without recomputing this module's geometry itself.
|
|
189
|
+
export function cubeEdges() {
|
|
190
|
+
const edges = [];
|
|
191
|
+
for (const axis of ["x", "y", "z"]) {
|
|
192
|
+
const [uAxis, vAxis] = IN_PLANE[axis];
|
|
193
|
+
const n = AXIS_INDEX[axis], u = AXIS_INDEX[uAxis], v = AXIS_INDEX[vAxis];
|
|
194
|
+
for (const su of [-1, 1]) {
|
|
195
|
+
for (const sv of [-1, 1]) {
|
|
196
|
+
const a = [0, 0, 0], b = [0, 0, 0];
|
|
197
|
+
a[u] = su; a[v] = sv; a[n] = -1;
|
|
198
|
+
b[u] = su; b[v] = sv; b[n] = 1;
|
|
199
|
+
// This is one of the 3 axis edges exactly when it starts at the
|
|
200
|
+
// shared corner (both in-plane coords at -1) and runs toward +axis.
|
|
201
|
+
const isAxisEdge = su === -1 && sv === -1;
|
|
202
|
+
const normalU = [0, 0, 0]; normalU[u] = su;
|
|
203
|
+
const normalV = [0, 0, 0]; normalV[v] = sv;
|
|
204
|
+
edges.push({ a, b, axis: isAxisEdge ? axis : null, faceNormals: [normalU, normalV] });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return edges;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// --- quaternion helpers (plain arrays, [x, y, z, w]) ------------------------
|
|
212
|
+
function qMul(a, b) {
|
|
213
|
+
return [
|
|
214
|
+
a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1],
|
|
215
|
+
a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0],
|
|
216
|
+
a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3],
|
|
217
|
+
a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2],
|
|
218
|
+
];
|
|
219
|
+
}
|
|
220
|
+
const qConj = (q) => [-q[0], -q[1], -q[2], q[3]];
|
|
221
|
+
|
|
222
|
+
// An edge is on the visible silhouette when at least one of its two adjoining
|
|
223
|
+
// faces is camera-facing; otherwise it is hidden (it either coincides exactly
|
|
224
|
+
// with a silhouette edge already drawn on the near side, in an orthographic
|
|
225
|
+
// projection, or projects to a point). "Camera-facing" here is view-space
|
|
226
|
+
// normal Z > this epsilon, not > 0: at the identity quaternion four faces are
|
|
227
|
+
// *exactly* edge-on (Z === 0 up to float noise from the quaternion rotation),
|
|
228
|
+
// and the rule needs those treated as not-facing so the back face's edges and
|
|
229
|
+
// the front-to-back edges land on the hidden side, not the visible one. Not a
|
|
230
|
+
// visual tunable (nobody sweeps a numerical epsilon by eye), so it sits
|
|
231
|
+
// outside CUBE_CONSTANTS, same as cube-canvas.js's MIN_ARROW_DIR_PX.
|
|
232
|
+
const EDGE_FACE_VISIBLE_EPS = 1e-9;
|
|
233
|
+
|
|
234
|
+
function qApply(q, v) {
|
|
235
|
+
const [x, y, z, w] = q;
|
|
236
|
+
const ix = w * v[0] + y * v[2] - z * v[1];
|
|
237
|
+
const iy = w * v[1] + z * v[0] - x * v[2];
|
|
238
|
+
const iz = w * v[2] + x * v[1] - y * v[0];
|
|
239
|
+
const iw = -x * v[0] - y * v[1] - z * v[2];
|
|
240
|
+
return [
|
|
241
|
+
ix * w + iw * -x + iy * -z - iz * -y,
|
|
242
|
+
iy * w + iw * -y + iz * -x - ix * -z,
|
|
243
|
+
iz * w + iw * -z + ix * -y - iy * -x,
|
|
244
|
+
];
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Project the cube as seen by a camera with world quaternion `cameraQuat`.
|
|
248
|
+
// Model -> world is the fixed pivot; world -> view is the camera's inverse
|
|
249
|
+
// rotation. The projection is deliberately ORTHOGRAPHIC regardless of the
|
|
250
|
+
// viewer's own projection: a widget that foreshortened would read as a bug.
|
|
251
|
+
//
|
|
252
|
+
// View space follows three.js: the camera looks down -Z, so a LARGER z is
|
|
253
|
+
// NEARER. Both halves are sorted ascending, which is exactly painter order.
|
|
254
|
+
export function projectCube(cameraQuat, {
|
|
255
|
+
size,
|
|
256
|
+
faceHalf = CUBE_CONSTANTS.faceHalf,
|
|
257
|
+
// A render-side pixel budget (head length + label gap + label glyph size)
|
|
258
|
+
// the caller may pass through so the SCALE reserves room for what gets
|
|
259
|
+
// drawn past the cube in fixed screen pixels — the head and label are no
|
|
260
|
+
// longer model-space geometry (see CUBE_CONSTANTS's comment), so the only
|
|
261
|
+
// thing left that can run past the box edge is those pixels. This module
|
|
262
|
+
// stays import-free (no reading CUBE_RENDER itself), so it defaults to 0
|
|
263
|
+
// and leaves supplying a real value to the caller that knows those pixel
|
|
264
|
+
// sizes (viewcube-mode.js).
|
|
265
|
+
outerPad = 0,
|
|
266
|
+
// Pixels to push the whole drawing DOWN inside the box (CUBE_DOWN_BIAS_PX).
|
|
267
|
+
// Defaults to 0 and is supplied by the caller for the same reason outerPad
|
|
268
|
+
// is: it is only safe to the extent the caller's own outerPad reserved room
|
|
269
|
+
// for it, and this module cannot see the render pixels that decide that. It
|
|
270
|
+
// does NOT touch the scale — the cube is the same size, it just sits lower.
|
|
271
|
+
downBias = 0,
|
|
272
|
+
} = {}) {
|
|
273
|
+
const toView = qMul(qConj(cameraQuat), PIVOT_QUAT);
|
|
274
|
+
|
|
275
|
+
// Scale so the drawing still fits the box at any rotation. Every cube
|
|
276
|
+
// vertex — and the arrow shafts now end exactly ON one (the far face, model
|
|
277
|
+
// coordinate 1 along the axis) — sits at the same distance from the model
|
|
278
|
+
// origin: sqrt(3). That is the only model-space reach left to guard;
|
|
279
|
+
// whatever the head/label add beyond it is screen pixels, covered by
|
|
280
|
+
// `outerPad` instead.
|
|
281
|
+
const scale = (size / 2 - outerPad) / Math.sqrt(3);
|
|
282
|
+
const cx = size / 2, cy = size / 2 + downBias;
|
|
283
|
+
const project = (p) => {
|
|
284
|
+
const v = qApply(toView, p);
|
|
285
|
+
return { xy: [cx + v[0] * scale, cy - v[1] * scale], z: v[2] };
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const back = [], front = [];
|
|
289
|
+
for (const cell of cubeCells({ faceHalf })) {
|
|
290
|
+
const projected = cell.corners.map(project);
|
|
291
|
+
const normalView = qApply(toView, cell.normal);
|
|
292
|
+
const depth = projected.reduce((s, p) => s + p.z, 0) / projected.length;
|
|
293
|
+
const face = AXIS_FACE[cell.axis][String(cell.sign)];
|
|
294
|
+
const entry = {
|
|
295
|
+
id: cell.id,
|
|
296
|
+
points: projected.map((p) => p.xy),
|
|
297
|
+
depth,
|
|
298
|
+
face,
|
|
299
|
+
isCentre: cell.id === face,
|
|
300
|
+
};
|
|
301
|
+
// normalView[2] > 0 means the face's outward normal points at the camera.
|
|
302
|
+
(normalView[2] > 0 ? front : back).push(entry);
|
|
303
|
+
}
|
|
304
|
+
back.sort((a, b) => a.depth - b.depth);
|
|
305
|
+
front.sort((a, b) => a.depth - b.depth);
|
|
306
|
+
|
|
307
|
+
const backEdges = [], frontEdges = [];
|
|
308
|
+
for (const edge of cubeEdges()) {
|
|
309
|
+
const a = project(edge.a), b = project(edge.b);
|
|
310
|
+
const depth = (a.z + b.z) / 2;
|
|
311
|
+
// Rotate the edge's two adjoining face normals the same way the cell
|
|
312
|
+
// normals above are rotated, and hide the edge only when NEITHER faces
|
|
313
|
+
// the camera — see EDGE_FACE_VISIBLE_EPS for why this is "> eps" and not
|
|
314
|
+
// "> 0". Axis-tagged edges get this too (it costs nothing to compute) but
|
|
315
|
+
// the renderer ignores it for them: they draw as arrows in real depth
|
|
316
|
+
// order regardless, by the host's explicit choice.
|
|
317
|
+
const hidden = edge.faceNormals.every((n) => qApply(toView, n)[2] <= EDGE_FACE_VISIBLE_EPS);
|
|
318
|
+
const entry = { points: [a.xy, b.xy], axis: edge.axis, depth, hidden };
|
|
319
|
+
(depth >= 0 ? frontEdges : backEdges).push(entry);
|
|
320
|
+
}
|
|
321
|
+
backEdges.sort((x, y) => x.depth - y.depth);
|
|
322
|
+
frontEdges.sort((x, y) => x.depth - y.depth);
|
|
323
|
+
|
|
324
|
+
// The three axis arrows all start at the same corner and ride their own
|
|
325
|
+
// edge out to the far face (model coordinate 1 along the axis) — the whole
|
|
326
|
+
// edge, not a fraction of it. Everything past that point (the head, the
|
|
327
|
+
// label) is a screen-space add-on cube-canvas.js builds from `tip`, so
|
|
328
|
+
// there is no model-space geometry left to compute for them here.
|
|
329
|
+
const corner = project(AXIS_ORIGIN_CORNER);
|
|
330
|
+
// The edge runs corner-to-corner (length 2); +dir*2 from the shared corner
|
|
331
|
+
// lands exactly on the far face — the adjacent vertex along that axis.
|
|
332
|
+
const farFace = (dir) => AXIS_ORIGIN_CORNER.map((c, i) => c + dir[i] * 2);
|
|
333
|
+
const arrows = [["X", [1, 0, 0]], ["Y", [0, 1, 0]], ["Z", [0, 0, 1]]].map(([axis, dir]) => {
|
|
334
|
+
const tip = project(farFace(dir));
|
|
335
|
+
// The corner is the shared reference depth; the tip is where the arrow
|
|
336
|
+
// actually ends up, so average the two rather than picking either alone.
|
|
337
|
+
const depth = (corner.z + tip.z) / 2;
|
|
338
|
+
return { axis, from: corner.xy, tip: tip.xy, depth };
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
return { back, front, backEdges, frontEdges, arrows };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Convex point-in-polygon: every cross product keeps the same sign.
|
|
345
|
+
function inside(px, py, points) {
|
|
346
|
+
let positive = false, negative = false;
|
|
347
|
+
for (let i = 0; i < points.length; i++) {
|
|
348
|
+
const [ax, ay] = points[i];
|
|
349
|
+
const [bx, by] = points[(i + 1) % points.length];
|
|
350
|
+
const cross = (bx - ax) * (py - ay) - (by - ay) * (px - ax);
|
|
351
|
+
if (cross > 0) positive = true;
|
|
352
|
+
if (cross < 0) negative = true;
|
|
353
|
+
if (positive && negative) return false;
|
|
354
|
+
}
|
|
355
|
+
return true;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Which orientation is under the cursor. Only camera-facing cells are
|
|
359
|
+
// candidates — you cannot click the far side of a cube — and they are tested
|
|
360
|
+
// nearest-first so an overlap resolves to the one actually on top.
|
|
361
|
+
export function hitRegion(px, py, projected) {
|
|
362
|
+
const cells = projected?.front ?? [];
|
|
363
|
+
for (let i = cells.length - 1; i >= 0; i--) {
|
|
364
|
+
if (inside(px, py, cells[i].points)) return cells[i].id;
|
|
365
|
+
}
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// The view cube's chrome: the bottom-right stack (the cube, with the projection
|
|
2
|
+
// toggle over its bottom-right corner), and the visually-hidden per-view buttons
|
|
3
|
+
// that stand in for the DOM focus a canvas cannot give us. Generated, not
|
|
4
|
+
// declared — no part's HTML carries this, and partforge-cloud's scaffold does
|
|
5
|
+
// not either (the mobile-tabs.js and animation-controls.js precedent).
|
|
6
|
+
//
|
|
7
|
+
// The projection button deliberately lives OUTSIDE #viewbar: partforge-cloud's
|
|
8
|
+
// sandbox-scaffold test enumerates #viewbar's buttons against what it renders,
|
|
9
|
+
// and this one is the framework's own.
|
|
10
|
+
//
|
|
11
|
+
// The button used to sit in its own `.pf-viewcube-pill` card below the cube,
|
|
12
|
+
// borrowing #viewbar's chrome. The 2026-08-20 revision made it a small bare
|
|
13
|
+
// circle beside the cube instead (see chrome.css/app.css's viewcube sections),
|
|
14
|
+
// so the pill card — which existed only to give a single button somewhere to
|
|
15
|
+
// sit — is gone; the button is now a direct child of the stack.
|
|
16
|
+
//
|
|
17
|
+
// A same-day follow-up took it out of the stack's flex flow entirely and laid
|
|
18
|
+
// it OVER the cube's bottom-right corner. The DOM is unchanged (still a direct
|
|
19
|
+
// child, still after the cube's wrapper, which is what puts it on top); the
|
|
20
|
+
// visible consequence is that the stack is now exactly as wide as the canvas
|
|
21
|
+
// rather than `canvas + gap + button`, so the size it publishes below — and
|
|
22
|
+
// therefore the crowding decision that reads it — went from 167px to 135
|
|
23
|
+
// (101 below the rail's narrow breakpoint).
|
|
24
|
+
import { attachButtonTooltips } from "../tooltip.js";
|
|
25
|
+
import { runCleanupSteps } from "../teardown.js";
|
|
26
|
+
import { createViewcubeMode } from "./viewcube-mode.js";
|
|
27
|
+
|
|
28
|
+
const PERSPECTIVE_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="M3 4l18 3v10l-18 3z"/><path d="M3 4v16"/></svg>`;
|
|
29
|
+
const ORTHOGRAPHIC_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"><rect x="3" y="6" width="14" height="12" rx="1"/><path d="M7 6V3h14v12h-4"/></svg>`;
|
|
30
|
+
|
|
31
|
+
// One hidden button per canonical FACE view. Edges and corners are reachable by
|
|
32
|
+
// pointer only — six targets is a usable keyboard surface; twenty-six is a
|
|
33
|
+
// tab-stop thicket.
|
|
34
|
+
const KEY_VIEWS = [
|
|
35
|
+
["front", "View from the front"],
|
|
36
|
+
["back", "View from the back"],
|
|
37
|
+
["left", "View from the left"],
|
|
38
|
+
["right", "View from the right"],
|
|
39
|
+
["top", "View from the top"],
|
|
40
|
+
["bottom", "View from the bottom"],
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
export function attachViewcubeControls(viewer, { stage } = {}, { tooltip } = {}) {
|
|
44
|
+
const stack = document.createElement("div");
|
|
45
|
+
stack.className = "pf-viewcube-stack";
|
|
46
|
+
stage.appendChild(stack);
|
|
47
|
+
|
|
48
|
+
const mode = createViewcubeMode(viewer, { host: stack });
|
|
49
|
+
|
|
50
|
+
const button = document.createElement("button");
|
|
51
|
+
button.type = "button";
|
|
52
|
+
button.id = "projection";
|
|
53
|
+
button.className = "pf-viewcube-toggle";
|
|
54
|
+
stack.appendChild(button);
|
|
55
|
+
|
|
56
|
+
const keys = document.createElement("div");
|
|
57
|
+
keys.className = "pf-viewcube-key";
|
|
58
|
+
const keyButtons = KEY_VIEWS.map(([view, label]) => {
|
|
59
|
+
const b = document.createElement("button");
|
|
60
|
+
b.type = "button";
|
|
61
|
+
b.dataset.view = view;
|
|
62
|
+
b.textContent = label;
|
|
63
|
+
b.setAttribute("aria-label", label);
|
|
64
|
+
keys.appendChild(b);
|
|
65
|
+
return b;
|
|
66
|
+
});
|
|
67
|
+
stack.appendChild(keys);
|
|
68
|
+
|
|
69
|
+
const tooltipBinding = tooltip
|
|
70
|
+
? attachButtonTooltips(tooltip, [{ element: button }])
|
|
71
|
+
: null;
|
|
72
|
+
|
|
73
|
+
function sync() {
|
|
74
|
+
const ortho = viewer.getProjection() === "orthographic";
|
|
75
|
+
button.innerHTML = ortho ? ORTHOGRAPHIC_ICON : PERSPECTIVE_ICON;
|
|
76
|
+
button.classList.toggle("on", ortho);
|
|
77
|
+
button.setAttribute("aria-pressed", String(ortho));
|
|
78
|
+
const label = ortho ? "Switch to perspective view" : "Switch to orthographic view";
|
|
79
|
+
button.setAttribute("aria-label", label);
|
|
80
|
+
if (!tooltip) button.title = label;
|
|
81
|
+
tooltipBinding?.sync();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const onToggle = () => {
|
|
85
|
+
viewer.setProjection(viewer.getProjection() === "orthographic" ? "perspective" : "orthographic");
|
|
86
|
+
sync();
|
|
87
|
+
};
|
|
88
|
+
button.addEventListener("click", onToggle);
|
|
89
|
+
|
|
90
|
+
const keyHandlers = keyButtons.map((b) => {
|
|
91
|
+
// Same user intent as clicking the face on the canvas, so the same
|
|
92
|
+
// `refit` — these buttons ARE the keyboard route to that click.
|
|
93
|
+
const handler = () => viewer.tweenCameraTo(b.dataset.view, { duration: 0.6, refit: true });
|
|
94
|
+
b.addEventListener("click", handler);
|
|
95
|
+
return handler;
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// A host or another mode can flip projection without going through this
|
|
99
|
+
// button; the chrome follows rather than drifting out of sync.
|
|
100
|
+
const offProjection = viewer.onProjectionChange(sync);
|
|
101
|
+
sync();
|
|
102
|
+
|
|
103
|
+
// Publish the stack's size on the element itself, in the data-pf-* convention
|
|
104
|
+
// the shell already uses (data-pf-pane). animation-controls.js reads it to
|
|
105
|
+
// decide whether the transport bar is crowded, and that decision has to come
|
|
106
|
+
// out the same whether or not the stack is on screen — otherwise hiding the
|
|
107
|
+
// cube un-crowds the bar, the bar un-hides the cube, and the two oscillate a
|
|
108
|
+
// frame at a time (see nominalClusterRect for the full argument). A
|
|
109
|
+
// display:none element measures all zeros, so the size cannot be read live;
|
|
110
|
+
// it has to have been written down.
|
|
111
|
+
//
|
|
112
|
+
// Only ever written from a REAL measured size, so the last real values survive
|
|
113
|
+
// a hide. They change only at the rail's narrow breakpoint, which leaves one
|
|
114
|
+
// stale case: a breakpoint change that happens WHILE hidden leaves the
|
|
115
|
+
// full-size value published. That is benign and deliberately not "fixed" — it
|
|
116
|
+
// is the conservative direction (it keeps the cube hidden rather than
|
|
117
|
+
// flickering it back), and the next real measurement corrects it.
|
|
118
|
+
//
|
|
119
|
+
// A dataset write affects no layout, so this observer cannot feed itself.
|
|
120
|
+
const publishSize = () => {
|
|
121
|
+
const { width, height } = stack.getBoundingClientRect();
|
|
122
|
+
if (width <= 0 || height <= 0) return;
|
|
123
|
+
stack.dataset.pfW = String(Math.round(width));
|
|
124
|
+
stack.dataset.pfH = String(Math.round(height));
|
|
125
|
+
};
|
|
126
|
+
const sizeObserver = typeof ResizeObserver === "function"
|
|
127
|
+
? new ResizeObserver(publishSize) : null;
|
|
128
|
+
sizeObserver?.observe(stack);
|
|
129
|
+
publishSize(); // the observer's first callback is a frame away; the reader may not be
|
|
130
|
+
|
|
131
|
+
function setHidden(flag) {
|
|
132
|
+
const next = !!flag;
|
|
133
|
+
stack.hidden = next;
|
|
134
|
+
// Stand the frame subscription's work down too, not just the pixels.
|
|
135
|
+
mode.setHidden(next);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let detached = false;
|
|
139
|
+
return {
|
|
140
|
+
element: stack,
|
|
141
|
+
mode,
|
|
142
|
+
setHidden,
|
|
143
|
+
detach() {
|
|
144
|
+
if (detached) return;
|
|
145
|
+
detached = true;
|
|
146
|
+
runCleanupSteps([
|
|
147
|
+
offProjection,
|
|
148
|
+
() => sizeObserver?.disconnect(),
|
|
149
|
+
() => button.removeEventListener("click", onToggle),
|
|
150
|
+
...keyButtons.map((b, i) => () => b.removeEventListener("click", keyHandlers[i])),
|
|
151
|
+
() => tooltipBinding?.detach(),
|
|
152
|
+
() => mode.detach(),
|
|
153
|
+
() => stack.remove(),
|
|
154
|
+
], "viewcube control cleanup failed");
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|