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/ui.jsx
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
import { useEffect, useId, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
// Custom dropdown, structurally matched to the reference (`.dropdown`,
|
|
4
|
+
// `dd-caret`, `dropdown-menu`, `dropdown-item`, `dd-check`) with the keyboard
|
|
5
|
+
// support the reference lacks. `highlighted` marks the arrow-key cursor;
|
|
6
|
+
// the selection itself is `active`.
|
|
7
|
+
export function Dropdown({ value, options, onChange, ariaLabel }) {
|
|
8
|
+
const [open, setOpen] = useState(false);
|
|
9
|
+
const [highlight, setHighlight] = useState(-1);
|
|
10
|
+
const rootRef = useRef(null);
|
|
11
|
+
const triggerRef = useRef(null);
|
|
12
|
+
const uid = useId();
|
|
13
|
+
const listId = `${uid}-list`;
|
|
14
|
+
const selectedIndex = options.findIndex((o) => o.value === value);
|
|
15
|
+
const selected = selectedIndex >= 0 ? options[selectedIndex] : null;
|
|
16
|
+
|
|
17
|
+
// Close on any mousedown outside the dropdown; effect cleanup also covers
|
|
18
|
+
// unmounting while open.
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (!open) return;
|
|
21
|
+
const onDocMouseDown = (e) => {
|
|
22
|
+
if (rootRef.current && !rootRef.current.contains(e.target)) setOpen(false);
|
|
23
|
+
};
|
|
24
|
+
document.addEventListener("mousedown", onDocMouseDown);
|
|
25
|
+
return () => document.removeEventListener("mousedown", onDocMouseDown);
|
|
26
|
+
}, [open]);
|
|
27
|
+
|
|
28
|
+
// Keyboard navigation starts from the current selection.
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (open) setHighlight(selectedIndex);
|
|
31
|
+
}, [open, selectedIndex]);
|
|
32
|
+
|
|
33
|
+
const close = () => setOpen(false);
|
|
34
|
+
|
|
35
|
+
const moveHighlight = (dir) => {
|
|
36
|
+
if (options.length === 0) return;
|
|
37
|
+
setHighlight((h) =>
|
|
38
|
+
h < 0
|
|
39
|
+
? dir > 0
|
|
40
|
+
? 0
|
|
41
|
+
: options.length - 1
|
|
42
|
+
: (h + dir + options.length) % options.length,
|
|
43
|
+
);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const onKeyDown = (e) => {
|
|
47
|
+
if (!open) return;
|
|
48
|
+
if (e.key === "Escape") {
|
|
49
|
+
e.preventDefault();
|
|
50
|
+
close();
|
|
51
|
+
triggerRef.current?.focus();
|
|
52
|
+
} else if (e.key === "ArrowDown") {
|
|
53
|
+
e.preventDefault();
|
|
54
|
+
moveHighlight(1);
|
|
55
|
+
} else if (e.key === "ArrowUp") {
|
|
56
|
+
e.preventDefault();
|
|
57
|
+
moveHighlight(-1);
|
|
58
|
+
} else if (e.key === "Enter" && highlight >= 0 && highlight < options.length) {
|
|
59
|
+
e.preventDefault();
|
|
60
|
+
onChange(options[highlight].value);
|
|
61
|
+
close();
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div className={"dropdown" + (open ? " open" : "")} ref={rootRef} onKeyDown={onKeyDown}>
|
|
67
|
+
<button
|
|
68
|
+
type="button"
|
|
69
|
+
className="trigger"
|
|
70
|
+
ref={triggerRef}
|
|
71
|
+
aria-expanded={open}
|
|
72
|
+
aria-haspopup="listbox"
|
|
73
|
+
aria-controls={listId}
|
|
74
|
+
aria-label={ariaLabel}
|
|
75
|
+
onClick={() => setOpen((o) => !o)}
|
|
76
|
+
>
|
|
77
|
+
<span>{selected ? selected.label : ""}</span>
|
|
78
|
+
<svg
|
|
79
|
+
className="dd-caret"
|
|
80
|
+
viewBox="0 0 24 24"
|
|
81
|
+
fill="none"
|
|
82
|
+
stroke="currentColor"
|
|
83
|
+
strokeWidth="2.5"
|
|
84
|
+
strokeLinecap="round"
|
|
85
|
+
strokeLinejoin="round"
|
|
86
|
+
>
|
|
87
|
+
<path d="M6 9l6 6 6-6" />
|
|
88
|
+
</svg>
|
|
89
|
+
</button>
|
|
90
|
+
{open && (
|
|
91
|
+
<div
|
|
92
|
+
className="dropdown-menu"
|
|
93
|
+
id={listId}
|
|
94
|
+
role="listbox"
|
|
95
|
+
aria-activedescendant={highlight >= 0 && highlight < options.length ? `${uid}-opt-${highlight}` : undefined}
|
|
96
|
+
>
|
|
97
|
+
{options.map((opt, i) => (
|
|
98
|
+
<button
|
|
99
|
+
type="button"
|
|
100
|
+
key={opt.value}
|
|
101
|
+
id={`${uid}-opt-${i}`}
|
|
102
|
+
role="option"
|
|
103
|
+
aria-selected={opt.value === value}
|
|
104
|
+
className={
|
|
105
|
+
"dropdown-item" +
|
|
106
|
+
(opt.value === value ? " active" : "") +
|
|
107
|
+
(i === highlight ? " highlighted" : "")
|
|
108
|
+
}
|
|
109
|
+
onClick={() => {
|
|
110
|
+
onChange(opt.value);
|
|
111
|
+
close();
|
|
112
|
+
}}
|
|
113
|
+
>
|
|
114
|
+
<span>{opt.label}</span>
|
|
115
|
+
{opt.value === value && (
|
|
116
|
+
<svg
|
|
117
|
+
className="dd-check"
|
|
118
|
+
viewBox="0 0 24 24"
|
|
119
|
+
fill="none"
|
|
120
|
+
stroke="currentColor"
|
|
121
|
+
strokeWidth="2.5"
|
|
122
|
+
strokeLinecap="round"
|
|
123
|
+
strokeLinejoin="round"
|
|
124
|
+
>
|
|
125
|
+
<path d="M20 6L9 17l-5-5" />
|
|
126
|
+
</svg>
|
|
127
|
+
)}
|
|
128
|
+
</button>
|
|
129
|
+
))}
|
|
130
|
+
</div>
|
|
131
|
+
)}
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Auto-dismissing toast; the timer restarts whenever the message (or
|
|
137
|
+
// duration) changes and is always cleared on unmount.
|
|
138
|
+
export function Toast({ message, onDone, duration = 2200 }) {
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
if (!message) return;
|
|
141
|
+
const timer = setTimeout(onDone, duration);
|
|
142
|
+
return () => clearTimeout(timer);
|
|
143
|
+
// onDone deliberately excluded: callers pass inline callbacks and the
|
|
144
|
+
// timer must survive parent re-renders within one message lifetime.
|
|
145
|
+
}, [message, duration]);
|
|
146
|
+
if (!message) return null;
|
|
147
|
+
return <div className="toast">{message}</div>;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Range slider with the reference's filled-track gradient. `value` may be a
|
|
151
|
+
// raw float (e.g. from drag interactions), so it is clamped and the label is
|
|
152
|
+
// rounded to the slider's own step precision.
|
|
153
|
+
export function Slider({ label, min, max, step, value, unit = "", onChange, compact }) {
|
|
154
|
+
const clamped = Math.min(max, Math.max(min, value));
|
|
155
|
+
const percent = Math.max(0, Math.min(100, ((clamped - min) / (max - min)) * 100));
|
|
156
|
+
const decimals = (String(step).split(".")[1] || "").length;
|
|
157
|
+
const snapped = Math.round((clamped - min) / step) * step + min;
|
|
158
|
+
const text = String(decimals > 0 ? Number(snapped.toFixed(decimals)) : Math.round(snapped));
|
|
159
|
+
const input = (
|
|
160
|
+
<input
|
|
161
|
+
type="range"
|
|
162
|
+
min={min}
|
|
163
|
+
max={max}
|
|
164
|
+
step={step}
|
|
165
|
+
value={clamped}
|
|
166
|
+
style={{ background: `linear-gradient(to right, var(--accent) ${percent}%, var(--track) ${percent}%)` }}
|
|
167
|
+
onChange={(e) => onChange(parseFloat(e.target.value))}
|
|
168
|
+
/>
|
|
169
|
+
);
|
|
170
|
+
if (compact) {
|
|
171
|
+
return (
|
|
172
|
+
<div className="cslider">
|
|
173
|
+
<div className="cslider-head">
|
|
174
|
+
<span>{label}</span>
|
|
175
|
+
<span className="val">
|
|
176
|
+
{text}
|
|
177
|
+
{unit}
|
|
178
|
+
</span>
|
|
179
|
+
</div>
|
|
180
|
+
{input}
|
|
181
|
+
</div>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
return (
|
|
185
|
+
<div className="row">
|
|
186
|
+
<span>{label}</span>
|
|
187
|
+
{input}
|
|
188
|
+
<span className="val">
|
|
189
|
+
{text}
|
|
190
|
+
{unit}
|
|
191
|
+
</span>
|
|
192
|
+
</div>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function Field({ label, children }) {
|
|
197
|
+
return (
|
|
198
|
+
<div className="field">
|
|
199
|
+
<label>{label}</label>
|
|
200
|
+
{children}
|
|
201
|
+
</div>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
export function NumberField({ label, value, step, precision = 2, onChange, onScrubStart, onScrubEnd, title }) {
|
|
205
|
+
const [focused, setFocused] = useState(false);
|
|
206
|
+
const [draft, setDraft] = useState(null);
|
|
207
|
+
// The draft must be readable synchronously: blur() fires the commit
|
|
208
|
+
// handler before React re-renders, so state alone would commit the
|
|
209
|
+
// pre-Escape value.
|
|
210
|
+
const draftRef = useRef(null);
|
|
211
|
+
// Freshest committed prop for a drag baseline, even while a parent
|
|
212
|
+
// re-render from the previous commit is still pending.
|
|
213
|
+
const valueRef = useRef(value);
|
|
214
|
+
const inputRef = useRef(null);
|
|
215
|
+
// The scrub lifecycle's window listeners are registered once (Escape,
|
|
216
|
+
// blur), so the freshest end callback is mirrored like `valueRef`.
|
|
217
|
+
const scrubEndRef = useRef(onScrubEnd);
|
|
218
|
+
scrubEndRef.current = onScrubEnd;
|
|
219
|
+
// `{ x, value, element, pointerId, token }` of the scrub start, null while
|
|
220
|
+
// not scrubbing. `element`/`pointerId` let the teardown release capture;
|
|
221
|
+
// `token` is the open store transaction, null when no scrub props exist.
|
|
222
|
+
const dragRef = useRef(null);
|
|
223
|
+
|
|
224
|
+
valueRef.current = value;
|
|
225
|
+
|
|
226
|
+
const setDraftValue = (text) => {
|
|
227
|
+
draftRef.current = text;
|
|
228
|
+
setDraft(text);
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
// Push the draft to the store when it parses; either way clear it so the
|
|
232
|
+
// field falls back to the incoming prop. Returns the parsed number (or
|
|
233
|
+
// null) so a drag can baseline from what was just committed.
|
|
234
|
+
const commitDraft = () => {
|
|
235
|
+
const parsed = parseFloat(draftRef.current);
|
|
236
|
+
const ok = Number.isFinite(parsed);
|
|
237
|
+
if (ok) onChange(parsed);
|
|
238
|
+
setDraftValue(null);
|
|
239
|
+
return ok ? parsed : null;
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
// Round to `precision` decimals, then let String() drop trailing zeros:
|
|
243
|
+
// 1.50 reads 1.5, 45.0 reads 45.
|
|
244
|
+
const formatValue = (n) => String(Number(n.toFixed(precision)));
|
|
245
|
+
|
|
246
|
+
const onFocus = (e) => {
|
|
247
|
+
setFocused(true);
|
|
248
|
+
// Start from the formatted number so typing replaces the whole value.
|
|
249
|
+
setDraftValue(formatValue(value));
|
|
250
|
+
e.target.select();
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const onBlur = () => {
|
|
254
|
+
commitDraft();
|
|
255
|
+
setFocused(false);
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const onKeyDown = (e) => {
|
|
259
|
+
if (e.key === "Enter") {
|
|
260
|
+
e.preventDefault();
|
|
261
|
+
commitDraft();
|
|
262
|
+
// Keep focus and select the committed value so the next keystroke
|
|
263
|
+
// replaces it, like Unity's inspector.
|
|
264
|
+
e.currentTarget.select();
|
|
265
|
+
} else if (e.key === "Escape") {
|
|
266
|
+
e.preventDefault();
|
|
267
|
+
// Clear the ref first: the blur below commits, and a cleared
|
|
268
|
+
// draft parses to NaN, so the revert survives.
|
|
269
|
+
setDraftValue(null);
|
|
270
|
+
inputRef.current?.blur();
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
// Unity-style scrubbing: drag the axis label to change the value. Pointer
|
|
275
|
+
// capture keeps the drag alive when the cursor leaves the window; Shift
|
|
276
|
+
// moves 10x per pixel, Alt 0.1x.
|
|
277
|
+
const onAxisPointerDown = (e) => {
|
|
278
|
+
e.preventDefault();
|
|
279
|
+
const base = commitDraft();
|
|
280
|
+
const drag = {
|
|
281
|
+
x: e.clientX,
|
|
282
|
+
value: base ?? valueRef.current,
|
|
283
|
+
element: e.currentTarget,
|
|
284
|
+
pointerId: e.pointerId,
|
|
285
|
+
};
|
|
286
|
+
// A scrub is one store transaction: begin before any apply so the
|
|
287
|
+
// whole drag lands as a single undo entry. The teardown is registered
|
|
288
|
+
// as the store's cancel so a settle leaves the scrub inert without
|
|
289
|
+
// this component closing the token itself.
|
|
290
|
+
dragRef.current = drag;
|
|
291
|
+
drag.token = onScrubStart?.({ owner: "field", cancel: () => teardownScrub(drag) });
|
|
292
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const onAxisPointerMove = (e) => {
|
|
296
|
+
const drag = dragRef.current;
|
|
297
|
+
if (!drag) return;
|
|
298
|
+
const multiplier = e.shiftKey ? 10 : e.altKey ? 0.1 : 1;
|
|
299
|
+
onChange(drag.value + (e.clientX - drag.x) * step * multiplier, drag.token);
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
// Drop the scrub ref and release the capture held on the axis label.
|
|
303
|
+
// Shared by every close path AND the store's cancel; never closes the
|
|
304
|
+
// transaction itself — the caller decides the commit value.
|
|
305
|
+
const teardownScrub = (drag) => {
|
|
306
|
+
if (!drag || dragRef.current !== drag) return;
|
|
307
|
+
dragRef.current = null;
|
|
308
|
+
if (drag.element.hasPointerCapture(drag.pointerId)) {
|
|
309
|
+
drag.element.releasePointerCapture(drag.pointerId);
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
const closeScrub = (commit) => {
|
|
314
|
+
const drag = dragRef.current;
|
|
315
|
+
if (!drag) return;
|
|
316
|
+
teardownScrub(drag);
|
|
317
|
+
scrubEndRef.current?.(drag.token, { commit });
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
const endAxisDrag = () => closeScrub(true);
|
|
321
|
+
|
|
322
|
+
useEffect(() => {
|
|
323
|
+
// While a scrub is live, Escape cancels the whole drag. It must run in
|
|
324
|
+
// the capture phase and stop propagation so the same press cannot also
|
|
325
|
+
// reach the input's revert-and-blur handler or App's Escape handler.
|
|
326
|
+
// Window blur commits like pointerup: travel already applied is real
|
|
327
|
+
// work, not intent to discard (plan §6.3).
|
|
328
|
+
const onEscape = (event) => {
|
|
329
|
+
if (event.key !== "Escape") return;
|
|
330
|
+
if (!dragRef.current) return;
|
|
331
|
+
event.stopPropagation();
|
|
332
|
+
closeScrub(false);
|
|
333
|
+
};
|
|
334
|
+
const onBlur = () => closeScrub(true);
|
|
335
|
+
window.addEventListener("keydown", onEscape, true);
|
|
336
|
+
window.addEventListener("blur", onBlur);
|
|
337
|
+
return () => {
|
|
338
|
+
window.removeEventListener("keydown", onEscape, true);
|
|
339
|
+
window.removeEventListener("blur", onBlur);
|
|
340
|
+
closeScrub(true);
|
|
341
|
+
};
|
|
342
|
+
}, []);
|
|
343
|
+
|
|
344
|
+
const display = focused && draft !== null ? draft : formatValue(value);
|
|
345
|
+
|
|
346
|
+
return (
|
|
347
|
+
<span className="number-field" title={title}>
|
|
348
|
+
<span
|
|
349
|
+
className="axis"
|
|
350
|
+
onPointerDown={onAxisPointerDown}
|
|
351
|
+
onPointerMove={onAxisPointerMove}
|
|
352
|
+
onPointerUp={endAxisDrag}
|
|
353
|
+
onPointerCancel={endAxisDrag}
|
|
354
|
+
>
|
|
355
|
+
{label}
|
|
356
|
+
</span>
|
|
357
|
+
<input
|
|
358
|
+
ref={inputRef}
|
|
359
|
+
type="text"
|
|
360
|
+
inputMode="decimal"
|
|
361
|
+
value={display}
|
|
362
|
+
onFocus={onFocus}
|
|
363
|
+
onBlur={onBlur}
|
|
364
|
+
onChange={(e) => setDraftValue(e.target.value)}
|
|
365
|
+
onKeyDown={onKeyDown}
|
|
366
|
+
/>
|
|
367
|
+
</span>
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// One Unity-style transform row: a row label plus one scrubbable NumberField
|
|
372
|
+
// per axis. `fields` entries are `{ axis, value, step, precision, onChange }`.
|
|
373
|
+
export function Vector3Row({ label, fields }) {
|
|
374
|
+
return (
|
|
375
|
+
<div className="vec3-row">
|
|
376
|
+
<span className="vec3-label">{label}</span>
|
|
377
|
+
{fields.map((field) => (
|
|
378
|
+
<NumberField
|
|
379
|
+
key={field.axis}
|
|
380
|
+
label={field.axis}
|
|
381
|
+
value={field.value}
|
|
382
|
+
step={field.step}
|
|
383
|
+
precision={field.precision}
|
|
384
|
+
onChange={field.onChange}
|
|
385
|
+
onScrubStart={field.onScrubStart}
|
|
386
|
+
onScrubEnd={field.onScrubEnd}
|
|
387
|
+
/>
|
|
388
|
+
))}
|
|
389
|
+
</div>
|
|
390
|
+
);
|
|
391
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
|
|
3
|
+
export const RENDER_ACTIVITY_KEYS = new Set([
|
|
4
|
+
"KeyW",
|
|
5
|
+
"KeyA",
|
|
6
|
+
"KeyS",
|
|
7
|
+
"KeyD",
|
|
8
|
+
"KeyQ",
|
|
9
|
+
"KeyE",
|
|
10
|
+
]);
|
|
11
|
+
export const RENDER_ACTIVITY_EVENT = "cozyclay-render-activity";
|
|
12
|
+
|
|
13
|
+
export function hasContinuousRenderActivity(pointerIds, keyCodes, transient) {
|
|
14
|
+
return pointerIds.size > 0 || keyCodes.size > 0 || transient;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Keep the WebGL loop hot only while the user is actively manipulating the
|
|
19
|
+
* scene. React/R3F still invalidates demand frames for ordinary state changes;
|
|
20
|
+
* this hook covers direct ref mutations such as IK drags, camera look/dolly,
|
|
21
|
+
* plan puck drags, scrubbing, and held WASD/QE movement.
|
|
22
|
+
*/
|
|
23
|
+
export function useRenderActivity(alwaysActive) {
|
|
24
|
+
const [interactive, setInteractive] = useState(false);
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
const pointerIds = new Set();
|
|
28
|
+
const keyCodes = new Set();
|
|
29
|
+
let transient = false;
|
|
30
|
+
let transientTimer = null;
|
|
31
|
+
const sync = () => setInteractive(hasContinuousRenderActivity(pointerIds, keyCodes, transient));
|
|
32
|
+
const endTransient = () => {
|
|
33
|
+
transient = false;
|
|
34
|
+
transientTimer = null;
|
|
35
|
+
sync();
|
|
36
|
+
};
|
|
37
|
+
const pulse = () => {
|
|
38
|
+
transient = true;
|
|
39
|
+
if (transientTimer !== null) clearTimeout(transientTimer);
|
|
40
|
+
transientTimer = setTimeout(endTransient, 180);
|
|
41
|
+
sync();
|
|
42
|
+
};
|
|
43
|
+
const onPointerDown = (event) => {
|
|
44
|
+
pointerIds.add(event.pointerId);
|
|
45
|
+
sync();
|
|
46
|
+
};
|
|
47
|
+
const onPointerEnd = (event) => {
|
|
48
|
+
pointerIds.delete(event.pointerId);
|
|
49
|
+
sync();
|
|
50
|
+
};
|
|
51
|
+
const onKeyDown = (event) => {
|
|
52
|
+
if (!RENDER_ACTIVITY_KEYS.has(event.code)) return;
|
|
53
|
+
const element = document.activeElement;
|
|
54
|
+
if (element && ["INPUT", "TEXTAREA", "SELECT"].includes(element.tagName)) return;
|
|
55
|
+
keyCodes.add(event.code);
|
|
56
|
+
sync();
|
|
57
|
+
};
|
|
58
|
+
const onKeyUp = (event) => {
|
|
59
|
+
if (!RENDER_ACTIVITY_KEYS.has(event.code)) return;
|
|
60
|
+
keyCodes.delete(event.code);
|
|
61
|
+
sync();
|
|
62
|
+
};
|
|
63
|
+
const reset = () => {
|
|
64
|
+
pointerIds.clear();
|
|
65
|
+
keyCodes.clear();
|
|
66
|
+
transient = false;
|
|
67
|
+
if (transientTimer !== null) clearTimeout(transientTimer);
|
|
68
|
+
transientTimer = null;
|
|
69
|
+
sync();
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
window.addEventListener("pointerdown", onPointerDown, true);
|
|
73
|
+
window.addEventListener("pointerup", onPointerEnd, true);
|
|
74
|
+
window.addEventListener("pointercancel", onPointerEnd, true);
|
|
75
|
+
window.addEventListener("keydown", onKeyDown, true);
|
|
76
|
+
window.addEventListener("keyup", onKeyUp, true);
|
|
77
|
+
window.addEventListener("wheel", pulse, { capture: true, passive: true });
|
|
78
|
+
window.addEventListener("blur", reset);
|
|
79
|
+
return () => {
|
|
80
|
+
if (transientTimer !== null) clearTimeout(transientTimer);
|
|
81
|
+
window.removeEventListener("pointerdown", onPointerDown, true);
|
|
82
|
+
window.removeEventListener("pointerup", onPointerEnd, true);
|
|
83
|
+
window.removeEventListener("pointercancel", onPointerEnd, true);
|
|
84
|
+
window.removeEventListener("keydown", onKeyDown, true);
|
|
85
|
+
window.removeEventListener("keyup", onKeyUp, true);
|
|
86
|
+
window.removeEventListener("wheel", pulse, true);
|
|
87
|
+
window.removeEventListener("blur", reset);
|
|
88
|
+
};
|
|
89
|
+
}, []);
|
|
90
|
+
|
|
91
|
+
const active = alwaysActive || interactive;
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
window.dispatchEvent(new CustomEvent(RENDER_ACTIVITY_EVENT, { detail: active }));
|
|
94
|
+
}, [active]);
|
|
95
|
+
return active;
|
|
96
|
+
}
|