cozyclay 1.0.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/LICENSE +674 -0
- package/README.md +109 -0
- package/THIRD_PARTY_NOTICES.md +29 -0
- package/bin/cozyclay.mjs +194 -0
- package/dist/ardy/cskel27-rest.json +557 -0
- package/dist/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
- package/dist/assets/basis_transcoder-o4Hde_L7.js +19 -0
- package/dist/assets/draco_decoder-C32yEggz.wasm +0 -0
- package/dist/assets/draco_decoder-Z1_iN-Ht.wasm +0 -0
- package/dist/assets/draco_decoder-fzg4nYZr.js +34 -0
- package/dist/assets/draco_wasm_wrapper-DxJM36Ib.js +117 -0
- package/dist/assets/draco_wasm_wrapper-fZCQGLGb.js +116 -0
- package/dist/assets/index-BW_S1YRy.js +4395 -0
- package/dist/assets/index-nRmmJgap.css +1 -0
- package/dist/demo/walk-then-stop.npz +0 -0
- package/dist/fonts/instrument-serif-italic-latin.woff2 +0 -0
- package/dist/fonts/instrument-serif-latin.woff2 +0 -0
- package/dist/fonts/inter-latin.woff2 +0 -0
- package/dist/index.html +13 -0
- package/dist/models/x-bot-tpose.fbx +0 -0
- package/dist/models/y-bot-tpose.fbx +0 -0
- package/package.json +80 -0
- package/src/App.jsx +3840 -0
- package/src/ardy/client.js +140 -0
- package/src/ardy/convert.js +313 -0
- package/src/ardy/cskel27-neutral.js +39 -0
- package/src/ardy/cskel27.js +68 -0
- package/src/ardy/export.js +157 -0
- package/src/ardy/ik.js +610 -0
- package/src/ardy/npz.js +520 -0
- package/src/ardy/playback.js +414 -0
- package/src/ardy/prompt-clips.js +16 -0
- package/src/ardy/timeline-coordinates.js +17 -0
- package/src/ardy/timeline-resize.js +11 -0
- package/src/ardy/timeline.jsx +723 -0
- package/src/ardy/to-cskel27.js +205 -0
- package/src/ardy/waypoints.js +364 -0
- package/src/camera-follow.js +366 -0
- package/src/camera-move.js +286 -0
- package/src/controls.jsx +245 -0
- package/src/dualview.jsx +305 -0
- package/src/hierarchy-model.js +77 -0
- package/src/hierarchy-panel.jsx +365 -0
- package/src/history.js +90 -0
- package/src/main.jsx +10 -0
- package/src/object-catalog.jsx +90 -0
- package/src/object-gizmo.jsx +755 -0
- package/src/planview.jsx +653 -0
- package/src/poses.js +424 -0
- package/src/posestudio.jsx +811 -0
- package/src/props.jsx +302 -0
- package/src/room.jsx +64 -0
- package/src/scene-history.js +125 -0
- package/src/scene-objects.js +420 -0
- package/src/shot-authoring.js +113 -0
- package/src/shot.js +239 -0
- package/src/styles.css +5639 -0
- package/src/ui.jsx +391 -0
- package/src/use-render-activity.js +96 -0
- package/tools/ardy/BRIDGE.md +255 -0
- package/tools/ardy/README.md +136 -0
- package/tools/ardy/__pycache__/cclay_sequence_generate.cpython-313.pyc +0 -0
- package/tools/ardy/bridge.mjs +1427 -0
- package/tools/ardy/cclay_motion_edit.py +445 -0
- package/tools/ardy/cclay_sequence_generate.py +595 -0
- package/tools/ardy/dump-npz.py +205 -0
- package/tools/ardy/extract-rest.mjs +299 -0
- package/tools/ardy/npz.mjs +335 -0
- package/tools/ardy/out/gen-1786443326924-c6019e-generated.npz +0 -0
- package/tools/ardy/out/gen-1786443609325-0053db-generated.npz +0 -0
- package/tools/ardy/out/gen-1786443835628-749ed1-generated.npz +0 -0
- package/tools/ardy/out/gen-1786462605247-03cb19-generated.npz +0 -0
- package/tools/ardy/pose-to-npz.mjs +106 -0
- package/tools/ardy/run-edit-on-box.sh +73 -0
- package/tools/ardy/run-on-box.sh +568 -0
- package/tools/ardy/run-sequence-on-box.sh +162 -0
- package/tools/ardy/visual-qa.mjs +188 -0
- package/tools/ardy/vq-car.mjs +52 -0
- package/tools/dev-full.mjs +29 -0
- package/tools/process-supervisor.mjs +63 -0
- package/tools/qa-browser.mjs +84 -0
- package/tools/qa-crop3.mjs +22 -0
- package/tools/qa-playview.mjs +49 -0
- package/tools/qa-screenshot.mjs +24 -0
- package/tools/qa-visual.mjs +51 -0
|
@@ -0,0 +1,755 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { useFrame, useThree } from "@react-three/fiber";
|
|
3
|
+
import * as THREE from "three";
|
|
4
|
+
import { GIZMO_LAYER, SHOT_ASPECT, fitAspect } from "./dualview.jsx";
|
|
5
|
+
import { objectSize, rotatePatch, scalePatch, screenRotatePatch, translatePatch, wrapAngle } from "./scene-objects.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The transform gizmo for the selected scene object — the thing every 3D tool
|
|
9
|
+
* has and this one did not: axis arrows to slide an object along X/Y/Z, rings
|
|
10
|
+
* to spin it and boxes to scale it, straight in the shot view, with the plan
|
|
11
|
+
* board's 5 cm / 5° detents so both views agree.
|
|
12
|
+
*
|
|
13
|
+
* Three deliberate choices:
|
|
14
|
+
*
|
|
15
|
+
* - Picking is owned here, not delegated to R3F. The shot camera renders into
|
|
16
|
+
* a letterboxed 16:9 sub-rect of the canvas, so R3F's canvas-wide pointer
|
|
17
|
+
* NDC is vertically off by the size of the bars and small cones would never
|
|
18
|
+
* be hit. The rect the camera actually drew into is what the ray is built
|
|
19
|
+
* from.
|
|
20
|
+
* - The listener sits on `window` in the CAPTURE phase. The fly-camera
|
|
21
|
+
* controls listen on the canvas element itself and were registered first;
|
|
22
|
+
* only a capture-phase listener further up the tree can stop a press on the
|
|
23
|
+
* gizmo — or on an object — from also swinging the camera.
|
|
24
|
+
* - Unity's division of labour: a plain left press SELECTS and nothing else,
|
|
25
|
+
* transforms only ever come from a handle, and a press on empty space clears
|
|
26
|
+
* the selection. Dragging the body used to move it, which made selecting an
|
|
27
|
+
* object — just to read its numbers — nudge the set. The plane handles below
|
|
28
|
+
* are the honest replacement for that.
|
|
29
|
+
* (docs/unity-reference.md §3.2, §6, §9.3, §9.6)
|
|
30
|
+
*
|
|
31
|
+
* The axis rings drive the object's X/Y/Z Euler channels directly. With the
|
|
32
|
+
* yaw-only rotations blocking actually uses that is exact; with all three
|
|
33
|
+
* channels live it is the usual world-axis-to-Euler approximation, same as
|
|
34
|
+
* any DCC's global-space rotate. The outer screen-space ring is different:
|
|
35
|
+
* a roll about the view axis legitimately touches all three channels at
|
|
36
|
+
* once, so its delta is composed onto the current orientation in quaternion
|
|
37
|
+
* space and decomposed back into the same channels (screenRotatePatch).
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
const AXES = [
|
|
41
|
+
{ axis: "x", dir: new THREE.Vector3(1, 0, 0), color: "#ff5340" },
|
|
42
|
+
{ axis: "y", dir: new THREE.Vector3(0, 1, 0), color: "#54e05c" },
|
|
43
|
+
{ axis: "z", dir: new THREE.Vector3(0, 0, 1), color: "#3d8bff" },
|
|
44
|
+
];
|
|
45
|
+
/** the same three directions, addressable by the record's axis key */
|
|
46
|
+
const AXIS_VECTORS = { x: AXES[0].dir, y: AXES[1].dir, z: AXES[2].dir };
|
|
47
|
+
/** Move-tool plane handles. Unity colours each square after the axis it LOCKS,
|
|
48
|
+
* so the blue square (z locked) slides in xy. `normal` is the pinned axis. */
|
|
49
|
+
const PLANES = [
|
|
50
|
+
{ id: "xz", axes: ["x", "z"], normal: "y", color: "#54e05c" },
|
|
51
|
+
{ id: "xy", axes: ["x", "y"], normal: "z", color: "#3d8bff" },
|
|
52
|
+
{ id: "yz", axes: ["y", "z"], normal: "x", color: "#ff5340" },
|
|
53
|
+
];
|
|
54
|
+
const PLANE_OFFSET = 0.26; // where the square sits along each of its two axes
|
|
55
|
+
const PLANE_SIZE = 0.2;
|
|
56
|
+
const ARROW_LEN = 0.62;
|
|
57
|
+
const SHAFT_R = 0.016;
|
|
58
|
+
const TIP_R = 0.05;
|
|
59
|
+
const TIP_LEN = 0.14;
|
|
60
|
+
const RING_R = 0.52;
|
|
61
|
+
/**
|
|
62
|
+
* The axis rings are drawn as two half-tori instead of one full torus. A full
|
|
63
|
+
* ring is misleading: the half facing away from the camera is pixel-identical
|
|
64
|
+
* to the near half, so a drag started on the far side reads backwards.
|
|
65
|
+
* Depth-sorting the ring against the object would only help when the object
|
|
66
|
+
* is big enough to occlude the far half — the gizmo sits at the object's
|
|
67
|
+
* centre and the ring is only 0.52 m across, so for the small props a
|
|
68
|
+
* blocking tool is full of, the far half projects outside the silhouette and
|
|
69
|
+
* stays ambiguous. Fading it instead (FAR_HALF_ALPHA vs the near half's
|
|
70
|
+
* 0.95) is unambiguous at every object size and camera angle. Only the drawn
|
|
71
|
+
* halves fade — the pick proxy stays one full invisible torus per ring.
|
|
72
|
+
*/
|
|
73
|
+
const FAR_HALF_ALPHA = 0.28;
|
|
74
|
+
/** the outer screen-space ring: larger than the axis rings (RING_R) so it
|
|
75
|
+
* encloses them from every angle, and far enough out that its pick band
|
|
76
|
+
* [0.61, 0.75] clears the axis rings' [0.45, 0.59] — the two can never fight
|
|
77
|
+
* for a grab. (§3.3: "a larger circle enclosing the sphere, drawn flat to
|
|
78
|
+
* the viewer" — rotates about the view axis.) */
|
|
79
|
+
const SCREEN_RING_R = 0.68;
|
|
80
|
+
/** Unity's yellow for the handle being dragged — and, after release, for the
|
|
81
|
+
* last-dragged one, until the selection or the tool changes (§3.1). */
|
|
82
|
+
const ACTIVE_COLOR = "#ffd23d";
|
|
83
|
+
const RING_TUBE = 0.022;
|
|
84
|
+
const BOX_SIZE = 0.09; // scale-mode axis knobs
|
|
85
|
+
const CENTRE_BOX = 0.11; // uniform-scale knob at the pivot
|
|
86
|
+
/** Pick proxies. A drawn 1.6 cm shaft is ~3 screen pixels — nothing anyone can
|
|
87
|
+
* reliably grab — so hit testing runs against invisible fat volumes around each
|
|
88
|
+
* arrow, ring and knob, the way every DCC gizmo does it. */
|
|
89
|
+
const PICK_SHAFT_R = 0.09;
|
|
90
|
+
const PICK_TUBE_R = 0.07;
|
|
91
|
+
const PICK_CENTRE_R = 0.16;
|
|
92
|
+
/** metres of gizmo per metre of camera distance: keeps it a constant size on
|
|
93
|
+
* screen whether the object is at the lens or across the room */
|
|
94
|
+
const SCREEN_SCALE = 0.16;
|
|
95
|
+
|
|
96
|
+
/** the object's own centre height, where the gizmo sits */
|
|
97
|
+
function gizmoHeight(object) {
|
|
98
|
+
return (object.y ?? 0) + Math.max(objectSize(object).height / 2, 0.25);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** the handles-Map key a pick entry was registered under — the same
|
|
102
|
+
* expression __gizmoPick reports, so highlights and QA hooks agree on handle
|
|
103
|
+
* identity */
|
|
104
|
+
function handleKey(entry) {
|
|
105
|
+
return entry.axis ?? (entry.plane ? `plane:${entry.plane.id}` : "centre");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Screen-space ring result: the object rotated `deltaDeg` about the world
|
|
110
|
+
* `viewAxis`, written back as all three Euler channels in one patch. The
|
|
111
|
+
* stored channels are Euler degrees per world axis, so the drag-start
|
|
112
|
+
* orientation is composed with the delta in quaternion space and decomposed
|
|
113
|
+
* again in the same order the renderer uses — exact for any start
|
|
114
|
+
* orientation, where a single-channel approximation would drift. Snap
|
|
115
|
+
* quantizes the accumulated delta to the same 5° grid the axis rings snap
|
|
116
|
+
* their start+delta to (start values are always on the grid themselves).
|
|
117
|
+
*/
|
|
118
|
+
|
|
119
|
+
// Path authoring intersects presses with the set floor, exactly where a
|
|
120
|
+
// dropped pin will live.
|
|
121
|
+
const GROUND = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
|
|
122
|
+
|
|
123
|
+
export default function ObjectGizmo({ object, objects = [], mode = "move", snap = true, enabled, paneRef, camRef, onChange, onSelect, onGroundClick, onDragStart, onDragEnd }) {
|
|
124
|
+
const { gl, scene } = useThree();
|
|
125
|
+
const rootRef = useRef(null);
|
|
126
|
+
const handlesRef = useRef(new Map()); // axis -> { mesh (pick proxy), axis, dir }
|
|
127
|
+
const screenRingRef = useRef(null); // the outer ring's group, billboarded to the camera each frame
|
|
128
|
+
const ringGroupsRef = useRef({}); // axis -> the group whose rotation.z picks that ring's near half
|
|
129
|
+
const [activeHandle, setActiveHandle] = useState(null); // the handle under a drag — and, after release, the last one (§3.1)
|
|
130
|
+
const [hoveredHandle, setHoveredHandle] = useState(null); // the handle under the pointer
|
|
131
|
+
const hoverRef = useRef(null); // last hovered key: pointer moves that keep it skip the re-render
|
|
132
|
+
const dragRef = useRef(null);
|
|
133
|
+
const stateRef = useRef(null);
|
|
134
|
+
stateRef.current = { object, objects, mode, snap, onChange, onSelect, onGroundClick, onDragStart, onDragEnd };
|
|
135
|
+
const tools = useMemo(
|
|
136
|
+
() => ({
|
|
137
|
+
raycaster: new THREE.Raycaster(),
|
|
138
|
+
ndc: new THREE.Vector2(),
|
|
139
|
+
hit: new THREE.Vector3(),
|
|
140
|
+
delta: new THREE.Vector3(),
|
|
141
|
+
eye: new THREE.Vector3(),
|
|
142
|
+
origin: new THREE.Vector3(),
|
|
143
|
+
camPos: new THREE.Vector3(),
|
|
144
|
+
}),
|
|
145
|
+
[],
|
|
146
|
+
);
|
|
147
|
+
/** each axis ring's in-plane basis in world space (u, v), for the
|
|
148
|
+
* per-frame near/far split — the torus sweeps cosθ·u + sinθ·v around the
|
|
149
|
+
* axis, and the frame loop only needs the world images of the group's
|
|
150
|
+
* local X and Z */
|
|
151
|
+
const ringBases = useMemo(
|
|
152
|
+
() =>
|
|
153
|
+
AXES.map(({ axis, dir }) => {
|
|
154
|
+
const quat = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir);
|
|
155
|
+
return {
|
|
156
|
+
axis,
|
|
157
|
+
u: new THREE.Vector3(1, 0, 0).applyQuaternion(quat),
|
|
158
|
+
v: new THREE.Vector3(0, 0, 1).applyQuaternion(quat),
|
|
159
|
+
};
|
|
160
|
+
}),
|
|
161
|
+
[],
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
// Unity keeps the last-dragged handle yellow until the selection or the
|
|
165
|
+
// tool changes (§3.1). The scope the highlight was captured in is compared
|
|
166
|
+
// each render; when either moves, the stored key no longer names a handle
|
|
167
|
+
// in this view. (Render-phase state adjustment: conditional, and it
|
|
168
|
+
// converges because the refs flip first.)
|
|
169
|
+
const activeScopeRef = useRef(null);
|
|
170
|
+
if (activeScopeRef.current && (activeScopeRef.current.id !== object?.id || activeScopeRef.current.mode !== mode)) {
|
|
171
|
+
setActiveHandle(null);
|
|
172
|
+
if (hoverRef.current !== null) {
|
|
173
|
+
hoverRef.current = null;
|
|
174
|
+
setHoveredHandle(null);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
activeScopeRef.current = { id: object?.id, mode };
|
|
178
|
+
|
|
179
|
+
/** pointer -> NDC inside the rect the shot camera actually rendered into */
|
|
180
|
+
const toNdc = (event) => {
|
|
181
|
+
const pane = paneRef?.current;
|
|
182
|
+
const camera = camRef?.current;
|
|
183
|
+
if (!pane || !camera) return null;
|
|
184
|
+
const bounds = pane.getBoundingClientRect();
|
|
185
|
+
if (bounds.width < 2 || bounds.height < 2) return null;
|
|
186
|
+
const rect = fitAspect({ x: bounds.left, y: bounds.top, w: bounds.width, h: bounds.height }, SHOT_ASPECT);
|
|
187
|
+
tools.ndc.set(
|
|
188
|
+
((event.clientX - rect.x) / rect.w) * 2 - 1,
|
|
189
|
+
-((event.clientY - rect.y) / rect.h) * 2 + 1,
|
|
190
|
+
);
|
|
191
|
+
return Math.abs(tools.ndc.x) <= 1 && Math.abs(tools.ndc.y) <= 1 ? tools.ndc : null;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
/** the scene object under the pointer, if any (meshes carry the id on an
|
|
195
|
+
* ancestor group, so the hit walks up to find it) */
|
|
196
|
+
const pickObject = () => {
|
|
197
|
+
// Edge linework (EdgesGeometry LineSegments) raycasts with a generous
|
|
198
|
+
// distance threshold, so it would claim pixels far from the solid mesh.
|
|
199
|
+
// Picking walks past lines/points to the first real surface.
|
|
200
|
+
const hits = tools.raycaster.intersectObjects(scene.children, true);
|
|
201
|
+
const hit = hits.find((entry) => entry.object.isMesh);
|
|
202
|
+
if (!hit) return null;
|
|
203
|
+
for (let node = hit.object; node; node = node.parent) {
|
|
204
|
+
if (node.userData?.sceneObjectId) return { id: node.userData.sceneObjectId, point: hit.point.clone() };
|
|
205
|
+
// Characters are click targets too: a namespaced id routes the
|
|
206
|
+
// selection to the hierarchy, so the Inspector owns the controls.
|
|
207
|
+
if (node.userData?.characterPick) return { id: `char:${node.userData.characterPick}`, point: hit.point.clone() };
|
|
208
|
+
}
|
|
209
|
+
return null; // whatever is in front is set, not an object
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
useEffect(() => {
|
|
213
|
+
if (!enabled) return undefined;
|
|
214
|
+
|
|
215
|
+
/** aims the shared raycaster through the pointer; null when the shot
|
|
216
|
+
* camera or the pane is not measurable yet */
|
|
217
|
+
const rayFrom = (event) => {
|
|
218
|
+
const camera = camRef?.current;
|
|
219
|
+
const ndc = camera ? toNdc(event) : null;
|
|
220
|
+
if (!ndc) return null;
|
|
221
|
+
// Picking happens outside the render loop: under demand rendering a
|
|
222
|
+
// programmatic camera move (framing, preset snap) may not have had a
|
|
223
|
+
// frame yet, and setFromCamera reads matrixWorld as-is. Refresh it
|
|
224
|
+
// here so the first pick after an idle gap aims from the true pose.
|
|
225
|
+
// The shot camera's aspect is locked to SHOT_ASPECT by the render
|
|
226
|
+
// loop (dualview); under demand rendering a layout change may not
|
|
227
|
+
// have had a frame yet, leaving a stale projection. Re-apply the
|
|
228
|
+
// render contract here so the pick matches what is on screen.
|
|
229
|
+
camera.aspect = SHOT_ASPECT;
|
|
230
|
+
camera.updateProjectionMatrix();
|
|
231
|
+
camera.updateMatrixWorld();
|
|
232
|
+
camera.matrixWorldInverse.copy(camera.matrixWorld).invert();
|
|
233
|
+
rootRef.current?.updateMatrixWorld(true);
|
|
234
|
+
// object meshes move with the drag record; between frames their
|
|
235
|
+
// matrixWorld can lag the same way, so refresh the scene too
|
|
236
|
+
scene.updateMatrixWorld();
|
|
237
|
+
tools.raycaster.setFromCamera(ndc, camera);
|
|
238
|
+
return { ndc, camera };
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const applyDrag = (event) => {
|
|
242
|
+
const drag = dragRef.current;
|
|
243
|
+
if (!drag) return;
|
|
244
|
+
if (!rayFrom(event)) return;
|
|
245
|
+
if (!tools.raycaster.ray.intersectPlane(drag.plane, tools.hit)) return;
|
|
246
|
+
const { objects: all, onChange: change, snap: snapOn } = stateRef.current;
|
|
247
|
+
const live = all.find((entry) => entry.id === drag.id);
|
|
248
|
+
if (!live) return;
|
|
249
|
+
// Unity's polarity: a drag is continuous, and Ctrl/Cmd snaps it to the
|
|
250
|
+
// increment. A persistent Snap toggle flips which one the modifier
|
|
251
|
+
// gives you, so the always-on 5 cm grid stays available as a choice
|
|
252
|
+
// instead of a law. (docs/unity-reference.md §5.2, §9.5)
|
|
253
|
+
const snapping = snapOn !== (event.ctrlKey || event.metaKey);
|
|
254
|
+
if (drag.mode === "rotate") {
|
|
255
|
+
tools.delta.subVectors(tools.hit, drag.origin);
|
|
256
|
+
const angle = Math.atan2(tools.delta.dot(drag.binormal), tools.delta.dot(drag.tangent));
|
|
257
|
+
// Accumulate wrapped increments. Reading the raw difference from
|
|
258
|
+
// the drag-start angle instead makes the object snap a full turn
|
|
259
|
+
// the moment the pointer crosses the ring's ±180° seam.
|
|
260
|
+
drag.turned += wrapAngle(((angle - drag.lastAngle) * 180) / Math.PI);
|
|
261
|
+
drag.lastAngle = angle;
|
|
262
|
+
if (drag.axis === "screen") {
|
|
263
|
+
// The screen ring spins about the view axis: compose the
|
|
264
|
+
// delta onto the drag-start orientation in quaternion space
|
|
265
|
+
// and write all three Euler channels back in one patch.
|
|
266
|
+
const patch = screenRotatePatch(drag.start, drag.dir, drag.turned, snapping ? undefined : 0);
|
|
267
|
+
if (patch) change?.(drag.id, patch, drag.token);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
const patch = rotatePatch(drag.start, drag.axis, drag.turned, snapping ? undefined : 0);
|
|
271
|
+
if (patch) change?.(drag.id, patch, drag.token);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
if (drag.mode === "scale") {
|
|
275
|
+
// A ratio against the grab point's leverage, so the knob keeps
|
|
276
|
+
// tracking the cursor and a drag toward the pivot shrinks.
|
|
277
|
+
tools.delta.subVectors(tools.hit, drag.origin);
|
|
278
|
+
const travel = tools.delta.dot(drag.dir) - drag.startAlong;
|
|
279
|
+
const patch = scalePatch(drag.start, drag.axis, 1 + travel / drag.reference, snapping ? undefined : 0);
|
|
280
|
+
if (patch) change?.(drag.id, patch, drag.token);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
tools.delta.subVectors(tools.hit, drag.hitStart);
|
|
284
|
+
if (drag.planeAxes) {
|
|
285
|
+
// Plane handle: two axes travel, the third is pinned. This is what
|
|
286
|
+
// replaced body-dragging — the XZ square is how a set gets dressed.
|
|
287
|
+
const patch = {};
|
|
288
|
+
for (const axis of drag.planeAxes) {
|
|
289
|
+
Object.assign(patch, translatePatch(drag.start, axis, tools.delta.dot(AXIS_VECTORS[axis]), snapping ? undefined : 0));
|
|
290
|
+
}
|
|
291
|
+
change?.(drag.id, patch, drag.token);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const patch = translatePatch(drag.start, drag.axis, tools.delta.dot(drag.dir), snapping ? undefined : 0);
|
|
295
|
+
if (patch) change?.(drag.id, patch, drag.token);
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
/** pure teardown: listeners down, drag ref null, cursor reset. This is
|
|
299
|
+
* also the `cancel` handed to the coordinator — teardown only, it never
|
|
300
|
+
* calls the end-style prop, because the coordinator owns the close
|
|
301
|
+
* (§5.2). */
|
|
302
|
+
const teardownDrag = () => {
|
|
303
|
+
if (!dragRef.current) return;
|
|
304
|
+
dragRef.current = null;
|
|
305
|
+
gl.domElement.style.cursor = "";
|
|
306
|
+
window.removeEventListener("pointermove", applyDrag);
|
|
307
|
+
window.removeEventListener("pointerup", onPointerUp);
|
|
308
|
+
window.removeEventListener("pointercancel", onPointerCancel);
|
|
309
|
+
window.removeEventListener("blur", onBlur);
|
|
310
|
+
window.removeEventListener("keydown", onKeyDown, true);
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
/** close the live drag with the given disposition. The token the
|
|
314
|
+
* coordinator issued is presented on the close; without one (the
|
|
315
|
+
* no-props fallback) the close is teardown-only, exactly today's
|
|
316
|
+
* behaviour. */
|
|
317
|
+
const endDrag = (commit) => {
|
|
318
|
+
const drag = dragRef.current;
|
|
319
|
+
if (!drag) return;
|
|
320
|
+
const end = stateRef.current.onDragEnd;
|
|
321
|
+
const token = drag.token;
|
|
322
|
+
teardownDrag();
|
|
323
|
+
if (token != null && end) end(token, { commit });
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
/** pointerup and pointercancel both commit: the travel already applied
|
|
327
|
+
* is work the user watched happen, and pointer loss is not intent to
|
|
328
|
+
* discard (§6.3). */
|
|
329
|
+
const onPointerUp = () => endDrag(true);
|
|
330
|
+
const onPointerCancel = () => endDrag(true);
|
|
331
|
+
|
|
332
|
+
/** window blur commits the drag as one entry — losing focus mid-drag is
|
|
333
|
+
* not an abort gesture, and silently rolling back work the user saw
|
|
334
|
+
* applied is the "undo eats work" failure (§6.3, §14.2). */
|
|
335
|
+
const onBlur = () => endDrag(true);
|
|
336
|
+
|
|
337
|
+
/** Escape cancels an in-flight drag: rollback, listeners down. Capture
|
|
338
|
+
* phase so stopPropagation keeps the same press away from App's
|
|
339
|
+
* Escape-clears-selection handler; with no drag open it returns without
|
|
340
|
+
* touching the event, so App still gets its deselect (§7). */
|
|
341
|
+
const onKeyDown = (event) => {
|
|
342
|
+
if ((event.code !== "Escape" && event.key !== "Escape") || !dragRef.current) return;
|
|
343
|
+
endDrag(false);
|
|
344
|
+
event.preventDefault();
|
|
345
|
+
event.stopPropagation();
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
/** Starts a `kind` drag ("move" / "rotate" / "scale"). The raycaster is
|
|
349
|
+
* already aimed at the pointer by the pick that got us here; `axis` is
|
|
350
|
+
* null for the uniform-scale knob and for plane handles, which carry
|
|
351
|
+
* their own `plane` axes instead. */
|
|
352
|
+
const beginDrag = (kind, axis, dir, camera) => {
|
|
353
|
+
const live = stateRef.current.object;
|
|
354
|
+
if (!live) return false;
|
|
355
|
+
tools.origin.set(live.x, gizmoHeight(live), live.z);
|
|
356
|
+
camera.getWorldDirection(tools.eye);
|
|
357
|
+
let plane;
|
|
358
|
+
let drag;
|
|
359
|
+
if (kind === "rotate") {
|
|
360
|
+
// spin plane: perpendicular to the ring's axis, through the
|
|
361
|
+
// pivot. The screen ring has no axis of its own: it spins
|
|
362
|
+
// about the camera's own forward, so its plane is the
|
|
363
|
+
// camera-facing plane through the pivot.
|
|
364
|
+
const spinDir = axis === "screen" ? tools.eye : dir;
|
|
365
|
+
plane = new THREE.Plane().setFromNormalAndCoplanarPoint(spinDir, tools.origin.clone());
|
|
366
|
+
const hit = new THREE.Vector3();
|
|
367
|
+
if (!tools.raycaster.ray.intersectPlane(plane, hit)) return false;
|
|
368
|
+
// a stable in-plane basis to read the pointer's angle from
|
|
369
|
+
const tangent = new THREE.Vector3(spinDir.y, spinDir.z, spinDir.x).cross(spinDir).normalize();
|
|
370
|
+
const binormal = spinDir.clone().cross(tangent).normalize();
|
|
371
|
+
const offset = hit.clone().sub(tools.origin);
|
|
372
|
+
if (offset.lengthSq() < 1e-8) return false;
|
|
373
|
+
drag = {
|
|
374
|
+
mode: "rotate",
|
|
375
|
+
tangent,
|
|
376
|
+
binormal,
|
|
377
|
+
lastAngle: Math.atan2(offset.dot(binormal), offset.dot(tangent)),
|
|
378
|
+
turned: 0,
|
|
379
|
+
origin: tools.origin.clone(),
|
|
380
|
+
dir: spinDir.clone(), // the world axis the delta spins about (the ring's, or the view's)
|
|
381
|
+
};
|
|
382
|
+
} else if (kind === "scale") {
|
|
383
|
+
// Scale reads a ratio against the grab point's distance from the
|
|
384
|
+
// pivot, so the knob tracks the cursor. The centre knob has no
|
|
385
|
+
// axis of its own: it rides the camera's right vector.
|
|
386
|
+
const dragDir = axis ? dir.clone() : new THREE.Vector3().crossVectors(tools.eye, new THREE.Vector3(0, 1, 0)).normalize();
|
|
387
|
+
const normal = dragDir.clone().cross(tools.eye).cross(dragDir).normalize();
|
|
388
|
+
plane = new THREE.Plane().setFromNormalAndCoplanarPoint(normal, tools.origin.clone());
|
|
389
|
+
const hit = new THREE.Vector3();
|
|
390
|
+
if (!tools.raycaster.ray.intersectPlane(plane, hit)) return false;
|
|
391
|
+
const startAlong = hit.clone().sub(tools.origin).dot(dragDir);
|
|
392
|
+
// A grab right on the pivot has no leverage; fall back to the
|
|
393
|
+
// gizmo's own length so the first move is still sane.
|
|
394
|
+
const reference = Math.abs(startAlong) < 0.05 ? (rootRef.current?.scale.x ?? 1) * ARROW_LEN : startAlong;
|
|
395
|
+
drag = { mode: "scale", dir: dragDir, startAlong, reference, origin: tools.origin.clone() };
|
|
396
|
+
} else if (kind === "plane") {
|
|
397
|
+
// The handle's own plane, pinned through the pivot: the two loose
|
|
398
|
+
// axes are read straight off the hit point.
|
|
399
|
+
const normal = AXIS_VECTORS[dir.normal];
|
|
400
|
+
plane = new THREE.Plane().setFromNormalAndCoplanarPoint(normal, tools.origin.clone());
|
|
401
|
+
const hit = new THREE.Vector3();
|
|
402
|
+
if (!tools.raycaster.ray.intersectPlane(plane, hit)) return false;
|
|
403
|
+
drag = { mode: "move", planeAxes: dir.axes, hitStart: hit };
|
|
404
|
+
} else {
|
|
405
|
+
// slide plane: contains the axis and faces the camera as squarely
|
|
406
|
+
// as it can, the same construction TransformControls uses
|
|
407
|
+
const normal = dir.clone().cross(tools.eye).cross(dir).normalize();
|
|
408
|
+
plane = new THREE.Plane().setFromNormalAndCoplanarPoint(normal, tools.origin.clone());
|
|
409
|
+
const hit = new THREE.Vector3();
|
|
410
|
+
if (!tools.raycaster.ray.intersectPlane(plane, hit)) return false;
|
|
411
|
+
drag = { mode: "move", dir: dir.clone(), hitStart: hit };
|
|
412
|
+
}
|
|
413
|
+
// The coordinator (App) issues the token; the gizmo keeps it on its
|
|
414
|
+
// own drag ref and presents it on every apply and on the close, so
|
|
415
|
+
// a resumed pointer stream after a settle is inert by construction
|
|
416
|
+
// (§6.1). The `cancel` handed over is teardown-only — the
|
|
417
|
+
// coordinator owns the close, and a close attempted from inside it
|
|
418
|
+
// is inert because the token is retired first (§5.2).
|
|
419
|
+
const token = stateRef.current.onDragStart?.({
|
|
420
|
+
owner: "gizmo",
|
|
421
|
+
cancel: teardownDrag,
|
|
422
|
+
});
|
|
423
|
+
dragRef.current = { ...drag, id: live.id, axis, plane, start: { ...live }, token };
|
|
424
|
+
// the grabbed handle turns yellow now and stays yellow after
|
|
425
|
+
// release, until the selection or the tool changes (§3.1)
|
|
426
|
+
setActiveHandle(handleKey({ axis, plane }));
|
|
427
|
+
gl.domElement.style.cursor = "grabbing";
|
|
428
|
+
window.addEventListener("pointermove", applyDrag);
|
|
429
|
+
window.addEventListener("pointerup", onPointerUp);
|
|
430
|
+
window.addEventListener("pointercancel", onPointerCancel);
|
|
431
|
+
// Only a real transaction gets the extra lifecycle edges: blur and
|
|
432
|
+
// Escape would otherwise change mid-drag behaviour for callers that
|
|
433
|
+
// pass no transaction props (the byte-for-byte fallback path).
|
|
434
|
+
if (token != null) {
|
|
435
|
+
window.addEventListener("blur", onBlur);
|
|
436
|
+
window.addEventListener("keydown", onKeyDown, true);
|
|
437
|
+
}
|
|
438
|
+
return true;
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
/** the gizmo handle under the pointer, if any */
|
|
442
|
+
const pickHandle = (event) => {
|
|
443
|
+
const aim = rayFrom(event);
|
|
444
|
+
if (!aim) return null;
|
|
445
|
+
// entries whose mesh has left the scene (mode switch, deselect) are
|
|
446
|
+
// stale registrations, not pick targets
|
|
447
|
+
const handles = [...handlesRef.current.values()].filter((entry) => entry.mesh?.parent);
|
|
448
|
+
if (!handles.length) return null;
|
|
449
|
+
tools.raycaster.layers.set(GIZMO_LAYER);
|
|
450
|
+
const hits = tools.raycaster.intersectObjects(handles.map((entry) => entry.mesh), false)
|
|
451
|
+
if (!hits.length) return null;
|
|
452
|
+
const grabbed = handles.find((entry) => entry.mesh === hits[0].object);
|
|
453
|
+
return grabbed ? { ...grabbed, aim } : null;
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
const onDown = (event) => {
|
|
457
|
+
// Plain left only. Alt+left orbits, middle pans, right flies — those
|
|
458
|
+
// belong to the camera and must pass straight through.
|
|
459
|
+
if (event.button !== 0 || event.altKey || event.target !== gl.domElement) return;
|
|
460
|
+
const grabbed = pickHandle(event);
|
|
461
|
+
if (grabbed && beginDrag(grabbed.plane ? "plane" : stateRef.current.mode, grabbed.axis, grabbed.plane ?? grabbed.dir, grabbed.aim.camera)) {
|
|
462
|
+
// keep this press away from the fly camera
|
|
463
|
+
event.preventDefault();
|
|
464
|
+
event.stopPropagation();
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
// Selection. A left press on a body selects it and does nothing else;
|
|
468
|
+
// a press on empty space clears the selection. Both claim the press so
|
|
469
|
+
// the fly camera cannot also react — navigation lives on the right and
|
|
470
|
+
// middle buttons now.
|
|
471
|
+
if (!rayFrom(event)) return;
|
|
472
|
+
tools.raycaster.layers.set(0);
|
|
473
|
+
const picked = pickObject();
|
|
474
|
+
event.preventDefault();
|
|
475
|
+
event.stopPropagation();
|
|
476
|
+
// Path authoring outranks deselection: an empty-floor press drops a
|
|
477
|
+
// waypoint where the ray meets the deck. Presses on bodies still select.
|
|
478
|
+
if (!picked && stateRef.current.onGroundClick && tools.raycaster.ray.intersectPlane(GROUND, tools.hit)) {
|
|
479
|
+
stateRef.current.onGroundClick({ x: tools.hit.x, z: tools.hit.z });
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
stateRef.current.onSelect?.(picked ? picked.id : null);
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
const onHover = (event) => {
|
|
486
|
+
// A held button is a camera look-drag: no picking work per frame.
|
|
487
|
+
if (dragRef.current || event.buttons !== 0 || event.target !== gl.domElement) return;
|
|
488
|
+
const picked = pickHandle(event);
|
|
489
|
+
const key = picked ? handleKey(picked) : null;
|
|
490
|
+
// Re-render only when the handle under the pointer actually
|
|
491
|
+
// changes; a plain move across one handle would otherwise re-render
|
|
492
|
+
// the gizmo every pointer tick.
|
|
493
|
+
if (key !== hoverRef.current) {
|
|
494
|
+
hoverRef.current = key;
|
|
495
|
+
setHoveredHandle(key);
|
|
496
|
+
}
|
|
497
|
+
if (picked) {
|
|
498
|
+
gl.domElement.style.cursor = "grab";
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
tools.raycaster.layers.set(0);
|
|
502
|
+
gl.domElement.style.cursor = pickObject() ? "pointer" : "";
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
// QA hooks: the real pickers, addressable from a headless check.
|
|
506
|
+
window.__gizmoPick = (x, y) => {
|
|
507
|
+
const grabbed = pickHandle({ clientX: x, clientY: y, button: 0 });
|
|
508
|
+
if (!grabbed) return null;
|
|
509
|
+
return grabbed.axis ?? (grabbed.plane ? `plane:${grabbed.plane.id}` : "centre");
|
|
510
|
+
};
|
|
511
|
+
window.__objectPick = (x, y) => {
|
|
512
|
+
if (!rayFrom({ clientX: x, clientY: y })) return null;
|
|
513
|
+
tools.raycaster.layers.set(0);
|
|
514
|
+
return pickObject()?.id ?? null;
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
window.addEventListener("pointerdown", onDown, true);
|
|
518
|
+
gl.domElement.addEventListener("pointermove", onHover);
|
|
519
|
+
return () => {
|
|
520
|
+
window.removeEventListener("pointerdown", onDown, true);
|
|
521
|
+
gl.domElement.removeEventListener("pointermove", onHover);
|
|
522
|
+
// unmount / enabled->false: the drag's travel is real work, so it
|
|
523
|
+
// commits as one entry rather than silently rolling back (§6.3)
|
|
524
|
+
endDrag(true);
|
|
525
|
+
};
|
|
526
|
+
}, [enabled, gl, scene, camRef, paneRef, tools]);
|
|
527
|
+
|
|
528
|
+
// Constant on-screen size: the gizmo is UI, not set dressing.
|
|
529
|
+
useFrame(() => {
|
|
530
|
+
const root = rootRef.current;
|
|
531
|
+
const camera = camRef?.current;
|
|
532
|
+
if (!root || !camera || !object) return;
|
|
533
|
+
camera.getWorldPosition(tools.camPos);
|
|
534
|
+
tools.origin.set(object.x, gizmoHeight(object), object.z);
|
|
535
|
+
root.scale.setScalar(Math.max(0.35, tools.camPos.distanceTo(tools.origin) * SCREEN_SCALE));
|
|
536
|
+
if (mode !== "rotate") return;
|
|
537
|
+
// The rotate rings are view-dependent, so their orientation is frame
|
|
538
|
+
// state: the screen ring billboards flat to the camera (the shot
|
|
539
|
+
// camera is a bare scene child, so its quaternion is world space),
|
|
540
|
+
// and each axis ring's near/far half boundary is the camera-facing
|
|
541
|
+
// plane through the pivot.
|
|
542
|
+
camera.getWorldDirection(tools.eye);
|
|
543
|
+
const screen = screenRingRef.current;
|
|
544
|
+
if (screen) screen.quaternion.copy(camera.quaternion);
|
|
545
|
+
for (const { axis, u, v } of ringBases) {
|
|
546
|
+
const ring = ringGroupsRef.current[axis];
|
|
547
|
+
if (!ring) continue;
|
|
548
|
+
// The near half points at the camera: its centre is −forward
|
|
549
|
+
// projected onto the ring plane, i.e. atan2(−f·v, −f·u) in the
|
|
550
|
+
// ring's (u, v) basis. The far half sits at φ + π.
|
|
551
|
+
ring.rotation.z = Math.atan2(-tools.eye.dot(v), -tools.eye.dot(u));
|
|
552
|
+
}
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
// QA hook: where each handle sits on screen, through the exact rect the
|
|
556
|
+
// picker builds its ray from. Headless checks drive real pointer events
|
|
557
|
+
// with it (harmless in normal use).
|
|
558
|
+
if (typeof window !== "undefined") {
|
|
559
|
+
window.__gizmoHandles = () => {
|
|
560
|
+
const camera = camRef?.current;
|
|
561
|
+
const pane = paneRef?.current;
|
|
562
|
+
if (!camera || !pane) return [];
|
|
563
|
+
// reads happen between frames (QA, headless checks): sync the
|
|
564
|
+
// matrices the projection depends on instead of trusting the last
|
|
565
|
+
// render tick under demand rendering, and re-apply the render
|
|
566
|
+
// loop's locked aspect (dualview) so QA geometry matches the
|
|
567
|
+
// drawn frame exactly
|
|
568
|
+
camera.aspect = SHOT_ASPECT;
|
|
569
|
+
camera.updateProjectionMatrix();
|
|
570
|
+
camera.updateMatrixWorld();
|
|
571
|
+
camera.matrixWorldInverse.copy(camera.matrixWorld).invert();
|
|
572
|
+
rootRef.current?.updateMatrixWorld(true);
|
|
573
|
+
const bounds = pane.getBoundingClientRect();
|
|
574
|
+
const rect = fitAspect({ x: bounds.left, y: bounds.top, w: bounds.width, h: bounds.height }, SHOT_ASPECT);
|
|
575
|
+
return [...handlesRef.current.values()]
|
|
576
|
+
.filter((entry) => entry.mesh?.parent)
|
|
577
|
+
.map(({ mesh, axis, plane }) => {
|
|
578
|
+
const point = mesh.getWorldPosition(new THREE.Vector3()).project(camera);
|
|
579
|
+
return {
|
|
580
|
+
axis: axis ?? (plane ? `plane:${plane.id}` : "centre"),
|
|
581
|
+
x: rect.x + ((point.x + 1) / 2) * rect.w,
|
|
582
|
+
y: rect.y + ((1 - point.y) / 2) * rect.h,
|
|
583
|
+
};
|
|
584
|
+
});
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
if (!enabled || !object) {
|
|
589
|
+
handlesRef.current.clear();
|
|
590
|
+
return null;
|
|
591
|
+
}
|
|
592
|
+
// Keyed registration: a re-render replaces the pick proxy in place instead
|
|
593
|
+
// of stacking a second copy of the gizmo in the pick list.
|
|
594
|
+
const register = (axis, dir) => (mesh) => {
|
|
595
|
+
if (!mesh) return;
|
|
596
|
+
mesh.layers.set(GIZMO_LAYER);
|
|
597
|
+
handlesRef.current.set(axis ?? "centre", { mesh, axis, dir });
|
|
598
|
+
};
|
|
599
|
+
/** the group whose rotation.z picks which half of this axis ring is near */
|
|
600
|
+
const registerRingGroup = (axis) => (group) => {
|
|
601
|
+
ringGroupsRef.current[axis] = group;
|
|
602
|
+
};
|
|
603
|
+
/** a plane square: no single axis, so it carries its own two-axis descriptor */
|
|
604
|
+
const registerPlane = (plane) => (mesh) => {
|
|
605
|
+
if (!mesh) return;
|
|
606
|
+
mesh.layers.set(GIZMO_LAYER);
|
|
607
|
+
handlesRef.current.set(`plane:${plane.id}`, { mesh, axis: null, plane });
|
|
608
|
+
};
|
|
609
|
+
/** invisible-but-raycastable pick volume */
|
|
610
|
+
const pickMaterial = <meshBasicMaterial visible={false} />;
|
|
611
|
+
|
|
612
|
+
const screenActive = activeHandle === "screen";
|
|
613
|
+
const screenHovered = hoveredHandle === "screen";
|
|
614
|
+
const centreActive = activeHandle === "centre";
|
|
615
|
+
const centreHovered = hoveredHandle === "centre";
|
|
616
|
+
return (
|
|
617
|
+
<group ref={rootRef} position={[object.x, gizmoHeight(object), object.z]} renderOrder={999}>
|
|
618
|
+
{AXES.map(({ axis, dir, color }) => {
|
|
619
|
+
const quat = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir);
|
|
620
|
+
// The handle under a drag — and, after release, the last one —
|
|
621
|
+
// is yellow; the one under the pointer is brighter (§3.1).
|
|
622
|
+
const isActive = activeHandle === axis;
|
|
623
|
+
const isHovered = hoveredHandle === axis;
|
|
624
|
+
const emissive = isActive ? ACTIVE_COLOR : color;
|
|
625
|
+
const emissiveIntensity = isActive ? 2.2 : isHovered ? 3.6 : 2.2;
|
|
626
|
+
const material = (opacity = 0.95) => (
|
|
627
|
+
<meshStandardMaterial
|
|
628
|
+
color="#000000"
|
|
629
|
+
emissive={emissive}
|
|
630
|
+
emissiveIntensity={emissiveIntensity}
|
|
631
|
+
toneMapped={false}
|
|
632
|
+
depthTest={false}
|
|
633
|
+
depthWrite={false}
|
|
634
|
+
transparent
|
|
635
|
+
opacity={opacity}
|
|
636
|
+
/>
|
|
637
|
+
);
|
|
638
|
+
return (
|
|
639
|
+
<group key={axis} quaternion={quat}>
|
|
640
|
+
{mode === "rotate" ? (
|
|
641
|
+
<group ref={registerRingGroup(axis)} rotation={[Math.PI / 2, 0, 0]}>
|
|
642
|
+
{/* near half: full brightness */}
|
|
643
|
+
<mesh renderOrder={999}>
|
|
644
|
+
<torusGeometry args={[RING_R, RING_TUBE, 8, 24, Math.PI]} />
|
|
645
|
+
{material()}
|
|
646
|
+
</mesh>
|
|
647
|
+
{/* far half: faded so it reads as behind (see FAR_HALF_ALPHA) */}
|
|
648
|
+
<mesh rotation={[0, 0, Math.PI]} renderOrder={999}>
|
|
649
|
+
<torusGeometry args={[RING_R, RING_TUBE, 8, 24, Math.PI]} />
|
|
650
|
+
{material(FAR_HALF_ALPHA)}
|
|
651
|
+
</mesh>
|
|
652
|
+
<mesh ref={register(axis, dir)}>
|
|
653
|
+
<torusGeometry args={[RING_R, PICK_TUBE_R, 6, 32]} />
|
|
654
|
+
{pickMaterial}
|
|
655
|
+
</mesh>
|
|
656
|
+
</group>
|
|
657
|
+
) : (
|
|
658
|
+
<>
|
|
659
|
+
<mesh position={[0, ARROW_LEN / 2, 0]} renderOrder={999}>
|
|
660
|
+
<cylinderGeometry args={[SHAFT_R, SHAFT_R, ARROW_LEN, 8]} />
|
|
661
|
+
{material()}
|
|
662
|
+
</mesh>
|
|
663
|
+
<mesh position={[0, ARROW_LEN + (mode === "scale" ? BOX_SIZE / 2 : TIP_LEN / 2), 0]} renderOrder={999}>
|
|
664
|
+
{mode === "scale" ? (
|
|
665
|
+
<boxGeometry args={[BOX_SIZE, BOX_SIZE, BOX_SIZE]} />
|
|
666
|
+
) : (
|
|
667
|
+
<coneGeometry args={[TIP_R, TIP_LEN, 14]} />
|
|
668
|
+
)}
|
|
669
|
+
{material()}
|
|
670
|
+
</mesh>
|
|
671
|
+
<mesh ref={register(axis, dir)} position={[0, (ARROW_LEN + TIP_LEN) / 2, 0]}>
|
|
672
|
+
<cylinderGeometry args={[PICK_SHAFT_R, PICK_SHAFT_R, ARROW_LEN + TIP_LEN, 8]} />
|
|
673
|
+
{pickMaterial}
|
|
674
|
+
</mesh>
|
|
675
|
+
</>
|
|
676
|
+
)}
|
|
677
|
+
</group>
|
|
678
|
+
);
|
|
679
|
+
})}
|
|
680
|
+
{mode === "rotate" && (
|
|
681
|
+
<group ref={screenRingRef}>
|
|
682
|
+
{/* the outermost ring, always flat to the viewer: rolls the
|
|
683
|
+
object about the view axis (§3.3) */}
|
|
684
|
+
<mesh renderOrder={999}>
|
|
685
|
+
<torusGeometry args={[SCREEN_RING_R, RING_TUBE, 8, 64]} />
|
|
686
|
+
<meshStandardMaterial
|
|
687
|
+
color="#000000"
|
|
688
|
+
emissive={screenActive ? ACTIVE_COLOR : "#f2f2f2"}
|
|
689
|
+
emissiveIntensity={screenActive ? 2.2 : screenHovered ? 3.6 : 2.2}
|
|
690
|
+
toneMapped={false}
|
|
691
|
+
depthTest={false}
|
|
692
|
+
depthWrite={false}
|
|
693
|
+
transparent
|
|
694
|
+
opacity={0.95}
|
|
695
|
+
/>
|
|
696
|
+
</mesh>
|
|
697
|
+
<mesh ref={register("screen", null)}>
|
|
698
|
+
<torusGeometry args={[SCREEN_RING_R, PICK_TUBE_R, 6, 48]} />
|
|
699
|
+
{pickMaterial}
|
|
700
|
+
</mesh>
|
|
701
|
+
</group>
|
|
702
|
+
)}
|
|
703
|
+
{mode === "move" &&
|
|
704
|
+
PLANES.map((plane) => {
|
|
705
|
+
// The square lies in its own plane, offset along both loose axes
|
|
706
|
+
// so it sits in the quadrant between the two arrows.
|
|
707
|
+
const position = [0, 0, 0];
|
|
708
|
+
const index = { x: 0, y: 1, z: 2 };
|
|
709
|
+
for (const axis of plane.axes) position[index[axis]] = PLANE_OFFSET;
|
|
710
|
+
const rotation = plane.normal === "y" ? [-Math.PI / 2, 0, 0] : plane.normal === "x" ? [0, Math.PI / 2, 0] : [0, 0, 0];
|
|
711
|
+
const planeActive = activeHandle === `plane:${plane.id}`;
|
|
712
|
+
const planeHovered = hoveredHandle === `plane:${plane.id}`;
|
|
713
|
+
return (
|
|
714
|
+
<group key={plane.id} position={position} rotation={rotation}>
|
|
715
|
+
<mesh renderOrder={999}>
|
|
716
|
+
<planeGeometry args={[PLANE_SIZE, PLANE_SIZE]} />
|
|
717
|
+
<meshBasicMaterial
|
|
718
|
+
color={planeActive ? ACTIVE_COLOR : plane.color}
|
|
719
|
+
transparent
|
|
720
|
+
opacity={planeActive ? 0.6 : planeHovered ? 0.55 : 0.34}
|
|
721
|
+
side={THREE.DoubleSide}
|
|
722
|
+
depthTest={false}
|
|
723
|
+
depthWrite={false}
|
|
724
|
+
/>
|
|
725
|
+
</mesh>
|
|
726
|
+
<mesh ref={registerPlane(plane)}>
|
|
727
|
+
<planeGeometry args={[PLANE_SIZE * 1.5, PLANE_SIZE * 1.5]} />
|
|
728
|
+
<meshBasicMaterial visible={false} side={THREE.DoubleSide} />
|
|
729
|
+
</mesh>
|
|
730
|
+
</group>
|
|
731
|
+
);
|
|
732
|
+
})}
|
|
733
|
+
{mode === "scale" && (
|
|
734
|
+
<>
|
|
735
|
+
{/* uniform scale: one knob at the pivot, all three axes at once */}
|
|
736
|
+
<mesh renderOrder={999}>
|
|
737
|
+
<boxGeometry args={[CENTRE_BOX, CENTRE_BOX, CENTRE_BOX]} />
|
|
738
|
+
<meshStandardMaterial
|
|
739
|
+
color="#000000"
|
|
740
|
+
emissive={centreActive ? ACTIVE_COLOR : "#f2f2f2"}
|
|
741
|
+
emissiveIntensity={centreActive ? 2 : centreHovered ? 3.4 : 2}
|
|
742
|
+
toneMapped={false}
|
|
743
|
+
depthTest={false}
|
|
744
|
+
depthWrite={false}
|
|
745
|
+
/>
|
|
746
|
+
</mesh>
|
|
747
|
+
<mesh ref={register(null, null)}>
|
|
748
|
+
<sphereGeometry args={[PICK_CENTRE_R, 8, 6]} />
|
|
749
|
+
{pickMaterial}
|
|
750
|
+
</mesh>
|
|
751
|
+
</>
|
|
752
|
+
)}
|
|
753
|
+
</group>
|
|
754
|
+
);
|
|
755
|
+
}
|