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
package/src/planview.jsx
ADDED
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { useFrame } from "@react-three/fiber";
|
|
3
|
+
import { Text } from "@react-three/drei";
|
|
4
|
+
import * as THREE from "three";
|
|
5
|
+
import { aimAt } from "./controls.jsx";
|
|
6
|
+
import { PLAN_LAYER } from "./dualview.jsx";
|
|
7
|
+
import { objectSize } from "./scene-objects.js";
|
|
8
|
+
|
|
9
|
+
const ROOM_LIMIT = 11; // stay inside the set walls
|
|
10
|
+
const ACTOR_LIMIT = 4; // matches the Subject sliders' range
|
|
11
|
+
const PUCK_R = 0.34;
|
|
12
|
+
const GRAB_R = 1.0; // the pick radius, well beyond the drawn disc
|
|
13
|
+
const HANDLE_DIST = 1.25; // how far the facing handle sits from the centre
|
|
14
|
+
const HANDLE_R = 0.14;
|
|
15
|
+
const HANDLE_GRAB = 0.5;
|
|
16
|
+
|
|
17
|
+
/** yaw that makes the character's local +Z face the given direction */
|
|
18
|
+
const yawToward = (dx, dz) => Math.atan2(dx, dz);
|
|
19
|
+
const wrapDeg = (deg) => ((((deg + 180) % 360) + 360) % 360) - 180;
|
|
20
|
+
/** world position of a puck's facing handle */
|
|
21
|
+
const handleAt = (e) => {
|
|
22
|
+
const distance = e.handleDist ?? HANDLE_DIST;
|
|
23
|
+
return { x: e.x + distance * Math.sin(e.yaw), z: e.z + distance * Math.cos(e.yaw) };
|
|
24
|
+
};
|
|
25
|
+
const near = (ax, az, bx, bz, r) => (ax - bx) ** 2 + (az - bz) ** 2 < r * r;
|
|
26
|
+
const insideFootprint = (point, entity, padding = 0.2) => {
|
|
27
|
+
const dx = point.x - entity.x;
|
|
28
|
+
const dz = point.z - entity.z;
|
|
29
|
+
const cos = Math.cos(entity.yaw);
|
|
30
|
+
const sin = Math.sin(entity.yaw);
|
|
31
|
+
const localX = dx * cos - dz * sin;
|
|
32
|
+
const localZ = dx * sin + dz * cos;
|
|
33
|
+
return (
|
|
34
|
+
Math.abs(localX) <= entity.footprint.width / 2 + padding &&
|
|
35
|
+
Math.abs(localZ) <= entity.footprint.depth / 2 + padding
|
|
36
|
+
);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** The lens cone, so you can see what the camera covers without leaving the plan. */
|
|
40
|
+
function FrustumWedge({ fovDeg, active }) {
|
|
41
|
+
const geometry = useMemo(() => new THREE.BufferGeometry(), []);
|
|
42
|
+
const half = (fovDeg * Math.PI) / 180 / 2;
|
|
43
|
+
// a 16:9 frame is wider than the vertical fov it is specified by
|
|
44
|
+
const spread = Math.atan(Math.tan(half) * (16 / 9));
|
|
45
|
+
const reach = 6;
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
const points = new Float32Array([
|
|
48
|
+
0, 0, 0,
|
|
49
|
+
-Math.sin(spread) * reach, 0, -Math.cos(spread) * reach,
|
|
50
|
+
Math.sin(spread) * reach, 0, -Math.cos(spread) * reach,
|
|
51
|
+
]);
|
|
52
|
+
geometry.setAttribute("position", new THREE.BufferAttribute(points, 3));
|
|
53
|
+
geometry.computeVertexNormals();
|
|
54
|
+
}, [geometry, spread]);
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<mesh geometry={geometry} position={[0, 0.02, 0]} renderOrder={8}>
|
|
58
|
+
<meshBasicMaterial
|
|
59
|
+
color="#4e9fb3"
|
|
60
|
+
transparent
|
|
61
|
+
opacity={active ? 0.32 : 0.18}
|
|
62
|
+
side={THREE.DoubleSide}
|
|
63
|
+
depthWrite={false}
|
|
64
|
+
depthTest={false}
|
|
65
|
+
/>
|
|
66
|
+
</mesh>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A staging puck: drag the body to slide it, drag the outrigger handle to turn it.
|
|
72
|
+
*
|
|
73
|
+
* Rotation has to be available right here. Reaching for a numeric slider on the
|
|
74
|
+
* far side of the screen to spin an actor is the single most awkward thing a
|
|
75
|
+
* plan board can ask for, so the facing indicator doubles as the grab point.
|
|
76
|
+
*
|
|
77
|
+
* Purely visual — picking is done analytically against circles on the floor,
|
|
78
|
+
* because these live in a viewport that is not the one driving R3F's events.
|
|
79
|
+
*/
|
|
80
|
+
function Puck({ color, dragging, turning, showBody = true, handleDist = HANDLE_DIST }) {
|
|
81
|
+
return (
|
|
82
|
+
<group>
|
|
83
|
+
{showBody && (
|
|
84
|
+
<>
|
|
85
|
+
<mesh position={[0, 0.03, 0]} rotation={[-Math.PI / 2, 0, 0]} renderOrder={10}>
|
|
86
|
+
<circleGeometry args={[PUCK_R, 6, Math.PI / 6]} />
|
|
87
|
+
<meshBasicMaterial color={color} transparent opacity={0.94} depthWrite={false} depthTest={false} />
|
|
88
|
+
</mesh>
|
|
89
|
+
<mesh position={[0, 0.04, 0]} rotation={[-Math.PI / 2, 0, 0]} renderOrder={10}>
|
|
90
|
+
<ringGeometry
|
|
91
|
+
args={[
|
|
92
|
+
PUCK_R * (dragging ? 1.24 : 1.14),
|
|
93
|
+
PUCK_R * (dragging ? 1.45 : 1.32),
|
|
94
|
+
6,
|
|
95
|
+
1,
|
|
96
|
+
Math.PI / 6,
|
|
97
|
+
]}
|
|
98
|
+
/>
|
|
99
|
+
<meshBasicMaterial
|
|
100
|
+
color={color}
|
|
101
|
+
transparent
|
|
102
|
+
opacity={dragging ? 0.9 : 0.46}
|
|
103
|
+
depthWrite={false}
|
|
104
|
+
depthTest={false}
|
|
105
|
+
/>
|
|
106
|
+
</mesh>
|
|
107
|
+
</>
|
|
108
|
+
)}
|
|
109
|
+
|
|
110
|
+
{/* stem: makes the handle read as attached to the puck, not as debris */}
|
|
111
|
+
<mesh position={[0, 0.035, handleDist / 2]} rotation={[-Math.PI / 2, 0, 0]} renderOrder={9}>
|
|
112
|
+
<planeGeometry args={[0.045, handleDist]} />
|
|
113
|
+
<meshBasicMaterial
|
|
114
|
+
color={color}
|
|
115
|
+
transparent
|
|
116
|
+
opacity={turning ? 0.9 : 0.35}
|
|
117
|
+
depthWrite={false}
|
|
118
|
+
depthTest={false}
|
|
119
|
+
/>
|
|
120
|
+
</mesh>
|
|
121
|
+
<mesh position={[0, 0.045, handleDist]} rotation={[-Math.PI / 2, 0, 0]} renderOrder={11}>
|
|
122
|
+
<circleGeometry args={[turning ? HANDLE_R * 1.2 : HANDLE_R, 4, Math.PI / 4]} />
|
|
123
|
+
<meshBasicMaterial color={color} depthWrite={false} depthTest={false} />
|
|
124
|
+
</mesh>
|
|
125
|
+
<mesh position={[0, 0.044, handleDist]} rotation={[-Math.PI / 2, 0, 0]} renderOrder={11}>
|
|
126
|
+
<ringGeometry args={[HANDLE_R * 1.42, HANDLE_R * 1.62, 4, 1, Math.PI / 4]} />
|
|
127
|
+
<meshBasicMaterial
|
|
128
|
+
color={color}
|
|
129
|
+
transparent
|
|
130
|
+
opacity={turning ? 0.85 : 0.38}
|
|
131
|
+
depthWrite={false}
|
|
132
|
+
depthTest={false}
|
|
133
|
+
/>
|
|
134
|
+
</mesh>
|
|
135
|
+
</group>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Floor-plan annotation. Lies flat on the deck so it reads from straight above.
|
|
141
|
+
*
|
|
142
|
+
* Short by design: full names ran into each other the moment two actors stood
|
|
143
|
+
* closer than the width of the word, which in a two-shot is always.
|
|
144
|
+
*/
|
|
145
|
+
function PlanLabel({ text, color, offset = -0.72 }) {
|
|
146
|
+
return (
|
|
147
|
+
<Text
|
|
148
|
+
position={[0, 0.05, offset]}
|
|
149
|
+
rotation={[-Math.PI / 2, 0, 0]}
|
|
150
|
+
fontSize={0.3}
|
|
151
|
+
color={color}
|
|
152
|
+
anchorX="center"
|
|
153
|
+
anchorY="middle"
|
|
154
|
+
outlineWidth={0.025}
|
|
155
|
+
outlineColor="#0e0d10"
|
|
156
|
+
outlineOpacity={0.72}
|
|
157
|
+
renderOrder={12}
|
|
158
|
+
depthOffset={-1}
|
|
159
|
+
>
|
|
160
|
+
{text}
|
|
161
|
+
</Text>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function SceneObjectFootprint({ object, selected, dragging, turning }) {
|
|
166
|
+
const { width, depth } = objectSize(object);
|
|
167
|
+
const handleDist = depth / 2 + 0.65;
|
|
168
|
+
const rotation = (object.rot * Math.PI) / 180;
|
|
169
|
+
return (
|
|
170
|
+
<group position={[object.x, 0, object.z]} rotation={[0, rotation, 0]}>
|
|
171
|
+
<mesh position={[0, 0.032, 0]} rotation={[-Math.PI / 2, 0, 0]} renderOrder={10}>
|
|
172
|
+
<planeGeometry args={[width, depth]} />
|
|
173
|
+
<meshBasicMaterial
|
|
174
|
+
color={selected ? "#e7b557" : "#c7836f"}
|
|
175
|
+
transparent
|
|
176
|
+
opacity={selected ? 0.46 : 0.24}
|
|
177
|
+
depthWrite={false}
|
|
178
|
+
depthTest={false}
|
|
179
|
+
/>
|
|
180
|
+
</mesh>
|
|
181
|
+
<mesh position={[0, 0.04, 0]} renderOrder={11}>
|
|
182
|
+
<boxGeometry args={[width, 0.02, depth]} />
|
|
183
|
+
<meshBasicMaterial color={selected ? "#d99725" : "#9b695a"} wireframe depthTest={false} />
|
|
184
|
+
</mesh>
|
|
185
|
+
<group position={[0, 0, depth / 2 + 0.45]} rotation={[0, -rotation, 0]}>
|
|
186
|
+
<PlanLabel
|
|
187
|
+
text={object.name.slice(0, 8).toUpperCase()}
|
|
188
|
+
color={selected ? "#b37610" : "#7c574d"}
|
|
189
|
+
offset={0}
|
|
190
|
+
/>
|
|
191
|
+
</group>
|
|
192
|
+
{selected && <Puck color="#e7b557" showBody={false} handleDist={handleDist} dragging={dragging} turning={turning} />}
|
|
193
|
+
</group>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const WAYPOINT_COLOR = "#6f9f86";
|
|
198
|
+
const RAIL_COLOR = "#a78bfa"; // the camera's violet, same identity as its timeline lane
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The drawn camera rail (and the live stroke while drawing): a flat violet
|
|
202
|
+
* line on the deck. Display only — authoring happens through the pointer
|
|
203
|
+
* handlers, exactly like the pucks.
|
|
204
|
+
*/
|
|
205
|
+
function CameraRailLine({ points, live = false }) {
|
|
206
|
+
const geometry = useMemo(() => {
|
|
207
|
+
if (!points || points.length < 2) return null;
|
|
208
|
+
const positions = new Float32Array(points.length * 3);
|
|
209
|
+
points.forEach((p, i) => {
|
|
210
|
+
positions[i * 3] = p.x;
|
|
211
|
+
positions[i * 3 + 1] = 0.03;
|
|
212
|
+
positions[i * 3 + 2] = p.z;
|
|
213
|
+
});
|
|
214
|
+
const g = new THREE.BufferGeometry();
|
|
215
|
+
g.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
|
216
|
+
return g;
|
|
217
|
+
}, [points]);
|
|
218
|
+
useEffect(() => () => geometry?.dispose(), [geometry]);
|
|
219
|
+
if (!geometry) return null;
|
|
220
|
+
const first = points[0];
|
|
221
|
+
const last = points[points.length - 1];
|
|
222
|
+
return (
|
|
223
|
+
<group>
|
|
224
|
+
<line geometry={geometry} renderOrder={9}>
|
|
225
|
+
<lineBasicMaterial color={RAIL_COLOR} transparent opacity={live ? 0.5 : 0.85} depthWrite={false} depthTest={false} />
|
|
226
|
+
</line>
|
|
227
|
+
{!live && [first, last].map((p, i) => (
|
|
228
|
+
<mesh key={i} position={[p.x, 0.04, p.z]} rotation={[-Math.PI / 2, 0, 0]} renderOrder={10}>
|
|
229
|
+
<circleGeometry args={[0.14, 16]} />
|
|
230
|
+
<meshBasicMaterial color={RAIL_COLOR} depthWrite={false} depthTest={false} />
|
|
231
|
+
</mesh>
|
|
232
|
+
))}
|
|
233
|
+
</group>
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The root path: numbered dots at each waypoint's floor position, connected
|
|
239
|
+
* in chronological frame order. Lies flat on the deck like the pucks, so it
|
|
240
|
+
* reads from straight above and never enters shot-camera exports.
|
|
241
|
+
*/
|
|
242
|
+
function WaypointPath({ waypoints, start, activeWaypointFrame }) {
|
|
243
|
+
const line = useMemo(() => {
|
|
244
|
+
if (waypoints.length < 1) return null;
|
|
245
|
+
const pathPoints = [{ x: start.x, z: start.z }, ...waypoints];
|
|
246
|
+
const positions = new Float32Array(pathPoints.length * 3);
|
|
247
|
+
pathPoints.forEach((w, i) => {
|
|
248
|
+
positions[i * 3] = w.x;
|
|
249
|
+
positions[i * 3 + 1] = 0.02;
|
|
250
|
+
positions[i * 3 + 2] = w.z;
|
|
251
|
+
});
|
|
252
|
+
const geometry = new THREE.BufferGeometry();
|
|
253
|
+
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
|
254
|
+
return geometry;
|
|
255
|
+
}, [start.x, start.z, waypoints]);
|
|
256
|
+
|
|
257
|
+
return (
|
|
258
|
+
<group>
|
|
259
|
+
{line && (
|
|
260
|
+
<line geometry={line} renderOrder={9}>
|
|
261
|
+
<lineBasicMaterial color={WAYPOINT_COLOR} transparent opacity={0.55} depthWrite={false} depthTest={false} />
|
|
262
|
+
</line>
|
|
263
|
+
)}
|
|
264
|
+
{waypoints.map((w, i) => {
|
|
265
|
+
const active = w.frame === activeWaypointFrame;
|
|
266
|
+
return (
|
|
267
|
+
<group key={w.frame} position={[w.x, 0, w.z]}>
|
|
268
|
+
<mesh position={[0, 0.045, 0]} rotation={[-Math.PI / 2, 0, 0]} renderOrder={11}>
|
|
269
|
+
<circleGeometry args={[active ? 0.2 : 0.15, 20]} />
|
|
270
|
+
<meshBasicMaterial color={active ? "#ffd76c" : WAYPOINT_COLOR} depthWrite={false} depthTest={false} />
|
|
271
|
+
</mesh>
|
|
272
|
+
<Text
|
|
273
|
+
position={[0, 0.05, 0.3]}
|
|
274
|
+
rotation={[-Math.PI / 2, 0, 0]}
|
|
275
|
+
fontSize={0.26}
|
|
276
|
+
color={active ? "#ffd76c" : WAYPOINT_COLOR}
|
|
277
|
+
anchorX="center"
|
|
278
|
+
anchorY="middle"
|
|
279
|
+
outlineWidth={0.04}
|
|
280
|
+
outlineColor="#0e0d10"
|
|
281
|
+
outlineOpacity={0.85}
|
|
282
|
+
renderOrder={12}
|
|
283
|
+
depthOffset={-1}
|
|
284
|
+
>
|
|
285
|
+
{i + 1}
|
|
286
|
+
</Text>
|
|
287
|
+
</group>
|
|
288
|
+
);
|
|
289
|
+
})}
|
|
290
|
+
</group>
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Top-down staging board. Drag a puck to move it, drag its handle to turn it.
|
|
296
|
+
*
|
|
297
|
+
* Picking is analytic rather than raycast-against-meshes: the board lives in a
|
|
298
|
+
* second viewport with its own camera, and R3F's event system only knows about
|
|
299
|
+
* the default one. Everything here sits on the y=0 plane, so a ground-plane
|
|
300
|
+
* intersection plus circle tests is both simpler and exact.
|
|
301
|
+
*
|
|
302
|
+
* Dragging is tracked on `window`, not on the host element: an element only
|
|
303
|
+
* reports moves that still hit it, so a fast drag off the edge silently strands
|
|
304
|
+
* the puck.
|
|
305
|
+
*/
|
|
306
|
+
export function PlanBoard({ hostRef, planCamRef, shotCamRef, look, fovDeg, charA, setCharA, charB, setCharB, showB, waypoints, activeWaypointFrame, onSelectWaypoint, onMoveWaypoint, onSelectEntity, sceneObjects = [], selectedSceneObjectId, onMoveSceneObject, onObjectMoveStart, onObjectMoveEnd, cameraRailPoints = null, railDraw = false, onRailStroke }) {
|
|
307
|
+
const [drag, setDrag] = useState(null); // { id, mode }
|
|
308
|
+
// live stroke while the rail is being drawn; world XZ, display only
|
|
309
|
+
const [railStroke, setRailStroke] = useState(null);
|
|
310
|
+
const rootRef = useRef();
|
|
311
|
+
const camPos = useRef();
|
|
312
|
+
const camRot = useRef();
|
|
313
|
+
const dragRef = useRef(null);
|
|
314
|
+
// Numbered waypoints are direct-manipulation handles. Empty floor presses
|
|
315
|
+
// intentionally do nothing; root keyframes are authored in the timeline.
|
|
316
|
+
const tools = useMemo(
|
|
317
|
+
() => ({
|
|
318
|
+
raycaster: new THREE.Raycaster(),
|
|
319
|
+
plane: new THREE.Plane(new THREE.Vector3(0, 1, 0), 0),
|
|
320
|
+
pointer: new THREE.Vector2(),
|
|
321
|
+
hit: new THREE.Vector3(),
|
|
322
|
+
}),
|
|
323
|
+
[],
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
// The overlay belongs to the plan camera alone. Without this the pucks would
|
|
327
|
+
// be drawn flat across the floor of the shot — and into exported frames.
|
|
328
|
+
useLayoutEffect(() => {
|
|
329
|
+
rootRef.current?.traverse((o) => o.layers.set(PLAN_LAYER));
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
// The drag listeners must never be re-registered mid-drag: their cleanup
|
|
333
|
+
// clears dragRef, so a dep that changes while dragging (charA.x does, on the
|
|
334
|
+
// very first move) would kill the drag after one frame. Read live values
|
|
335
|
+
// through a ref and keep the effect's deps stable.
|
|
336
|
+
const latest = useRef({ charA, charB, showB, waypoints, onSelectWaypoint, onMoveWaypoint, onSelectEntity, sceneObjects, selectedSceneObjectId, onMoveSceneObject, onObjectMoveStart, onObjectMoveEnd, railDraw, onRailStroke });
|
|
337
|
+
latest.current = { charA, charB, showB, waypoints, onSelectWaypoint, onMoveWaypoint, onSelectEntity, sceneObjects, selectedSceneObjectId, onMoveSceneObject, onObjectMoveStart, onObjectMoveEnd, railDraw, onRailStroke };
|
|
338
|
+
|
|
339
|
+
const targets = () => {
|
|
340
|
+
const { charA: a, charB: b, showB: two } = latest.current;
|
|
341
|
+
const cam = shotCamRef.current;
|
|
342
|
+
const out = [];
|
|
343
|
+
if (cam) out.push({ id: "cam", x: cam.position.x, z: cam.position.z, yaw: look.current.yaw });
|
|
344
|
+
out.push({ id: "a", x: a.x, z: a.z, yaw: (a.rot * Math.PI) / 180 });
|
|
345
|
+
if (two) out.push({ id: "b", x: b.x, z: b.z, yaw: (b.rot * Math.PI) / 180 });
|
|
346
|
+
for (const object of latest.current.sceneObjects) {
|
|
347
|
+
const { width, depth } = objectSize(object);
|
|
348
|
+
out.push({
|
|
349
|
+
id: `object:${object.id}`,
|
|
350
|
+
objectId: object.id,
|
|
351
|
+
x: object.x,
|
|
352
|
+
z: object.z,
|
|
353
|
+
yaw: (object.rot * Math.PI) / 180,
|
|
354
|
+
rot: object.rot,
|
|
355
|
+
footprint: { width, depth },
|
|
356
|
+
handleDist: depth / 2 + 0.65,
|
|
357
|
+
selected: object.id === latest.current.selectedSceneObjectId,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
return out;
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
const aimTarget = () => {
|
|
364
|
+
const { charA: a, charB: b, showB: two } = latest.current;
|
|
365
|
+
const y = 1.35;
|
|
366
|
+
if (!two) return { x: a.x, y, z: a.z };
|
|
367
|
+
return { x: (a.x + b.x) / 2, y, z: (a.z + b.z) / 2 };
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
useFrame(() => {
|
|
371
|
+
const cam = shotCamRef.current;
|
|
372
|
+
if (!cam || !camPos.current || !camRot.current) return;
|
|
373
|
+
camPos.current.position.set(cam.position.x, 0, cam.position.z);
|
|
374
|
+
camRot.current.rotation.y = look.current.yaw;
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
useEffect(() => {
|
|
378
|
+
const host = hostRef.current;
|
|
379
|
+
if (!host) return undefined;
|
|
380
|
+
const snap = (v, limit) => THREE.MathUtils.clamp(Math.round(v / 0.05) * 0.05, -limit, limit);
|
|
381
|
+
|
|
382
|
+
/** pointer -> a point on the floor, seen through the plan camera */
|
|
383
|
+
const toFloor = (event) => {
|
|
384
|
+
const cam = planCamRef.current;
|
|
385
|
+
if (!cam) return null;
|
|
386
|
+
const r = host.getBoundingClientRect();
|
|
387
|
+
if (r.width < 2 || r.height < 2) return null;
|
|
388
|
+
tools.pointer.set(
|
|
389
|
+
((event.clientX - r.left) / r.width) * 2 - 1,
|
|
390
|
+
-((event.clientY - r.top) / r.height) * 2 + 1,
|
|
391
|
+
);
|
|
392
|
+
tools.raycaster.setFromCamera(tools.pointer, cam);
|
|
393
|
+
return tools.raycaster.ray.intersectPlane(tools.plane, tools.hit) ? tools.hit : null;
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
// A generous nearest-point hit target makes the numbered roots draggable
|
|
397
|
+
// directly, without requiring a prior timeline selection/focus step.
|
|
398
|
+
const pick = (p) => {
|
|
399
|
+
let nearestWaypoint = null;
|
|
400
|
+
let nearestDistance = Infinity;
|
|
401
|
+
for (const waypoint of latest.current.waypoints) {
|
|
402
|
+
const distance = (p.x - waypoint.x) ** 2 + (p.z - waypoint.z) ** 2;
|
|
403
|
+
if (distance < nearestDistance) {
|
|
404
|
+
nearestWaypoint = waypoint;
|
|
405
|
+
nearestDistance = distance;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
if (nearestWaypoint && nearestDistance < 0.5 ** 2) {
|
|
409
|
+
return { id: `waypoint:${nearestWaypoint.frame}`, mode: "waypoint", origin: nearestWaypoint };
|
|
410
|
+
}
|
|
411
|
+
// Overlapping pucks (S1 sitting on the camera, like a close-up
|
|
412
|
+
// setup): the FIRST list entry used to win, so grabbing S1 near the
|
|
413
|
+
// cam moved the camera instead and the character never budged —
|
|
414
|
+
// "the puck doesn't move". Pick the NEAREST target instead.
|
|
415
|
+
const list = targets();
|
|
416
|
+
let best = null;
|
|
417
|
+
for (const e of list) {
|
|
418
|
+
const h = handleAt(e);
|
|
419
|
+
const dh = (p.x - h.x) ** 2 + (p.z - h.z) ** 2;
|
|
420
|
+
if ((!e.objectId || e.selected) && dh < HANDLE_GRAB * HANDLE_GRAB && (!best || dh < best.d)) best = { id: e.id, mode: "turn", origin: e, d: dh };
|
|
421
|
+
const db = (p.x - e.x) ** 2 + (p.z - e.z) ** 2;
|
|
422
|
+
const bodyHit = e.objectId ? insideFootprint(p, e) : db < GRAB_R * GRAB_R;
|
|
423
|
+
if (bodyHit && (!best || db < best.d)) best = { id: e.id, mode: "move", origin: e, d: db };
|
|
424
|
+
}
|
|
425
|
+
if (best) return { id: best.id, mode: best.mode, origin: best.origin };
|
|
426
|
+
return null;
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
const onDown = (event) => {
|
|
430
|
+
if (event.button !== 0) return;
|
|
431
|
+
// Rail drawing owns the pointer: a stroke starts anywhere on the
|
|
432
|
+
// deck, so puck picking would fight every stroke that crosses one.
|
|
433
|
+
if (latest.current.railDraw) {
|
|
434
|
+
const start = toFloor(event);
|
|
435
|
+
if (!start) return;
|
|
436
|
+
event.preventDefault();
|
|
437
|
+
event.stopPropagation();
|
|
438
|
+
dragRef.current = { mode: "rail", stroke: [{ x: start.x, z: start.z }] };
|
|
439
|
+
setDrag({ id: "rail", mode: "rail" });
|
|
440
|
+
setRailStroke([{ x: start.x, z: start.z }]);
|
|
441
|
+
host.style.cursor = "crosshair";
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
const p = toFloor(event);
|
|
445
|
+
const grip = p && pick(p);
|
|
446
|
+
if (!grip) return;
|
|
447
|
+
event.preventDefault();
|
|
448
|
+
event.stopPropagation();
|
|
449
|
+
dragRef.current = grip;
|
|
450
|
+
setDrag({ id: grip.id, mode: grip.mode });
|
|
451
|
+
if (grip.mode === "waypoint") latest.current.onSelectWaypoint?.(grip.origin.frame);
|
|
452
|
+
else latest.current.onSelectEntity?.(grip.id);
|
|
453
|
+
// Scene-object grips only: select FIRST (the call above), then
|
|
454
|
+
// begin — App's select handler settles any open transaction, so
|
|
455
|
+
// beginning before the select would leak the freshly-issued token
|
|
456
|
+
// instantly (plan §6.4). Camera, character and waypoint grips
|
|
457
|
+
// write separate state and must never open a scene transaction.
|
|
458
|
+
if (grip.origin.objectId) {
|
|
459
|
+
dragRef.current.token = latest.current.onObjectMoveStart?.({
|
|
460
|
+
owner: "plan",
|
|
461
|
+
cancel: () => teardownDrag(grip),
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
host.style.cursor = grip.mode === "turn" ? "ew-resize" : "grabbing";
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
// hover is the only cue that these are handles at all
|
|
468
|
+
const onHover = (event) => {
|
|
469
|
+
if (dragRef.current) return;
|
|
470
|
+
if (latest.current.railDraw) {
|
|
471
|
+
host.style.cursor = "crosshair";
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
const p = toFloor(event);
|
|
475
|
+
const grip = p && pick(p);
|
|
476
|
+
host.style.cursor = !grip
|
|
477
|
+
? "default"
|
|
478
|
+
: grip.mode === "turn"
|
|
479
|
+
? "ew-resize"
|
|
480
|
+
: "grab";
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
const onMove = (event) => {
|
|
484
|
+
const grip = dragRef.current;
|
|
485
|
+
if (!grip) return;
|
|
486
|
+
const p = toFloor(event);
|
|
487
|
+
if (!p) return;
|
|
488
|
+
|
|
489
|
+
if (grip.mode === "rail") {
|
|
490
|
+
// sample sparsely; RDP simplifies the rest on commit
|
|
491
|
+
const tail = grip.stroke[grip.stroke.length - 1];
|
|
492
|
+
if (Math.hypot(p.x - tail.x, p.z - tail.z) > 0.06) {
|
|
493
|
+
grip.stroke.push({ x: p.x, z: p.z });
|
|
494
|
+
setRailStroke([...grip.stroke]);
|
|
495
|
+
}
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (grip.mode === "waypoint") {
|
|
500
|
+
latest.current.onMoveWaypoint?.(grip.origin.frame, snap(p.x, ROOM_LIMIT), snap(p.z, ROOM_LIMIT));
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (grip.mode === "turn") {
|
|
505
|
+
const dx = p.x - grip.origin.x;
|
|
506
|
+
const dz = p.z - grip.origin.z;
|
|
507
|
+
// Right on the pivot there is no direction to read, and atan2 of a
|
|
508
|
+
// near-zero vector snaps wildly. Hold the last angle instead.
|
|
509
|
+
if (dx * dx + dz * dz < 0.04) return;
|
|
510
|
+
const yaw = yawToward(dx, dz);
|
|
511
|
+
if (grip.id === "cam") {
|
|
512
|
+
// a manual aim; the operator has decided to point off-subject
|
|
513
|
+
look.current.yaw = yaw;
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
// 5° detents, because actors are blocked to clean angles
|
|
517
|
+
const deg = wrapDeg(Math.round((yaw * 180) / Math.PI / 5) * 5);
|
|
518
|
+
if (grip.origin.objectId) latest.current.onMoveSceneObject?.(grip.origin.objectId, { rot: deg }, grip.token);
|
|
519
|
+
else if (grip.id === "a") setCharA((prev) => ({ ...prev, rot: deg }));
|
|
520
|
+
else setCharB((prev) => ({ ...prev, rot: deg }));
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (grip.id === "cam") {
|
|
525
|
+
const cam = shotCamRef.current;
|
|
526
|
+
if (!cam) return;
|
|
527
|
+
cam.position.set(snap(p.x, ROOM_LIMIT), cam.position.y, snap(p.z, ROOM_LIMIT));
|
|
528
|
+
// Sliding the camera across the floor plan must keep it pointed at the
|
|
529
|
+
// cast. Without this every drag leaves the lens staring at a wall.
|
|
530
|
+
const angles = aimAt(cam.position, aimTarget());
|
|
531
|
+
look.current.yaw = angles.yaw;
|
|
532
|
+
look.current.pitch = angles.pitch;
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
const next = { x: snap(p.x, ACTOR_LIMIT), z: snap(p.z, ACTOR_LIMIT) };
|
|
536
|
+
if (grip.origin.objectId) latest.current.onMoveSceneObject?.(grip.origin.objectId, {
|
|
537
|
+
x: snap(p.x, ROOM_LIMIT),
|
|
538
|
+
z: snap(p.z, ROOM_LIMIT),
|
|
539
|
+
}, grip.token);
|
|
540
|
+
else if (grip.id === "a") setCharA((prev) => ({ ...prev, ...next }));
|
|
541
|
+
else setCharB((prev) => ({ ...prev, ...next }));
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
// Teardown-only close: null the drag ref and reset the drag visuals.
|
|
545
|
+
// Never closes the transaction — used by the store's cancel (which is
|
|
546
|
+
// forbidden from calling the end prop back, plan §6.2) as well as the
|
|
547
|
+
// producer's own close paths.
|
|
548
|
+
const teardownDrag = (grip) => {
|
|
549
|
+
if (!grip || dragRef.current !== grip) return;
|
|
550
|
+
dragRef.current = null;
|
|
551
|
+
setDrag(null);
|
|
552
|
+
host.style.cursor = "default";
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
// pointerup, pointercancel, window blur and unmount all COMMIT: the
|
|
556
|
+
// travel already applied is real work the user can see, so it becomes
|
|
557
|
+
// one undo entry rather than being silently reverted (plan §6.3).
|
|
558
|
+
// Escape is the only gesture that rolls back.
|
|
559
|
+
const closeDrag = (commit) => {
|
|
560
|
+
const grip = dragRef.current;
|
|
561
|
+
if (!grip) return;
|
|
562
|
+
if (grip.mode === "rail") {
|
|
563
|
+
// commit hands the raw stroke to the app (which simplifies and
|
|
564
|
+
// splines it); Escape throws the stroke away
|
|
565
|
+
teardownDrag(grip);
|
|
566
|
+
setRailStroke(null);
|
|
567
|
+
if (commit && grip.stroke.length >= 2) latest.current.onRailStroke?.(grip.stroke);
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
const token = grip.token;
|
|
571
|
+
teardownDrag(grip);
|
|
572
|
+
if (token != null) latest.current.onObjectMoveEnd?.(token, { commit });
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
const onUp = () => {
|
|
576
|
+
closeDrag(true);
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
const onCancel = () => closeDrag(true);
|
|
580
|
+
|
|
581
|
+
const onBlur = () => closeDrag(true);
|
|
582
|
+
|
|
583
|
+
const onEscape = (event) => {
|
|
584
|
+
if (event.key !== "Escape") return;
|
|
585
|
+
if (!dragRef.current) return;
|
|
586
|
+
// Capture-phase stopPropagation keeps the same press from also
|
|
587
|
+
// reaching App's Escape-clears-selection handler (plan §7).
|
|
588
|
+
event.stopPropagation();
|
|
589
|
+
closeDrag(false);
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
host.addEventListener("pointerdown", onDown);
|
|
593
|
+
host.addEventListener("pointermove", onHover);
|
|
594
|
+
window.addEventListener("pointermove", onMove);
|
|
595
|
+
window.addEventListener("pointerup", onUp);
|
|
596
|
+
window.addEventListener("pointercancel", onCancel);
|
|
597
|
+
window.addEventListener("blur", onBlur);
|
|
598
|
+
window.addEventListener("keydown", onEscape, true);
|
|
599
|
+
return () => {
|
|
600
|
+
host.removeEventListener("pointerdown", onDown);
|
|
601
|
+
host.removeEventListener("pointermove", onHover);
|
|
602
|
+
window.removeEventListener("pointermove", onMove);
|
|
603
|
+
window.removeEventListener("pointerup", onUp);
|
|
604
|
+
window.removeEventListener("pointercancel", onCancel);
|
|
605
|
+
window.removeEventListener("blur", onBlur);
|
|
606
|
+
window.removeEventListener("keydown", onEscape, true);
|
|
607
|
+
closeDrag(true);
|
|
608
|
+
};
|
|
609
|
+
}, [hostRef, planCamRef, shotCamRef, tools, setCharA, setCharB, look]);
|
|
610
|
+
|
|
611
|
+
const state = (id) => ({ dragging: drag?.id === id && drag.mode === "move", turning: drag?.id === id && drag.mode === "turn" });
|
|
612
|
+
|
|
613
|
+
return (
|
|
614
|
+
<group ref={rootRef}>
|
|
615
|
+
<group ref={camPos}>
|
|
616
|
+
<PlanLabel text="CAM" color="#247da0" />
|
|
617
|
+
<group ref={camRot}>
|
|
618
|
+
<FrustumWedge fovDeg={fovDeg} active={drag?.id === "cam"} />
|
|
619
|
+
<Puck color="#4e9fb3" {...state("cam")} />
|
|
620
|
+
</group>
|
|
621
|
+
</group>
|
|
622
|
+
|
|
623
|
+
<group position={[charA.x, 0, charA.z]}>
|
|
624
|
+
<PlanLabel text="S1" color="#273849" />
|
|
625
|
+
<group rotation={[0, (charA.rot * Math.PI) / 180, 0]}>
|
|
626
|
+
<Puck color="#273849" {...state("a")} />
|
|
627
|
+
</group>
|
|
628
|
+
</group>
|
|
629
|
+
|
|
630
|
+
{showB && (
|
|
631
|
+
<group position={[charB.x, 0, charB.z]}>
|
|
632
|
+
<PlanLabel text="S2" color="#d65f55" />
|
|
633
|
+
<group rotation={[0, (charB.rot * Math.PI) / 180, 0]}>
|
|
634
|
+
<Puck color="#d65f55" {...state("b")} />
|
|
635
|
+
</group>
|
|
636
|
+
</group>
|
|
637
|
+
)}
|
|
638
|
+
|
|
639
|
+
{sceneObjects.map((object) => (
|
|
640
|
+
<SceneObjectFootprint
|
|
641
|
+
key={object.id}
|
|
642
|
+
object={object}
|
|
643
|
+
selected={object.id === selectedSceneObjectId}
|
|
644
|
+
{...state(`object:${object.id}`)}
|
|
645
|
+
/>
|
|
646
|
+
))}
|
|
647
|
+
|
|
648
|
+
{waypoints.length > 0 && <WaypointPath waypoints={waypoints} start={charA} activeWaypointFrame={activeWaypointFrame} />}
|
|
649
|
+
{cameraRailPoints && cameraRailPoints.length > 1 && <CameraRailLine points={cameraRailPoints} />}
|
|
650
|
+
{railStroke && railStroke.length > 1 && <CameraRailLine points={railStroke} live />}
|
|
651
|
+
</group>
|
|
652
|
+
);
|
|
653
|
+
}
|