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.
Files changed (85) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +109 -0
  3. package/THIRD_PARTY_NOTICES.md +29 -0
  4. package/bin/cozyclay.mjs +194 -0
  5. package/dist/ardy/cskel27-rest.json +557 -0
  6. package/dist/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
  7. package/dist/assets/basis_transcoder-o4Hde_L7.js +19 -0
  8. package/dist/assets/draco_decoder-C32yEggz.wasm +0 -0
  9. package/dist/assets/draco_decoder-Z1_iN-Ht.wasm +0 -0
  10. package/dist/assets/draco_decoder-fzg4nYZr.js +34 -0
  11. package/dist/assets/draco_wasm_wrapper-DxJM36Ib.js +117 -0
  12. package/dist/assets/draco_wasm_wrapper-fZCQGLGb.js +116 -0
  13. package/dist/assets/index-BW_S1YRy.js +4395 -0
  14. package/dist/assets/index-nRmmJgap.css +1 -0
  15. package/dist/demo/walk-then-stop.npz +0 -0
  16. package/dist/fonts/instrument-serif-italic-latin.woff2 +0 -0
  17. package/dist/fonts/instrument-serif-latin.woff2 +0 -0
  18. package/dist/fonts/inter-latin.woff2 +0 -0
  19. package/dist/index.html +13 -0
  20. package/dist/models/x-bot-tpose.fbx +0 -0
  21. package/dist/models/y-bot-tpose.fbx +0 -0
  22. package/package.json +80 -0
  23. package/src/App.jsx +3840 -0
  24. package/src/ardy/client.js +140 -0
  25. package/src/ardy/convert.js +313 -0
  26. package/src/ardy/cskel27-neutral.js +39 -0
  27. package/src/ardy/cskel27.js +68 -0
  28. package/src/ardy/export.js +157 -0
  29. package/src/ardy/ik.js +610 -0
  30. package/src/ardy/npz.js +520 -0
  31. package/src/ardy/playback.js +414 -0
  32. package/src/ardy/prompt-clips.js +16 -0
  33. package/src/ardy/timeline-coordinates.js +17 -0
  34. package/src/ardy/timeline-resize.js +11 -0
  35. package/src/ardy/timeline.jsx +723 -0
  36. package/src/ardy/to-cskel27.js +205 -0
  37. package/src/ardy/waypoints.js +364 -0
  38. package/src/camera-follow.js +366 -0
  39. package/src/camera-move.js +286 -0
  40. package/src/controls.jsx +245 -0
  41. package/src/dualview.jsx +305 -0
  42. package/src/hierarchy-model.js +77 -0
  43. package/src/hierarchy-panel.jsx +365 -0
  44. package/src/history.js +90 -0
  45. package/src/main.jsx +10 -0
  46. package/src/object-catalog.jsx +90 -0
  47. package/src/object-gizmo.jsx +755 -0
  48. package/src/planview.jsx +653 -0
  49. package/src/poses.js +424 -0
  50. package/src/posestudio.jsx +811 -0
  51. package/src/props.jsx +302 -0
  52. package/src/room.jsx +64 -0
  53. package/src/scene-history.js +125 -0
  54. package/src/scene-objects.js +420 -0
  55. package/src/shot-authoring.js +113 -0
  56. package/src/shot.js +239 -0
  57. package/src/styles.css +5639 -0
  58. package/src/ui.jsx +391 -0
  59. package/src/use-render-activity.js +96 -0
  60. package/tools/ardy/BRIDGE.md +255 -0
  61. package/tools/ardy/README.md +136 -0
  62. package/tools/ardy/__pycache__/cclay_sequence_generate.cpython-313.pyc +0 -0
  63. package/tools/ardy/bridge.mjs +1427 -0
  64. package/tools/ardy/cclay_motion_edit.py +445 -0
  65. package/tools/ardy/cclay_sequence_generate.py +595 -0
  66. package/tools/ardy/dump-npz.py +205 -0
  67. package/tools/ardy/extract-rest.mjs +299 -0
  68. package/tools/ardy/npz.mjs +335 -0
  69. package/tools/ardy/out/gen-1786443326924-c6019e-generated.npz +0 -0
  70. package/tools/ardy/out/gen-1786443609325-0053db-generated.npz +0 -0
  71. package/tools/ardy/out/gen-1786443835628-749ed1-generated.npz +0 -0
  72. package/tools/ardy/out/gen-1786462605247-03cb19-generated.npz +0 -0
  73. package/tools/ardy/pose-to-npz.mjs +106 -0
  74. package/tools/ardy/run-edit-on-box.sh +73 -0
  75. package/tools/ardy/run-on-box.sh +568 -0
  76. package/tools/ardy/run-sequence-on-box.sh +162 -0
  77. package/tools/ardy/visual-qa.mjs +188 -0
  78. package/tools/ardy/vq-car.mjs +52 -0
  79. package/tools/dev-full.mjs +29 -0
  80. package/tools/process-supervisor.mjs +63 -0
  81. package/tools/qa-browser.mjs +84 -0
  82. package/tools/qa-crop3.mjs +22 -0
  83. package/tools/qa-playview.mjs +49 -0
  84. package/tools/qa-screenshot.mjs +24 -0
  85. package/tools/qa-visual.mjs +51 -0
@@ -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
+ }
@@ -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
+ }