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/controls.jsx
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import { useFrame, useThree } from "@react-three/fiber";
|
|
3
|
+
import * as THREE from "three";
|
|
4
|
+
|
|
5
|
+
const MOVE_SPEED = 2.6; // metres per second
|
|
6
|
+
const CRANE_SPEED = 1.3; // Q/E up/down runs at half walk speed — fine
|
|
7
|
+
// vertical adjustments while posing need the finer step, not a 2.6 m/s jump
|
|
8
|
+
const LOOK_SENS = 0.0032; // radians per pixel
|
|
9
|
+
const ORBIT_SENS = 0.006; // radians per pixel, a touch faster than looking
|
|
10
|
+
const PAN_SENS = 0.0022; // metres per pixel at 1 m of pivot distance
|
|
11
|
+
const DOLLY_STEP = 0.0016; // metres per wheel unit
|
|
12
|
+
const PITCH_LIMIT = (85 * Math.PI) / 180;
|
|
13
|
+
const BOOST = 2.6; // Shift during a fly
|
|
14
|
+
const MIN_ORBIT_RADIUS = 0.6;
|
|
15
|
+
|
|
16
|
+
/** yaw/pitch -> unit forward vector for a YXZ-ordered camera */
|
|
17
|
+
export function forwardFrom(yaw, pitch) {
|
|
18
|
+
return new THREE.Vector3(-Math.sin(yaw) * Math.cos(pitch), Math.sin(pitch), -Math.cos(yaw) * Math.cos(pitch));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** point a camera at a target and return the matching yaw/pitch */
|
|
22
|
+
export function aimAt(position, target) {
|
|
23
|
+
const dx = target.x - position.x;
|
|
24
|
+
const dy = target.y - position.y;
|
|
25
|
+
const dz = target.z - position.z;
|
|
26
|
+
return {
|
|
27
|
+
yaw: Math.atan2(-dx, -dz),
|
|
28
|
+
pitch: Math.atan2(dy, Math.max(Math.hypot(dx, dz), 1e-6)),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Viewport navigation, on Unity's bindings.
|
|
34
|
+
*
|
|
35
|
+
* The left button belongs to the CONTENT — selecting and dragging gizmo
|
|
36
|
+
* handles — and the camera lives on the other buttons. That split is the whole
|
|
37
|
+
* point: while the left button was also "look around", every press had two
|
|
38
|
+
* possible meanings and the tool had to guess, which is why W/E/R could not be
|
|
39
|
+
* the transform hotkeys and why grabbing an object also swung the view.
|
|
40
|
+
*
|
|
41
|
+
* right-drag look around (flythrough)
|
|
42
|
+
* right-held + WASD/QE walk and crane, live ONLY during the flythrough
|
|
43
|
+
* right-held + Shift faster
|
|
44
|
+
* right-held + wheel change the fly speed instead of dollying
|
|
45
|
+
* middle-drag pan
|
|
46
|
+
* Alt + left-drag orbit the pivot the caller supplies (the selection)
|
|
47
|
+
* wheel dolly
|
|
48
|
+
*
|
|
49
|
+
* `getPivot()` is optional and returns a world point to orbit; without one,
|
|
50
|
+
* Alt-drag orbits a point straight ahead of the lens.
|
|
51
|
+
*/
|
|
52
|
+
export function FlyControls({ enabled, camRef, look, getPivot, onFlyStateChange }) {
|
|
53
|
+
const { gl } = useThree();
|
|
54
|
+
const keys = useRef(new Set());
|
|
55
|
+
const gesture = useRef(null); // { kind: "fly" | "pan" | "orbit", pointerId, x, y, ... }
|
|
56
|
+
const speedScale = useRef(1);
|
|
57
|
+
const flyStateRef = useRef(onFlyStateChange);
|
|
58
|
+
flyStateRef.current = onFlyStateChange;
|
|
59
|
+
const pivotRef = useRef(getPivot);
|
|
60
|
+
pivotRef.current = getPivot;
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (!enabled) return undefined;
|
|
64
|
+
const element = gl.domElement;
|
|
65
|
+
element.tabIndex = 0;
|
|
66
|
+
element.style.touchAction = "none";
|
|
67
|
+
|
|
68
|
+
const isTyping = () => {
|
|
69
|
+
const el = document.activeElement;
|
|
70
|
+
return !!el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT");
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/* Physical key positions, not characters: `e.key` follows the OS input
|
|
74
|
+
* method, so in Hangul IME mode pressing W yields "ㅈ" and every
|
|
75
|
+
* camera key goes dead — the exact "WASD is blocked" failure Korean
|
|
76
|
+
* users hit. `e.code` is layout/IME independent. */
|
|
77
|
+
const KEY_BY_CODE = { KeyW: "w", KeyA: "a", KeyS: "s", KeyD: "d", KeyQ: "q", KeyE: "e", ShiftLeft: "shift", ShiftRight: "shift" };
|
|
78
|
+
const onKeyDown = (e) => {
|
|
79
|
+
if (isTyping()) return;
|
|
80
|
+
// Movement keys are live only while the flythrough button is held.
|
|
81
|
+
// Outside a fly they are tool hotkeys and belong to the app.
|
|
82
|
+
if (gesture.current?.kind !== "fly") return;
|
|
83
|
+
const key = KEY_BY_CODE[e.code];
|
|
84
|
+
if (key) {
|
|
85
|
+
keys.current.add(key);
|
|
86
|
+
e.preventDefault();
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
const onKeyUp = (e) => {
|
|
90
|
+
const key = KEY_BY_CODE[e.code];
|
|
91
|
+
if (key) keys.current.delete(key);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const endGesture = (e) => {
|
|
95
|
+
const active = gesture.current;
|
|
96
|
+
if (!active) return;
|
|
97
|
+
if (e && active.pointerId !== undefined && element.hasPointerCapture(active.pointerId)) {
|
|
98
|
+
element.releasePointerCapture(active.pointerId);
|
|
99
|
+
}
|
|
100
|
+
gesture.current = null;
|
|
101
|
+
keys.current.clear();
|
|
102
|
+
// the fly speed set with the wheel persists between flights, as it does in Unity
|
|
103
|
+
element.style.cursor = "";
|
|
104
|
+
flyStateRef.current?.(false);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const onPointerDown = (e) => {
|
|
108
|
+
if (gesture.current) return;
|
|
109
|
+
const orbit = e.button === 0 && e.altKey;
|
|
110
|
+
const kind = e.button === 2 ? "fly" : e.button === 1 ? "pan" : orbit ? "orbit" : null;
|
|
111
|
+
if (!kind) return;
|
|
112
|
+
e.preventDefault();
|
|
113
|
+
// Alt-drag and middle-drag must not reach the gizmo picker either;
|
|
114
|
+
// the picker only claims plain left presses, so this is belt and
|
|
115
|
+
// braces for the middle button's browser autoscroll.
|
|
116
|
+
e.stopPropagation();
|
|
117
|
+
const cam = camRef.current;
|
|
118
|
+
const pivot = kind === "orbit" && cam ? resolvePivot(cam) : null;
|
|
119
|
+
gesture.current = { kind, pointerId: e.pointerId, x: e.clientX, y: e.clientY, pivot };
|
|
120
|
+
element.setPointerCapture(e.pointerId);
|
|
121
|
+
element.focus();
|
|
122
|
+
element.style.cursor = kind === "fly" ? "crosshair" : kind === "pan" ? "grabbing" : "move";
|
|
123
|
+
if (kind === "fly") flyStateRef.current?.(true);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/** the point Alt-drag turns around: the caller's selection, or a point
|
|
127
|
+
* a few metres down the lens when there is nothing selected */
|
|
128
|
+
const resolvePivot = (cam) => {
|
|
129
|
+
const supplied = pivotRef.current?.();
|
|
130
|
+
if (supplied) return new THREE.Vector3(supplied.x, supplied.y, supplied.z);
|
|
131
|
+
return cam.position.clone().addScaledVector(forwardFrom(look.current.yaw, look.current.pitch), 3);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const onPointerMove = (e) => {
|
|
135
|
+
const active = gesture.current;
|
|
136
|
+
if (!active) return;
|
|
137
|
+
const dx = e.clientX - active.x;
|
|
138
|
+
const dy = e.clientY - active.y;
|
|
139
|
+
active.x = e.clientX;
|
|
140
|
+
active.y = e.clientY;
|
|
141
|
+
const cam = camRef.current;
|
|
142
|
+
if (!cam) return;
|
|
143
|
+
|
|
144
|
+
if (active.kind === "fly") {
|
|
145
|
+
look.current.yaw -= dx * LOOK_SENS;
|
|
146
|
+
look.current.pitch -= dy * LOOK_SENS;
|
|
147
|
+
look.current.pitch = THREE.MathUtils.clamp(look.current.pitch, -PITCH_LIMIT, PITCH_LIMIT);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (active.kind === "pan") {
|
|
151
|
+
// Slide the camera across its own image plane, scaled by how far
|
|
152
|
+
// away the subject is so panning feels the same at any distance.
|
|
153
|
+
const distance = Math.max(cam.position.distanceTo(resolvePivot(cam)), 1);
|
|
154
|
+
const right = new THREE.Vector3(Math.cos(look.current.yaw), 0, -Math.sin(look.current.yaw));
|
|
155
|
+
const up = new THREE.Vector3(0, 1, 0);
|
|
156
|
+
cam.position.addScaledVector(right, -dx * PAN_SENS * distance);
|
|
157
|
+
cam.position.addScaledVector(up, dy * PAN_SENS * distance);
|
|
158
|
+
cam.position.y = Math.max(cam.position.y, 0.12);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
// orbit: swing the camera around the pivot, then re-aim at it so the
|
|
162
|
+
// yaw/pitch the rest of the app reads stays the truth
|
|
163
|
+
const pivot = active.pivot ?? resolvePivot(cam);
|
|
164
|
+
const offset = cam.position.clone().sub(pivot);
|
|
165
|
+
const radius = Math.max(offset.length(), MIN_ORBIT_RADIUS);
|
|
166
|
+
const spherical = new THREE.Spherical().setFromVector3(offset);
|
|
167
|
+
spherical.radius = radius;
|
|
168
|
+
spherical.theta -= dx * ORBIT_SENS;
|
|
169
|
+
spherical.phi = THREE.MathUtils.clamp(spherical.phi + dy * ORBIT_SENS, 0.08, Math.PI - 0.08);
|
|
170
|
+
cam.position.copy(pivot).add(new THREE.Vector3().setFromSpherical(spherical));
|
|
171
|
+
cam.position.y = Math.max(cam.position.y, 0.12);
|
|
172
|
+
const angles = aimAt(cam.position, pivot);
|
|
173
|
+
look.current.yaw = angles.yaw;
|
|
174
|
+
look.current.pitch = angles.pitch;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const onWheel = (e) => {
|
|
178
|
+
const cam = camRef.current;
|
|
179
|
+
if (!cam) return;
|
|
180
|
+
e.preventDefault();
|
|
181
|
+
if (gesture.current?.kind === "fly") {
|
|
182
|
+
// Unity: the wheel sets the flythrough speed, it does not dolly.
|
|
183
|
+
speedScale.current = THREE.MathUtils.clamp(speedScale.current * (e.deltaY > 0 ? 0.9 : 1.1), 0.15, 6);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
cam.position.addScaledVector(forwardFrom(look.current.yaw, look.current.pitch), -e.deltaY * DOLLY_STEP);
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
// A right-drag is navigation, so the browser menu must not interrupt it.
|
|
190
|
+
const onContextMenu = (e) => e.preventDefault();
|
|
191
|
+
|
|
192
|
+
window.addEventListener("keydown", onKeyDown);
|
|
193
|
+
window.addEventListener("keyup", onKeyUp);
|
|
194
|
+
element.addEventListener("pointerdown", onPointerDown);
|
|
195
|
+
element.addEventListener("pointermove", onPointerMove);
|
|
196
|
+
element.addEventListener("pointerup", endGesture);
|
|
197
|
+
element.addEventListener("pointercancel", endGesture);
|
|
198
|
+
element.addEventListener("lostpointercapture", endGesture);
|
|
199
|
+
element.addEventListener("contextmenu", onContextMenu);
|
|
200
|
+
element.addEventListener("wheel", onWheel, { passive: false });
|
|
201
|
+
// Alt-tabbing away mid-fly must not leave keys stuck down.
|
|
202
|
+
window.addEventListener("blur", endGesture);
|
|
203
|
+
return () => {
|
|
204
|
+
keys.current.clear();
|
|
205
|
+
gesture.current = null;
|
|
206
|
+
flyStateRef.current?.(false);
|
|
207
|
+
window.removeEventListener("keydown", onKeyDown);
|
|
208
|
+
window.removeEventListener("keyup", onKeyUp);
|
|
209
|
+
element.removeEventListener("pointerdown", onPointerDown);
|
|
210
|
+
element.removeEventListener("pointermove", onPointerMove);
|
|
211
|
+
element.removeEventListener("pointerup", endGesture);
|
|
212
|
+
element.removeEventListener("pointercancel", endGesture);
|
|
213
|
+
element.removeEventListener("lostpointercapture", endGesture);
|
|
214
|
+
element.removeEventListener("contextmenu", onContextMenu);
|
|
215
|
+
element.removeEventListener("wheel", onWheel);
|
|
216
|
+
window.removeEventListener("blur", endGesture);
|
|
217
|
+
};
|
|
218
|
+
}, [enabled, gl, camRef, look]);
|
|
219
|
+
|
|
220
|
+
useFrame((_, delta) => {
|
|
221
|
+
const cam = camRef.current;
|
|
222
|
+
if (!cam) return;
|
|
223
|
+
cam.rotation.order = "YXZ";
|
|
224
|
+
cam.rotation.y = look.current.yaw;
|
|
225
|
+
cam.rotation.x = look.current.pitch;
|
|
226
|
+
cam.rotation.z = 0;
|
|
227
|
+
|
|
228
|
+
if (!enabled || keys.current.size === 0) return;
|
|
229
|
+
const boost = keys.current.has("shift") ? BOOST : 1;
|
|
230
|
+
const step = MOVE_SPEED * speedScale.current * boost * Math.min(delta, 0.1);
|
|
231
|
+
// walking stays on the floor plane; Q/E is the crane
|
|
232
|
+
const forward = new THREE.Vector3(-Math.sin(look.current.yaw), 0, -Math.cos(look.current.yaw));
|
|
233
|
+
const right = new THREE.Vector3(Math.cos(look.current.yaw), 0, -Math.sin(look.current.yaw));
|
|
234
|
+
if (keys.current.has("w")) cam.position.addScaledVector(forward, step);
|
|
235
|
+
if (keys.current.has("s")) cam.position.addScaledVector(forward, -step);
|
|
236
|
+
if (keys.current.has("d")) cam.position.addScaledVector(right, step);
|
|
237
|
+
if (keys.current.has("a")) cam.position.addScaledVector(right, -step);
|
|
238
|
+
const crane = CRANE_SPEED * speedScale.current * boost * Math.min(delta, 0.1);
|
|
239
|
+
if (keys.current.has("e")) cam.position.y += crane;
|
|
240
|
+
if (keys.current.has("q")) cam.position.y -= crane;
|
|
241
|
+
cam.position.y = Math.max(cam.position.y, 0.12);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
return null;
|
|
245
|
+
}
|
package/src/dualview.jsx
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { useEffect, useMemo } from "react";
|
|
2
|
+
import { useFrame, useThree } from "@react-three/fiber";
|
|
3
|
+
import * as THREE from "three";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Two live viewports out of one scene.
|
|
7
|
+
*
|
|
8
|
+
* Blocking a shot means moving the camera and watching the frame change. Doing
|
|
9
|
+
* that through a view toggle costs a round trip per nudge, so both views render
|
|
10
|
+
* every frame into scissored regions of the same canvas. One scene, one WebGL
|
|
11
|
+
* context, two cameras — cloning the scene would double the FBX cost for a
|
|
12
|
+
* picture that is only ever a monitor.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** the plan pucks/wedge: overlay geometry only the top-down camera may see */
|
|
16
|
+
export const PLAN_LAYER = 2;
|
|
17
|
+
/** the ceiling: it would fill the entire top-down view, so hide it from the plan */
|
|
18
|
+
export const SHOT_LAYER = 3;
|
|
19
|
+
/** IK handles/gizmo: only the poser camera (the IK working view) sees them,
|
|
20
|
+
* never the frozen shot camera in the inset or the plan. */
|
|
21
|
+
export const POSER_LAYER = 4;
|
|
22
|
+
/** the scene-object transform gizmo: live in both 3D working views, stripped
|
|
23
|
+
* from the plan and from exported frames (the capture rig drops this layer). */
|
|
24
|
+
export const GIZMO_LAYER = 5;
|
|
25
|
+
/** the shot camera is locked to the export aspect no matter which pane holds it */
|
|
26
|
+
export const SHOT_ASPECT = 16 / 9;
|
|
27
|
+
/** half-height of the plan frustum, in metres — covers the full camera throw */
|
|
28
|
+
export const PLAN_EXTENT = 12.4;
|
|
29
|
+
|
|
30
|
+
const EDGE_VERTEX = `
|
|
31
|
+
varying vec2 vUv;
|
|
32
|
+
void main() {
|
|
33
|
+
vUv = uv;
|
|
34
|
+
gl_Position = vec4(position.xy, 0.0, 1.0);
|
|
35
|
+
}
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
const EDGE_FRAGMENT = `
|
|
39
|
+
uniform sampler2D tNormal;
|
|
40
|
+
uniform sampler2D tDepth;
|
|
41
|
+
uniform vec2 texel;
|
|
42
|
+
uniform vec3 edgeColor;
|
|
43
|
+
uniform float cameraNear;
|
|
44
|
+
uniform float cameraFar;
|
|
45
|
+
uniform float orthographic;
|
|
46
|
+
varying vec2 vUv;
|
|
47
|
+
|
|
48
|
+
float linearDepth(float value) {
|
|
49
|
+
if (orthographic > 0.5) return mix(cameraNear, cameraFar, value);
|
|
50
|
+
float z = value * 2.0 - 1.0;
|
|
51
|
+
return (2.0 * cameraNear * cameraFar) /
|
|
52
|
+
(cameraFar + cameraNear - z * (cameraFar - cameraNear));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
void main() {
|
|
56
|
+
vec2 x = vec2(texel.x * 0.8, 0.0);
|
|
57
|
+
vec2 y = vec2(0.0, texel.y * 0.8);
|
|
58
|
+
float centerZ = linearDepth(texture2D(tDepth, vUv).x);
|
|
59
|
+
float nearestZ = min(
|
|
60
|
+
min(linearDepth(texture2D(tDepth, vUv - x).x), linearDepth(texture2D(tDepth, vUv + x).x)),
|
|
61
|
+
min(linearDepth(texture2D(tDepth, vUv - y).x), linearDepth(texture2D(tDepth, vUv + y).x))
|
|
62
|
+
);
|
|
63
|
+
float foregroundOwner = 1.0 - step(nearestZ + max(0.002, centerZ * 0.0015), centerZ);
|
|
64
|
+
float dTL = log2(1.0 + linearDepth(texture2D(tDepth, vUv - x + y).x));
|
|
65
|
+
float dT = log2(1.0 + linearDepth(texture2D(tDepth, vUv + y).x));
|
|
66
|
+
float dTR = log2(1.0 + linearDepth(texture2D(tDepth, vUv + x + y).x));
|
|
67
|
+
float dL = log2(1.0 + linearDepth(texture2D(tDepth, vUv - x).x));
|
|
68
|
+
float dR = log2(1.0 + linearDepth(texture2D(tDepth, vUv + x).x));
|
|
69
|
+
float dBL = log2(1.0 + linearDepth(texture2D(tDepth, vUv - x - y).x));
|
|
70
|
+
float dB = log2(1.0 + linearDepth(texture2D(tDepth, vUv - y).x));
|
|
71
|
+
float dBR = log2(1.0 + linearDepth(texture2D(tDepth, vUv + x - y).x));
|
|
72
|
+
float depthX = dTL + 2.0 * dL + dBL - dTR - 2.0 * dR - dBR;
|
|
73
|
+
float depthY = dTL + 2.0 * dT + dTR - dBL - 2.0 * dB - dBR;
|
|
74
|
+
float depthEdge = length(vec2(depthX, depthY));
|
|
75
|
+
|
|
76
|
+
vec3 nTL = texture2D(tNormal, vUv - x + y).xyz * 2.0 - 1.0;
|
|
77
|
+
vec3 nT = texture2D(tNormal, vUv + y).xyz * 2.0 - 1.0;
|
|
78
|
+
vec3 nTR = texture2D(tNormal, vUv + x + y).xyz * 2.0 - 1.0;
|
|
79
|
+
vec3 nL = texture2D(tNormal, vUv - x).xyz * 2.0 - 1.0;
|
|
80
|
+
vec3 nR = texture2D(tNormal, vUv + x).xyz * 2.0 - 1.0;
|
|
81
|
+
vec3 nBL = texture2D(tNormal, vUv - x - y).xyz * 2.0 - 1.0;
|
|
82
|
+
vec3 nB = texture2D(tNormal, vUv - y).xyz * 2.0 - 1.0;
|
|
83
|
+
vec3 nBR = texture2D(tNormal, vUv + x - y).xyz * 2.0 - 1.0;
|
|
84
|
+
vec3 normalX = nTL + 2.0 * nL + nBL - nTR - 2.0 * nR - nBR;
|
|
85
|
+
vec3 normalY = nTL + 2.0 * nT + nTR - nBL - 2.0 * nB - nBR;
|
|
86
|
+
float normalEdge = sqrt(dot(normalX, normalX) + dot(normalY, normalY));
|
|
87
|
+
|
|
88
|
+
float depthLine = smoothstep(0.055, 0.16, depthEdge);
|
|
89
|
+
float normalLine = smoothstep(0.7, 1.7, normalEdge);
|
|
90
|
+
float edge = max(depthLine * foregroundOwner, normalLine * 0.68);
|
|
91
|
+
gl_FragColor = vec4(edgeColor, edge * 0.76);
|
|
92
|
+
}
|
|
93
|
+
`;
|
|
94
|
+
|
|
95
|
+
/** largest centred sub-rect of `rect` with the given aspect */
|
|
96
|
+
export function fitAspect(rect, aspect) {
|
|
97
|
+
let w = rect.w;
|
|
98
|
+
let h = w / aspect;
|
|
99
|
+
if (h > rect.h) {
|
|
100
|
+
h = rect.h;
|
|
101
|
+
w = h * aspect;
|
|
102
|
+
}
|
|
103
|
+
return { x: rect.x + (rect.w - w) / 2, y: rect.y + (rect.h - h) / 2, w, h };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function DualRender({ stageRef, mainRef, insetRef, shotCamRef, planCamRef, poserCamRef, ikMode = false, planIsMain, playMode = false, insetCollapsed = false, planZoom = 1 }) {
|
|
107
|
+
const invalidate = useThree((state) => state.invalidate);
|
|
108
|
+
// Demand mode draws nothing by itself: the first frame can land before the
|
|
109
|
+
// layout settles (the inset reads 0x0), and async scene content commits
|
|
110
|
+
// later. Force frames on mount and after the DOM has caught up.
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
invalidate();
|
|
113
|
+
const f1 = requestAnimationFrame(invalidate);
|
|
114
|
+
const f2 = requestAnimationFrame(() => requestAnimationFrame(invalidate));
|
|
115
|
+
return () => {
|
|
116
|
+
cancelAnimationFrame(f1);
|
|
117
|
+
cancelAnimationFrame(f2);
|
|
118
|
+
};
|
|
119
|
+
}, [invalidate]);
|
|
120
|
+
const { gl, scene, size } = useThree();
|
|
121
|
+
const edgePass = useMemo(() => {
|
|
122
|
+
const target = new THREE.WebGLRenderTarget(1, 1, {
|
|
123
|
+
depthBuffer: true,
|
|
124
|
+
stencilBuffer: false,
|
|
125
|
+
minFilter: THREE.NearestFilter,
|
|
126
|
+
magFilter: THREE.NearestFilter,
|
|
127
|
+
});
|
|
128
|
+
target.depthTexture = new THREE.DepthTexture(1, 1, THREE.UnsignedIntType);
|
|
129
|
+
target.depthTexture.minFilter = THREE.NearestFilter;
|
|
130
|
+
target.depthTexture.magFilter = THREE.NearestFilter;
|
|
131
|
+
const normalMaterial = new THREE.MeshNormalMaterial({
|
|
132
|
+
blending: THREE.NoBlending,
|
|
133
|
+
side: THREE.DoubleSide,
|
|
134
|
+
});
|
|
135
|
+
const material = new THREE.ShaderMaterial({
|
|
136
|
+
vertexShader: EDGE_VERTEX,
|
|
137
|
+
fragmentShader: EDGE_FRAGMENT,
|
|
138
|
+
transparent: true,
|
|
139
|
+
depthTest: false,
|
|
140
|
+
depthWrite: false,
|
|
141
|
+
toneMapped: false,
|
|
142
|
+
uniforms: {
|
|
143
|
+
tNormal: { value: target.texture },
|
|
144
|
+
tDepth: { value: target.depthTexture },
|
|
145
|
+
texel: { value: new THREE.Vector2(1, 1) },
|
|
146
|
+
edgeColor: { value: new THREE.Color("#243b4d") },
|
|
147
|
+
cameraNear: { value: 0.1 },
|
|
148
|
+
cameraFar: { value: 100 },
|
|
149
|
+
orthographic: { value: 0 },
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
const passScene = new THREE.Scene();
|
|
153
|
+
const passCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
|
154
|
+
passScene.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), material));
|
|
155
|
+
return { target, normalMaterial, material, passScene, passCamera };
|
|
156
|
+
}, []);
|
|
157
|
+
|
|
158
|
+
useEffect(() => () => {
|
|
159
|
+
edgePass.target.dispose();
|
|
160
|
+
edgePass.normalMaterial.dispose();
|
|
161
|
+
edgePass.material.dispose();
|
|
162
|
+
edgePass.passScene.children[0].geometry.dispose();
|
|
163
|
+
}, [edgePass]);
|
|
164
|
+
|
|
165
|
+
// Layer masks are set once. The shot camera must never pick up the plan
|
|
166
|
+
// overlay, or exported frames would carry pucks across the floor.
|
|
167
|
+
useEffect(() => {
|
|
168
|
+
const shot = shotCamRef.current;
|
|
169
|
+
const plan = planCamRef.current;
|
|
170
|
+
const poser = poserCamRef?.current;
|
|
171
|
+
if (shot) {
|
|
172
|
+
shot.layers.set(0);
|
|
173
|
+
shot.layers.enable(SHOT_LAYER);
|
|
174
|
+
shot.layers.enable(GIZMO_LAYER);
|
|
175
|
+
}
|
|
176
|
+
if (plan) {
|
|
177
|
+
plan.layers.set(0);
|
|
178
|
+
plan.layers.enable(PLAN_LAYER);
|
|
179
|
+
}
|
|
180
|
+
if (poser) {
|
|
181
|
+
poser.layers.set(0);
|
|
182
|
+
poser.layers.enable(SHOT_LAYER);
|
|
183
|
+
poser.layers.enable(POSER_LAYER);
|
|
184
|
+
poser.layers.enable(GIZMO_LAYER);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// priority 1 takes over the render loop; R3F stops auto-rendering for us
|
|
189
|
+
useFrame(() => {
|
|
190
|
+
const shotCam = shotCamRef.current;
|
|
191
|
+
const planCam = planCamRef.current;
|
|
192
|
+
const poserCam = poserCamRef?.current;
|
|
193
|
+
const stage = stageRef.current;
|
|
194
|
+
if (!shotCam || !planCam || !stage || !mainRef.current || !insetRef.current) return;
|
|
195
|
+
|
|
196
|
+
const base = stage.getBoundingClientRect();
|
|
197
|
+
const rectOf = (el) => {
|
|
198
|
+
const r = el.getBoundingClientRect();
|
|
199
|
+
return { x: r.left - base.left, y: r.top - base.top, w: r.width, h: r.height };
|
|
200
|
+
};
|
|
201
|
+
const mainRect = rectOf(mainRef.current);
|
|
202
|
+
// A collapsed inset is just its tag pill: skip the whole inset pass
|
|
203
|
+
// instead of drawing a squished viewport into it.
|
|
204
|
+
const insetRect = insetCollapsed ? { x: 0, y: 0, w: 0, h: 0 } : rectOf(insetRef.current);
|
|
205
|
+
if (mainRect.w < 2 || (!playMode && !insetCollapsed && insetRect.w < 2)) return;
|
|
206
|
+
|
|
207
|
+
const planPane = planIsMain ? mainRect : insetRect;
|
|
208
|
+
const shotPane = planIsMain ? insetRect : mainRect;
|
|
209
|
+
|
|
210
|
+
// scissor bounds the clear, viewport bounds the image: letterbox bars in
|
|
211
|
+
// the shot pane get painted with the scene background instead of leaking
|
|
212
|
+
// whatever was underneath.
|
|
213
|
+
const draw = (camera, pane, imageRect, ink = true) => {
|
|
214
|
+
const glY = size.height - (pane.y + pane.h);
|
|
215
|
+
gl.setScissorTest(true);
|
|
216
|
+
gl.setScissor(pane.x, glY, pane.w, pane.h);
|
|
217
|
+
const img = imageRect ?? pane;
|
|
218
|
+
const imgY = size.height - (img.y + img.h);
|
|
219
|
+
if (ink) {
|
|
220
|
+
const targetWidth = Math.max(1, Math.round(img.w * gl.getPixelRatio()));
|
|
221
|
+
const targetHeight = Math.max(1, Math.round(img.h * gl.getPixelRatio()));
|
|
222
|
+
edgePass.target.setSize(targetWidth, targetHeight);
|
|
223
|
+
edgePass.material.uniforms.texel.value.set(1 / targetWidth, 1 / targetHeight);
|
|
224
|
+
gl.setRenderTarget(edgePass.target);
|
|
225
|
+
gl.setScissorTest(false);
|
|
226
|
+
gl.setClearColor(0x000000, 0);
|
|
227
|
+
gl.clear(true, true, false);
|
|
228
|
+
scene.overrideMaterial = edgePass.normalMaterial;
|
|
229
|
+
// Editor chrome (grid, gizmo, pins) takes no ink: the override
|
|
230
|
+
// material ignores per-material depthWrite opt-outs, and a
|
|
231
|
+
// stylised outline on UI reads as set dressing. Mask restored
|
|
232
|
+
// exactly, so plan/poser cameras keep their own layer sets.
|
|
233
|
+
const inkMask = camera.layers.mask;
|
|
234
|
+
camera.layers.disable(GIZMO_LAYER);
|
|
235
|
+
gl.render(scene, camera);
|
|
236
|
+
camera.layers.mask = inkMask;
|
|
237
|
+
scene.overrideMaterial = null;
|
|
238
|
+
}
|
|
239
|
+
gl.setRenderTarget(null);
|
|
240
|
+
gl.setScissorTest(true);
|
|
241
|
+
gl.setScissor(pane.x, glY, pane.w, pane.h);
|
|
242
|
+
gl.setViewport(img.x, imgY, img.w, img.h);
|
|
243
|
+
gl.render(scene, camera);
|
|
244
|
+
if (ink) {
|
|
245
|
+
const uniforms = edgePass.material.uniforms;
|
|
246
|
+
uniforms.cameraNear.value = camera.near;
|
|
247
|
+
uniforms.cameraFar.value = camera.far;
|
|
248
|
+
uniforms.orthographic.value = camera.isOrthographicCamera ? 1 : 0;
|
|
249
|
+
gl.autoClear = false;
|
|
250
|
+
gl.render(edgePass.passScene, edgePass.passCamera);
|
|
251
|
+
gl.autoClear = true;
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
gl.autoClear = true;
|
|
256
|
+
shotCam.aspect = SHOT_ASPECT;
|
|
257
|
+
shotCam.updateProjectionMatrix();
|
|
258
|
+
// An orthographic plan keeps every puck the same size and the floor
|
|
259
|
+
// undistorted; a perspective one skews the pucks near the edges and reads
|
|
260
|
+
// as a 3/4 view rather than a plan.
|
|
261
|
+
if (planCam.isOrthographicCamera) {
|
|
262
|
+
// planZoom is the Top-View's camera height: larger zoom = smaller
|
|
263
|
+
// extent = the pucks draw bigger.
|
|
264
|
+
const extent = PLAN_EXTENT / Math.max(0.25, planZoom);
|
|
265
|
+
const a = planPane.w / planPane.h;
|
|
266
|
+
planCam.top = extent;
|
|
267
|
+
planCam.bottom = -extent;
|
|
268
|
+
planCam.left = -extent * a;
|
|
269
|
+
planCam.right = extent * a;
|
|
270
|
+
} else {
|
|
271
|
+
planCam.aspect = planPane.w / planPane.h;
|
|
272
|
+
}
|
|
273
|
+
planCam.updateProjectionMatrix();
|
|
274
|
+
|
|
275
|
+
if (playMode) {
|
|
276
|
+
// PlayView (Unity Game view): the shot camera owns the whole pane —
|
|
277
|
+
// no plan inset, no editing chrome, just the framed output. Editor
|
|
278
|
+
// furniture (selection cage, gizmo, grid, pins) lives on
|
|
279
|
+
// GIZMO_LAYER and is dropped for this draw, mask restored after.
|
|
280
|
+
const playMask = shotCam.layers.mask;
|
|
281
|
+
shotCam.layers.disable(GIZMO_LAYER);
|
|
282
|
+
draw(shotCam, mainRect, fitAspect(mainRect, SHOT_ASPECT));
|
|
283
|
+
shotCam.layers.mask = playMask;
|
|
284
|
+
} else if (ikMode && poserCam) {
|
|
285
|
+
// IK mode: the main pane is the poser working view (free navigation,
|
|
286
|
+
// handle layer visible); the inset is the FROZEN shot camera — the
|
|
287
|
+
// separate camera-placement screen the framing lives on.
|
|
288
|
+
poserCam.aspect = mainRect.w / mainRect.h;
|
|
289
|
+
poserCam.updateProjectionMatrix();
|
|
290
|
+
draw(poserCam, mainRect);
|
|
291
|
+
if (!insetCollapsed) draw(shotCam, insetRect, fitAspect(insetRect, SHOT_ASPECT));
|
|
292
|
+
} else if (planIsMain) {
|
|
293
|
+
draw(planCam, planPane, null, false);
|
|
294
|
+
if (!insetCollapsed) draw(shotCam, shotPane, fitAspect(shotPane, SHOT_ASPECT));
|
|
295
|
+
} else {
|
|
296
|
+
draw(shotCam, shotPane, fitAspect(shotPane, SHOT_ASPECT));
|
|
297
|
+
if (!insetCollapsed) draw(planCam, planPane, null, false);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
gl.setScissorTest(false);
|
|
301
|
+
gl.setViewport(0, 0, size.width, size.height);
|
|
302
|
+
}, 1);
|
|
303
|
+
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
export const HIERARCHY_NODES = [
|
|
2
|
+
{
|
|
3
|
+
id: "shot",
|
|
4
|
+
label: "SHOT 01",
|
|
5
|
+
kind: "shot",
|
|
6
|
+
children: [
|
|
7
|
+
{ id: "camera", label: "Camera", kind: "camera" },
|
|
8
|
+
{
|
|
9
|
+
id: "characters",
|
|
10
|
+
label: "Characters",
|
|
11
|
+
kind: "group",
|
|
12
|
+
children: [
|
|
13
|
+
{
|
|
14
|
+
id: "characterA",
|
|
15
|
+
label: "Character 1",
|
|
16
|
+
kind: "character",
|
|
17
|
+
children: [
|
|
18
|
+
{ id: "characterA.character", label: "Character", kind: "character" },
|
|
19
|
+
{
|
|
20
|
+
id: "characterA.motion",
|
|
21
|
+
label: "Motion",
|
|
22
|
+
kind: "motion",
|
|
23
|
+
children: [
|
|
24
|
+
{ id: "characterA.baseMotion", label: "Base Motion", kind: "clip" },
|
|
25
|
+
{ id: "characterA.promptBlocks", label: "Prompt Blocks", kind: "prompt" },
|
|
26
|
+
{ id: "characterA.ik", label: "IK Corrections", kind: "key" },
|
|
27
|
+
],
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: "characterA.rig",
|
|
31
|
+
label: "Rig",
|
|
32
|
+
kind: "rig",
|
|
33
|
+
children: [
|
|
34
|
+
{ id: "rig.hips", label: "Root / Hips", kind: "bone" },
|
|
35
|
+
{ id: "rig.spine", label: "Spine", kind: "bone" },
|
|
36
|
+
{ id: "rig.leftArm", label: "Left Arm", kind: "bone" },
|
|
37
|
+
{ id: "rig.rightArm", label: "Right Arm", kind: "bone" },
|
|
38
|
+
{ id: "rig.leftLeg", label: "Left Leg", kind: "bone" },
|
|
39
|
+
{ id: "rig.rightLeg", label: "Right Leg", kind: "bone" },
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: "characterB",
|
|
46
|
+
label: "Character 2",
|
|
47
|
+
kind: "character",
|
|
48
|
+
optional: "showB",
|
|
49
|
+
children: [
|
|
50
|
+
{ id: "characterB.character", label: "Character", kind: "character" },
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
{ id: "rootPath", label: "Root Path", kind: "path" },
|
|
56
|
+
{ id: "environment", label: "Environment", kind: "environment" },
|
|
57
|
+
{ id: "props", label: "Props", kind: "props" },
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
export function buildHierarchyNodes(sceneObjects = []) {
|
|
63
|
+
const clone = (node) => {
|
|
64
|
+
const next = { ...node };
|
|
65
|
+
if (node.id === "props") {
|
|
66
|
+
next.children = sceneObjects.map((object) => ({
|
|
67
|
+
id: `object:${object.id}`,
|
|
68
|
+
label: object.name,
|
|
69
|
+
kind: "object",
|
|
70
|
+
}));
|
|
71
|
+
} else if (node.children) {
|
|
72
|
+
next.children = node.children.map(clone);
|
|
73
|
+
}
|
|
74
|
+
return next;
|
|
75
|
+
};
|
|
76
|
+
return HIERARCHY_NODES.map(clone);
|
|
77
|
+
}
|