partforge 0.72.0 → 0.73.1
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-place.js +77 -8
- package/src/framework/measure/dim3-scene.js +15 -2
- package/src/framework/measure/measure-mode.js +80 -15
- 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
|
@@ -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).
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
// The view cube's renderer: one small 2D canvas, repainted only when the camera
|
|
2
|
+
// actually moved (viewcube-mode.js owns that decision). A canvas rather than
|
|
3
|
+
// SVG because the alternative rewrites ~26 polygon `points` attributes inside
|
|
4
|
+
// the rAF callback, and each write re-parses a string and invalidates style and
|
|
5
|
+
// paint for the subtree — spent during orbit, which is the worst time to spend
|
|
6
|
+
// it. Here an idle frame costs literally nothing.
|
|
7
|
+
//
|
|
8
|
+
// The 2D context is injected (happy-dom has no real one) — the createInkCanvas
|
|
9
|
+
// and dim3-scene paintLabel precedent.
|
|
10
|
+
import { faceLabelUpSign } from "./cube-geom.js";
|
|
11
|
+
|
|
12
|
+
// The full-size cube. Below RAIL_NARROW_BREAKPOINT the shell has only one pane
|
|
13
|
+
// on screen at a time (rail.js), so the cube drops to CUBE_SIZE_NARROW —
|
|
14
|
+
// viewcube-mode.js is the one that watches the breakpoint and calls setSize().
|
|
15
|
+
export const CUBE_SIZE = 135; // CSS px; the backing store is this x devicePixelRatio
|
|
16
|
+
// Three quarters of the full size: below the rail's narrow breakpoint the stage
|
|
17
|
+
// is much tighter, but the cube stays VISIBLE there (the 2026-08-19 design
|
|
18
|
+
// decision) and so it still has to be legible — 26 hit regions and six face
|
|
19
|
+
// labels on a phone-sized widget. Deliberately a tuned literal rather than
|
|
20
|
+
// `Math.round(CUBE_SIZE * 0.75)`, like every other number in this file's
|
|
21
|
+
// exported blocks: a derived expression invites the next reader to retune the
|
|
22
|
+
// RATIO when what they actually want is a different number of pixels.
|
|
23
|
+
export const CUBE_SIZE_NARROW = 101;
|
|
24
|
+
|
|
25
|
+
// Deliberately hardcoded rather than read from CSS vars: this paints into a
|
|
26
|
+
// bitmap where var() cannot reach, exactly like DIM_THEME in dim3-scene.js.
|
|
27
|
+
// Locked by the look-and-feel spike (plan Task 4); the `edge` colour was
|
|
28
|
+
// retuned in the 2026-08-19 reshape (see CUBE_RENDER's comment below).
|
|
29
|
+
//
|
|
30
|
+
// `hoverFill` has to keep a promise the other fills do not: a hovered corner
|
|
31
|
+
// shows THREE cells at once and all of them must read as one shade (2026-08-20
|
|
32
|
+
// — "one side is much lighter"). Translucent paint alone cannot promise that,
|
|
33
|
+
// because whether a given cell has a back-face cell (`backFill`), a back-phase
|
|
34
|
+
// axis arrow, or bare canvas behind it depends on the rotation, and the tint
|
|
35
|
+
// takes the colour of whatever that is.
|
|
36
|
+
//
|
|
37
|
+
// Opacity would force it, and was tried — at full alpha the highlight read as
|
|
38
|
+
// far too strong a blue against the ghost cube. So the uniformity comes from
|
|
39
|
+
// the BACKDROP instead: draw() clips to each hovered cell, clears it, and lays
|
|
40
|
+
// down one coat of `frontFill` before the tint. Every side of a region then
|
|
41
|
+
// composites over exactly the same two layers, whatever the rotation put
|
|
42
|
+
// behind it, and the tint itself stays quiet (alpha 0.30).
|
|
43
|
+
//
|
|
44
|
+
// The trade-off, written down rather than left to be rediscovered: the cleared
|
|
45
|
+
// cells no longer show the cube's back geometry or a back-phase axis arrow
|
|
46
|
+
// through them. That is accepted — the highlight reads as a clean tinted patch,
|
|
47
|
+
// which is exactly what makes it uniform — and it affects only the 1-3 cells
|
|
48
|
+
// under the cursor.
|
|
49
|
+
export const CUBE_PALETTE = {
|
|
50
|
+
dark: {
|
|
51
|
+
backFill: "rgba(124, 143, 176, 0.10)",
|
|
52
|
+
frontFill: "rgba(159, 180, 204, 0.22)",
|
|
53
|
+
hoverFill: "rgba(122, 162, 247, 0.30)",
|
|
54
|
+
edge: "rgba(190, 205, 226, 0.25)",
|
|
55
|
+
faceLabel: "rgba(214, 226, 255, 0.65)",
|
|
56
|
+
axisX: "#e06c75",
|
|
57
|
+
axisY: "#98c379",
|
|
58
|
+
axisZ: "#61afef",
|
|
59
|
+
label: "#d6e2ff",
|
|
60
|
+
},
|
|
61
|
+
light: {
|
|
62
|
+
backFill: "rgba(70, 88, 118, 0.08)",
|
|
63
|
+
frontFill: "rgba(90, 108, 138, 0.18)",
|
|
64
|
+
hoverFill: "rgba(43, 108, 214, 0.30)",
|
|
65
|
+
edge: "rgba(56, 72, 98, 0.25)",
|
|
66
|
+
faceLabel: "rgba(24, 42, 78, 0.65)",
|
|
67
|
+
axisX: "#c0392b",
|
|
68
|
+
axisY: "#2f7d32",
|
|
69
|
+
axisZ: "#1f6feb",
|
|
70
|
+
label: "#182a4e",
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// Render proportions the look-and-feel spike sweeps (plan Task 4), kept here
|
|
75
|
+
// rather than inline for the same reason CUBE_PALETTE is: a tunable nobody can
|
|
76
|
+
// find is a tunable nobody tunes. Geometry proportions live in cube-geom.js's
|
|
77
|
+
// CUBE_CONSTANTS; these are the ones that only affect how it is PAINTED.
|
|
78
|
+
//
|
|
79
|
+
// The 2026-08-19 reshape dropped per-cell strokes entirely (the 26 regions are
|
|
80
|
+
// invisible until hovered) in favour of the cube's own 12 edges: `edge`
|
|
81
|
+
// above is now a plain, quiet cube-edge colour rather than a busy grid line,
|
|
82
|
+
// and its alpha was cut roughly in half from the old cell-outline value for
|
|
83
|
+
// exactly that reason.
|
|
84
|
+
//
|
|
85
|
+
// A same-day follow-up moved the arrowhead and its label fully into SCREEN
|
|
86
|
+
// space: an arrowhead sized from the (foreshortened) projected shaft grew and
|
|
87
|
+
// shrank as the cube turned, which read as a glitch. `headLengthPx` and
|
|
88
|
+
// `headHalfWidthPx` (replacing the old fraction-of-shaft `headHalfWidth`) are
|
|
89
|
+
// now fixed CSS px, built from the shaft's normalised screen direction, so the
|
|
90
|
+
// head is the same size at every rotation and its back edge sits flush
|
|
91
|
+
// against the cube (the shaft itself now ends exactly on the far face — see
|
|
92
|
+
// cube-geom.js). `labelGapPx` is the fixed px gap from the head's tip to the
|
|
93
|
+
// axis label anchor beyond it.
|
|
94
|
+
//
|
|
95
|
+
// `faceLabelScale` sizes the FRONT/BACK/... names painted ON their faces
|
|
96
|
+
// (see drawFaceLabel below) — it is a font size in FACE-LOCAL units, not CSS
|
|
97
|
+
// px: 1 unit is half the centre cell's own width, so the label scales with
|
|
98
|
+
// the cube (and with the 135px/90px breakpoint switch) for free instead of
|
|
99
|
+
// needing its own px tunable.
|
|
100
|
+
export const CUBE_RENDER = {
|
|
101
|
+
headLengthPx: 9, // arrowhead length, CSS px, constant regardless of rotation
|
|
102
|
+
headHalfWidthPx: 3.5, // arrowhead half-width, CSS px
|
|
103
|
+
labelGapPx: 4, // gap from the head's tip to the axis label anchor, CSS px
|
|
104
|
+
arrowWidth: 2, // axis arrow shaft stroke, CSS px
|
|
105
|
+
edgeWidth: 1, // cube-edge stroke, CSS px
|
|
106
|
+
labelPx: 10, // axis label (X/Y/Z) size, CSS px
|
|
107
|
+
faceLabelScale: 0.45, // face label (FRONT/BACK/...) font size, in face-local units
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// Below this projected shaft length (CSS px) the axis points almost exactly
|
|
111
|
+
// at or away from the camera: the shaft is a dot on screen, its direction is
|
|
112
|
+
// numerically meaningless, and normalising it would produce NaN. Not a visual
|
|
113
|
+
// tunable (nobody wants to "nudge" a numerical safety epsilon by eye) so it
|
|
114
|
+
// sits outside CUBE_RENDER, same as viewcube-mode.js's DRAG_THRESHOLD_PX.
|
|
115
|
+
const MIN_ARROW_DIR_PX = 0.5;
|
|
116
|
+
|
|
117
|
+
// Lays the face name flat ON the face — the label rotates with the face rather
|
|
118
|
+
// than hovering in front of it always facing the screen (the 2026-08-19 design
|
|
119
|
+
// decision, and the thing this must not regress). Returns the affine basis
|
|
120
|
+
// drawFaceLabel hands the context: the cell's screen centroid plus two axes
|
|
121
|
+
// where 1 local unit is half the cell's own on-screen width/height, so text
|
|
122
|
+
// sized in that space (CUBE_RENDER.faceLabelScale) scales with the cube for
|
|
123
|
+
// free. Pure and exported so the six faces' orientations can be asserted
|
|
124
|
+
// directly, including for faces the renderer would not have drawn.
|
|
125
|
+
//
|
|
126
|
+
// The v (down-the-glyph) axis is deliberately NOT taken from the corner
|
|
127
|
+
// ordering. That ordering is consistent, but consistency is not orientation:
|
|
128
|
+
// the old basis was `u = (p1-p0)/2, v = (p3-p0)/2` with a single determinant
|
|
129
|
+
// guard, and a determinant only catches MIRRORING. A basis rotated 180 degrees
|
|
130
|
+
// is non-mirrored too, so LEFT and BACK sailed through it reading upside down.
|
|
131
|
+
//
|
|
132
|
+
// Instead each face declares an up direction in model space (cube-geom.js's
|
|
133
|
+
// FACE_LABEL_UP) and faceLabelUpSign says which way that lies along this cell's
|
|
134
|
+
// own projected v edge. v is that edge pointed the OTHER way, because canvas +y
|
|
135
|
+
// is down: local +y, the direction a glyph descends, has to run DOWN the face.
|
|
136
|
+
export function faceLabelBasis(cell) {
|
|
137
|
+
const [p0, p1, , p3] = cell.points;
|
|
138
|
+
const cx0 = cell.points.reduce((s, pt) => s + pt[0], 0) / cell.points.length;
|
|
139
|
+
const cy0 = cell.points.reduce((s, pt) => s + pt[1], 0) / cell.points.length;
|
|
140
|
+
let ux = (p1[0] - p0[0]) / 2, uy = (p1[1] - p0[1]) / 2;
|
|
141
|
+
const bx = (p3[0] - p0[0]) / 2, by = (p3[1] - p0[1]) / 2;
|
|
142
|
+
const down = -faceLabelUpSign(cell.face);
|
|
143
|
+
const vx = down * bx, vy = down * by;
|
|
144
|
+
// The handedness guard, kept as a backstop but now applied to U. Only
|
|
145
|
+
// camera-facing faces get labelled, so "toward the camera" is the handedness
|
|
146
|
+
// the basis should keep, and a negative determinant still means this pairing
|
|
147
|
+
// would mirror the text. What changed is which axis is free to fix it: v now
|
|
148
|
+
// carries the orientation, so flipping v would undo the correction above and
|
|
149
|
+
// put the label back upside down. Negating u instead reflects the pair across
|
|
150
|
+
// v, which un-mirrors the glyph while leaving its up direction alone.
|
|
151
|
+
if (ux * vy - uy * vx < 0) { ux = -ux; uy = -uy; }
|
|
152
|
+
return { cx: cx0, cy: cy0, ux, uy, vx, vy };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function createCubeCanvas(host, {
|
|
156
|
+
getContext2d = (canvas) => canvas.getContext("2d"),
|
|
157
|
+
createCanvas = () => document.createElement("canvas"),
|
|
158
|
+
size: initialSize = CUBE_SIZE,
|
|
159
|
+
} = {}) {
|
|
160
|
+
const canvas = createCanvas();
|
|
161
|
+
canvas.className = "pf-viewcube-canvas";
|
|
162
|
+
let size = initialSize;
|
|
163
|
+
canvas.style.width = `${size}px`;
|
|
164
|
+
canvas.style.height = `${size}px`;
|
|
165
|
+
host.appendChild(canvas);
|
|
166
|
+
const ctx = getContext2d(canvas);
|
|
167
|
+
|
|
168
|
+
let theme = "dark";
|
|
169
|
+
let last = null; // the most recent { projected, hover }, so setTheme can repaint
|
|
170
|
+
let backingDpr = 0;
|
|
171
|
+
|
|
172
|
+
// The BACKING STORE is size x dpr while the CSS box stays `size` — and draw()
|
|
173
|
+
// scales the context by the same dpr so it can keep working in CSS px. Sizing
|
|
174
|
+
// the backing store in CSS px while scaling the context is the classic
|
|
175
|
+
// version of this bug: everything renders at 2x on a retina display and the
|
|
176
|
+
// cube is clipped to its top-left quarter. Re-checked per draw because a
|
|
177
|
+
// window can move between displays of different density.
|
|
178
|
+
function resizeBackingStore(dpr) {
|
|
179
|
+
backingDpr = dpr;
|
|
180
|
+
canvas.width = Math.max(1, Math.round(size * dpr));
|
|
181
|
+
canvas.height = Math.max(1, Math.round(size * dpr));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function syncBackingStore(dpr) {
|
|
185
|
+
if (dpr === backingDpr) return;
|
|
186
|
+
resizeBackingStore(dpr);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function polygon(points) {
|
|
190
|
+
ctx.beginPath();
|
|
191
|
+
points.forEach(([x, y], i) => (i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y)));
|
|
192
|
+
ctx.closePath();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function strokeEdges(edges, colour) {
|
|
196
|
+
ctx.strokeStyle = colour;
|
|
197
|
+
ctx.lineWidth = CUBE_RENDER.edgeWidth;
|
|
198
|
+
for (const edge of edges) {
|
|
199
|
+
// The 3 axis-tagged edges are drawn by their arrow instead (shaft +
|
|
200
|
+
// head, in the axis colour) — stroking them here too would just be a
|
|
201
|
+
// duller line sitting under a brighter one.
|
|
202
|
+
if (edge.axis) continue;
|
|
203
|
+
// Neither adjoining face is camera-facing (cube-geom.js's `hidden`) —
|
|
204
|
+
// an edge on the cube's far side, or one that projects onto a
|
|
205
|
+
// silhouette edge already drawn from the near side.
|
|
206
|
+
if (edge.hidden) continue;
|
|
207
|
+
ctx.beginPath();
|
|
208
|
+
ctx.moveTo(edge.points[0][0], edge.points[0][1]);
|
|
209
|
+
ctx.lineTo(edge.points[1][0], edge.points[1][1]);
|
|
210
|
+
ctx.stroke();
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// The shaft (arrow.from -> arrow.tip) is real cube geometry — it ends
|
|
215
|
+
// exactly on the far face. Everything past that is built here in constant
|
|
216
|
+
// SCREEN pixels from the shaft's normalised on-screen direction, so the
|
|
217
|
+
// head reads the same size at every rotation instead of growing and
|
|
218
|
+
// shrinking with the shaft's foreshortened projected length. Below
|
|
219
|
+
// MIN_ARROW_DIR_PX the direction is numerically meaningless (the axis
|
|
220
|
+
// points almost straight at/away from the camera, so the shaft is a dot on
|
|
221
|
+
// screen) — returning null here rather than a near-zero/NaN vector is what
|
|
222
|
+
// keeps that case from poisoning the head or label.
|
|
223
|
+
function arrowDirection(arrow) {
|
|
224
|
+
const dx = arrow.tip[0] - arrow.from[0];
|
|
225
|
+
const dy = arrow.tip[1] - arrow.from[1];
|
|
226
|
+
const len = Math.hypot(dx, dy);
|
|
227
|
+
if (len < MIN_ARROW_DIR_PX) return null;
|
|
228
|
+
return [dx / len, dy / len];
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Where the head's outward tip and the label anchor land, given the
|
|
232
|
+
// shaft's screen direction — shared by drawArrow (which also needs the
|
|
233
|
+
// head's base corners) and the axis-label pass (drawn last, over
|
|
234
|
+
// everything, regardless of which depth phase drew the arrow itself).
|
|
235
|
+
function arrowFurniture(arrow, dir) {
|
|
236
|
+
const headTip = [
|
|
237
|
+
arrow.tip[0] + dir[0] * CUBE_RENDER.headLengthPx,
|
|
238
|
+
arrow.tip[1] + dir[1] * CUBE_RENDER.headLengthPx,
|
|
239
|
+
];
|
|
240
|
+
const label = [
|
|
241
|
+
headTip[0] + dir[0] * CUBE_RENDER.labelGapPx,
|
|
242
|
+
headTip[1] + dir[1] * CUBE_RENDER.labelGapPx,
|
|
243
|
+
];
|
|
244
|
+
return { headTip, label };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function drawArrow(arrow, colour) {
|
|
248
|
+
ctx.strokeStyle = colour;
|
|
249
|
+
ctx.lineWidth = CUBE_RENDER.arrowWidth;
|
|
250
|
+
ctx.beginPath();
|
|
251
|
+
ctx.moveTo(arrow.from[0], arrow.from[1]);
|
|
252
|
+
ctx.lineTo(arrow.tip[0], arrow.tip[1]);
|
|
253
|
+
ctx.stroke();
|
|
254
|
+
|
|
255
|
+
const dir = arrowDirection(arrow);
|
|
256
|
+
if (!dir) return; // degenerate: axis points at/away from the camera, no head to draw
|
|
257
|
+
const { headTip } = arrowFurniture(arrow, dir);
|
|
258
|
+
const nx = -dir[1] * CUBE_RENDER.headHalfWidthPx, ny = dir[0] * CUBE_RENDER.headHalfWidthPx;
|
|
259
|
+
ctx.fillStyle = colour;
|
|
260
|
+
polygon([headTip, [arrow.tip[0] + nx, arrow.tip[1] + ny], [arrow.tip[0] - nx, arrow.tip[1] - ny]]);
|
|
261
|
+
ctx.fill();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// faceLabelBasis (module scope, above) does the geometry; this composes it
|
|
265
|
+
// onto the DPR scale with save()/transform() rather than replacing it via
|
|
266
|
+
// setTransform().
|
|
267
|
+
function drawFaceLabel(cell, colour) {
|
|
268
|
+
const { cx: cx0, cy: cy0, ux, uy, vx, vy } = faceLabelBasis(cell);
|
|
269
|
+
ctx.save();
|
|
270
|
+
ctx.transform(ux, uy, vx, vy, cx0, cy0);
|
|
271
|
+
ctx.font = `600 ${CUBE_RENDER.faceLabelScale}px ui-sans-serif, system-ui, sans-serif`;
|
|
272
|
+
ctx.textAlign = "center";
|
|
273
|
+
ctx.textBaseline = "middle";
|
|
274
|
+
ctx.fillStyle = colour;
|
|
275
|
+
ctx.fillText(cell.face.toUpperCase(), 0, 0);
|
|
276
|
+
ctx.restore();
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Draw order (2026-08-19 reshape): back faces, back edges, the hovered
|
|
280
|
+
// region (if any), front faces, front edges, the 3 axis arrows in their own
|
|
281
|
+
// depth order, face labels, axis labels. No cell is ever stroked — the grid
|
|
282
|
+
// is gone, replaced by the cube's own 12 (quiet) edges — and face labels are
|
|
283
|
+
// skipped entirely while hovering so the highlight reads unobstructed.
|
|
284
|
+
function draw(projected, { hover } = {}) {
|
|
285
|
+
if (!ctx || !projected) return;
|
|
286
|
+
last = { projected, hover };
|
|
287
|
+
const p = CUBE_PALETTE[theme] ?? CUBE_PALETTE.dark;
|
|
288
|
+
const dpr = globalThis.devicePixelRatio || 1;
|
|
289
|
+
syncBackingStore(dpr);
|
|
290
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
291
|
+
ctx.clearRect(0, 0, size, size);
|
|
292
|
+
|
|
293
|
+
const axisColour = { X: p.axisX, Y: p.axisY, Z: p.axisZ };
|
|
294
|
+
// Arrows are routed by real depth rather than drawn unconditionally on
|
|
295
|
+
// top: one whose corner has rotated to the back must be drawn BEFORE the
|
|
296
|
+
// (translucent) front faces so it reads dimly through them, not floating
|
|
297
|
+
// over geometry that should be hiding it.
|
|
298
|
+
const behind = projected.arrows.filter((a) => a.depth < 0).sort((a, b) => a.depth - b.depth);
|
|
299
|
+
const ahead = projected.arrows.filter((a) => a.depth >= 0).sort((a, b) => a.depth - b.depth);
|
|
300
|
+
|
|
301
|
+
// 1. back faces
|
|
302
|
+
ctx.fillStyle = p.backFill;
|
|
303
|
+
for (const cell of projected.back) {
|
|
304
|
+
polygon(cell.points);
|
|
305
|
+
ctx.fill();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// 2. back edges (the 9 quiet ones; the 3 axis edges are arrows only)
|
|
309
|
+
strokeEdges(projected.backEdges, p.edge);
|
|
310
|
+
|
|
311
|
+
for (const arrow of behind) drawArrow(arrow, axisColour[arrow.axis]);
|
|
312
|
+
|
|
313
|
+
// 3. the hovered region — EVERY camera-facing cell of it, not just one.
|
|
314
|
+
// A face id owns 1 cell, an edge id 2, a corner id 3 (see cube-geom.js),
|
|
315
|
+
// and step 4 below skips all of them, so resolving a single cell here left
|
|
316
|
+
// an edge's second cell and a corner's other two unpainted altogether.
|
|
317
|
+
//
|
|
318
|
+
// Each cell is CLEARED (clipped to its own polygon) before it is tinted, so
|
|
319
|
+
// the translucent highlight composites over identical pixels on every side
|
|
320
|
+
// of the region rather than over whatever back-face cell or back-phase
|
|
321
|
+
// arrow happens to lie behind that one — see CUBE_PALETTE's note. This must
|
|
322
|
+
// stay here, before the front faces: step 4 skips hovered cells, so a clear
|
|
323
|
+
// any later would erase the cube rather than prepare the highlight.
|
|
324
|
+
if (hover) {
|
|
325
|
+
for (const cell of projected.front) {
|
|
326
|
+
if (cell.id !== hover) continue;
|
|
327
|
+
ctx.save();
|
|
328
|
+
polygon(cell.points);
|
|
329
|
+
ctx.clip();
|
|
330
|
+
ctx.clearRect(0, 0, size, size); // clipped: only this cell's pixels
|
|
331
|
+
ctx.restore();
|
|
332
|
+
// Then ONE uniform base coat before the tint: the cell's own ordinary
|
|
333
|
+
// front fill. Clearing alone would leave the tint sitting on bare
|
|
334
|
+
// canvas, and on the dark theme that composites DARKER than the cube
|
|
335
|
+
// around it — the highlight read as a hole punched in the face rather
|
|
336
|
+
// than as a highlight. This keeps the backdrop identical on every side
|
|
337
|
+
// of the region (which is the whole point) while the hovered cells
|
|
338
|
+
// still read as lit.
|
|
339
|
+
ctx.fillStyle = p.frontFill;
|
|
340
|
+
polygon(cell.points);
|
|
341
|
+
ctx.fill();
|
|
342
|
+
ctx.fillStyle = p.hoverFill;
|
|
343
|
+
polygon(cell.points);
|
|
344
|
+
ctx.fill();
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// 4. front faces (skip the hovered one — its highlight is already down)
|
|
349
|
+
ctx.fillStyle = p.frontFill;
|
|
350
|
+
for (const cell of projected.front) {
|
|
351
|
+
if (cell.id === hover) continue;
|
|
352
|
+
polygon(cell.points);
|
|
353
|
+
ctx.fill();
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// 5. front edges
|
|
357
|
+
strokeEdges(projected.frontEdges, p.edge);
|
|
358
|
+
|
|
359
|
+
// 6. axis arrows, in depth order relative to the faces
|
|
360
|
+
for (const arrow of ahead) drawArrow(arrow, axisColour[arrow.axis]);
|
|
361
|
+
|
|
362
|
+
// 7. face labels — camera-facing faces only, and none at all while
|
|
363
|
+
// hovering so the highlighted region is unobstructed.
|
|
364
|
+
if (!hover) {
|
|
365
|
+
for (const cell of projected.front) {
|
|
366
|
+
if (cell.isCentre) drawFaceLabel(cell, p.faceLabel);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// 8. axis labels — always drawn, in both depth phases, since they sit
|
|
371
|
+
// outside the cube's silhouette and cover nothing.
|
|
372
|
+
ctx.font = `600 ${CUBE_RENDER.labelPx}px ui-sans-serif, system-ui, sans-serif`;
|
|
373
|
+
ctx.textAlign = "center";
|
|
374
|
+
ctx.textBaseline = "middle";
|
|
375
|
+
for (const arrow of projected.arrows) {
|
|
376
|
+
const dir = arrowDirection(arrow);
|
|
377
|
+
if (!dir) continue; // no meaningful direction to place the label along
|
|
378
|
+
const { label } = arrowFurniture(arrow, dir);
|
|
379
|
+
ctx.fillStyle = p.label;
|
|
380
|
+
ctx.fillText(arrow.axis, label[0], label[1]);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function setTheme(mode) {
|
|
385
|
+
theme = CUBE_PALETTE[mode] ? mode : "dark";
|
|
386
|
+
if (last) draw(last.projected, { hover: last.hover });
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Resizes the CSS box and the DPR backing store, then repaints with
|
|
390
|
+
// whatever projection is on hand. That repaint is necessarily stale (it
|
|
391
|
+
// still uses the old size's geometry) — viewcube-mode.js, which owns the
|
|
392
|
+
// projection, re-projects at the new size and calls draw() again right
|
|
393
|
+
// after; this one just keeps the canvas from sitting blank or mis-scaled
|
|
394
|
+
// for the tick in between. Bypasses syncBackingStore's dpr-equality check
|
|
395
|
+
// on purpose: a size change with no DPR change would otherwise be ignored.
|
|
396
|
+
function setSize(px) {
|
|
397
|
+
size = px;
|
|
398
|
+
canvas.style.width = `${size}px`;
|
|
399
|
+
canvas.style.height = `${size}px`;
|
|
400
|
+
resizeBackingStore(globalThis.devicePixelRatio || 1);
|
|
401
|
+
if (last) draw(last.projected, { hover: last.hover });
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function dispose() {
|
|
405
|
+
canvas.remove();
|
|
406
|
+
last = null;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
return { element: canvas, draw, setTheme, setSize, get size() { return size; }, dispose };
|
|
410
|
+
}
|