cozyclay 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +674 -0
- package/README.md +109 -0
- package/THIRD_PARTY_NOTICES.md +29 -0
- package/bin/cozyclay.mjs +194 -0
- package/dist/ardy/cskel27-rest.json +557 -0
- package/dist/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
- package/dist/assets/basis_transcoder-o4Hde_L7.js +19 -0
- package/dist/assets/draco_decoder-C32yEggz.wasm +0 -0
- package/dist/assets/draco_decoder-Z1_iN-Ht.wasm +0 -0
- package/dist/assets/draco_decoder-fzg4nYZr.js +34 -0
- package/dist/assets/draco_wasm_wrapper-DxJM36Ib.js +117 -0
- package/dist/assets/draco_wasm_wrapper-fZCQGLGb.js +116 -0
- package/dist/assets/index-BW_S1YRy.js +4395 -0
- package/dist/assets/index-nRmmJgap.css +1 -0
- package/dist/demo/walk-then-stop.npz +0 -0
- package/dist/fonts/instrument-serif-italic-latin.woff2 +0 -0
- package/dist/fonts/instrument-serif-latin.woff2 +0 -0
- package/dist/fonts/inter-latin.woff2 +0 -0
- package/dist/index.html +13 -0
- package/dist/models/x-bot-tpose.fbx +0 -0
- package/dist/models/y-bot-tpose.fbx +0 -0
- package/package.json +80 -0
- package/src/App.jsx +3840 -0
- package/src/ardy/client.js +140 -0
- package/src/ardy/convert.js +313 -0
- package/src/ardy/cskel27-neutral.js +39 -0
- package/src/ardy/cskel27.js +68 -0
- package/src/ardy/export.js +157 -0
- package/src/ardy/ik.js +610 -0
- package/src/ardy/npz.js +520 -0
- package/src/ardy/playback.js +414 -0
- package/src/ardy/prompt-clips.js +16 -0
- package/src/ardy/timeline-coordinates.js +17 -0
- package/src/ardy/timeline-resize.js +11 -0
- package/src/ardy/timeline.jsx +723 -0
- package/src/ardy/to-cskel27.js +205 -0
- package/src/ardy/waypoints.js +364 -0
- package/src/camera-follow.js +366 -0
- package/src/camera-move.js +286 -0
- package/src/controls.jsx +245 -0
- package/src/dualview.jsx +305 -0
- package/src/hierarchy-model.js +77 -0
- package/src/hierarchy-panel.jsx +365 -0
- package/src/history.js +90 -0
- package/src/main.jsx +10 -0
- package/src/object-catalog.jsx +90 -0
- package/src/object-gizmo.jsx +755 -0
- package/src/planview.jsx +653 -0
- package/src/poses.js +424 -0
- package/src/posestudio.jsx +811 -0
- package/src/props.jsx +302 -0
- package/src/room.jsx +64 -0
- package/src/scene-history.js +125 -0
- package/src/scene-objects.js +420 -0
- package/src/shot-authoring.js +113 -0
- package/src/shot.js +239 -0
- package/src/styles.css +5639 -0
- package/src/ui.jsx +391 -0
- package/src/use-render-activity.js +96 -0
- package/tools/ardy/BRIDGE.md +255 -0
- package/tools/ardy/README.md +136 -0
- package/tools/ardy/__pycache__/cclay_sequence_generate.cpython-313.pyc +0 -0
- package/tools/ardy/bridge.mjs +1427 -0
- package/tools/ardy/cclay_motion_edit.py +445 -0
- package/tools/ardy/cclay_sequence_generate.py +595 -0
- package/tools/ardy/dump-npz.py +205 -0
- package/tools/ardy/extract-rest.mjs +299 -0
- package/tools/ardy/npz.mjs +335 -0
- package/tools/ardy/out/gen-1786443326924-c6019e-generated.npz +0 -0
- package/tools/ardy/out/gen-1786443609325-0053db-generated.npz +0 -0
- package/tools/ardy/out/gen-1786443835628-749ed1-generated.npz +0 -0
- package/tools/ardy/out/gen-1786462605247-03cb19-generated.npz +0 -0
- package/tools/ardy/pose-to-npz.mjs +106 -0
- package/tools/ardy/run-edit-on-box.sh +73 -0
- package/tools/ardy/run-on-box.sh +568 -0
- package/tools/ardy/run-sequence-on-box.sh +162 -0
- package/tools/ardy/visual-qa.mjs +188 -0
- package/tools/ardy/vq-car.mjs +52 -0
- package/tools/dev-full.mjs +29 -0
- package/tools/process-supervisor.mjs +63 -0
- package/tools/qa-browser.mjs +84 -0
- package/tools/qa-crop3.mjs +22 -0
- package/tools/qa-playview.mjs +49 -0
- package/tools/qa-screenshot.mjs +24 -0
- package/tools/qa-visual.mjs +51 -0
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scene objects: everything the user drops into the set themselves.
|
|
3
|
+
*
|
|
4
|
+
* A shot needs things in it. The set used to ship a fixed handful of props and
|
|
5
|
+
* no way to add anything, so this module owns the whole lifecycle instead:
|
|
6
|
+
* the catalogue you can create from, the record every object carries, and the
|
|
7
|
+
* transform maths the gizmo and the inspector sliders both go through. One
|
|
8
|
+
* clamp/snap path means a drag in the viewport and a slider nudge can never
|
|
9
|
+
* disagree about what a legal transform is.
|
|
10
|
+
*
|
|
11
|
+
* Record shape:
|
|
12
|
+
* { id, name, renderer, x, y, z, rot, rotX, rotZ, scaleX, scaleY, scaleZ,
|
|
13
|
+
* color, footprint: { width, depth }, height }
|
|
14
|
+
* Position is metres on the floor plane (`y` is height above the deck, 0 =
|
|
15
|
+
* standing on it). `rot` stays the Y (yaw) angle in degrees — the bird's-eye
|
|
16
|
+
* board and its handles are built on it — with `rotX`/`rotZ` the pitch/roll
|
|
17
|
+
* the 3D gizmo's other two rings drive.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { Euler, Quaternion } from "three";
|
|
21
|
+
|
|
22
|
+
export const DEFAULT_SCENE_OBJECTS = [];
|
|
23
|
+
/** The persistence contract (plan §8.1): the version lives in the key AND in
|
|
24
|
+
* the body, so a future v2 can read a v1 body. The quarantine key holds a
|
|
25
|
+
* corrupt payload byte-for-byte until an older build can be upgraded. */
|
|
26
|
+
export const SCENE_STORAGE_KEY = "cozyclay.scene.v1";
|
|
27
|
+
export const SCENE_QUARANTINE_KEY = "cozyclay.scene.v1.quarantine";
|
|
28
|
+
export const SCENE_VERSION = 1;
|
|
29
|
+
|
|
30
|
+
/** Euler convention shared with the renderer in props.jsx. */
|
|
31
|
+
const EULER_ORDER = "XYZ";
|
|
32
|
+
const DEG = Math.PI / 180;
|
|
33
|
+
|
|
34
|
+
/** Room half-extent; matches the plan board's ROOM_LIMIT. */
|
|
35
|
+
const ROOM_LIMIT = 11;
|
|
36
|
+
const CEILING = 6;
|
|
37
|
+
const SCALE_MIN = 0.1;
|
|
38
|
+
const SCALE_MAX = 100;
|
|
39
|
+
|
|
40
|
+
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
|
|
41
|
+
/** degrees folded into [-180, 180), the range both rotation sliders span */
|
|
42
|
+
export const wrapAngle = (deg) => ((((deg + 180) % 360) + 360) % 360) - 180;
|
|
43
|
+
/** Snap to a detent AND to that detent's own precision: plain multiplication
|
|
44
|
+
* leaves 0.05 grids reading -1.7000000000000002 in the inspector. A step of 0
|
|
45
|
+
* means "no detent" — a free drag still rounds, or the inspector would show a
|
|
46
|
+
* position of 1.2999999999999998. */
|
|
47
|
+
const snapTo = (value, step) => Number((step > 0 ? Math.round(value / step) * step : value).toFixed(4));
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* What you can create. `group` is the catalogue heading, `footprint` is the
|
|
51
|
+
* plan-board rectangle in metres, `height` is how tall the untransformed
|
|
52
|
+
* object stands (used for the selection box and the gizmo's height).
|
|
53
|
+
*
|
|
54
|
+
* Primitives are grey-box grey on purpose: a blockout stands in for something
|
|
55
|
+
* else, and a tinted maquette reads as a finished prop. The hand-built set
|
|
56
|
+
* pieces keep their clay colours because they ARE the thing they depict.
|
|
57
|
+
*/
|
|
58
|
+
const GREY_BOX = "#c2c6c8";
|
|
59
|
+
export const OBJECT_LIBRARY = [
|
|
60
|
+
{ kind: "cube", label: "Cube", group: "Primitives", footprint: { width: 1, depth: 1 }, height: 1, color: GREY_BOX },
|
|
61
|
+
{ kind: "sphere", label: "Sphere", group: "Primitives", footprint: { width: 1, depth: 1 }, height: 1, color: GREY_BOX },
|
|
62
|
+
{ kind: "capsule", label: "Capsule", group: "Primitives", footprint: { width: 0.7, depth: 0.7 }, height: 1.4, color: GREY_BOX },
|
|
63
|
+
{ kind: "cylinder", label: "Cylinder", group: "Primitives", footprint: { width: 1, depth: 1 }, height: 1, color: GREY_BOX },
|
|
64
|
+
{ kind: "cone", label: "Cone", group: "Primitives", footprint: { width: 1, depth: 1 }, height: 1, color: GREY_BOX },
|
|
65
|
+
{ kind: "plane", label: "Plane", group: "Primitives", footprint: { width: 2, depth: 2 }, height: 0, color: GREY_BOX },
|
|
66
|
+
{ kind: "chair", label: "Chair", group: "Set pieces", footprint: { width: 0.6, depth: 0.6 }, height: 1.15, color: "#b9855d" },
|
|
67
|
+
{ kind: "car", label: "Car", group: "Set pieces", footprint: { width: 1.8, depth: 4.5 }, height: 1.4, color: "#d98770" },
|
|
68
|
+
{ kind: "small-plane", label: "Plane (aircraft)", group: "Set pieces", footprint: { width: 3.4, depth: 3.6 }, height: 1.4, color: "#7896a4" },
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
/** Blockout greys first, then the clay accents, for re-tinting from the
|
|
72
|
+
* inspector. */
|
|
73
|
+
export const OBJECT_COLORS = ["#e2e5e6", GREY_BOX, "#9aa1a5", "#767d81", "#d9b18c", "#8fae9b"];
|
|
74
|
+
|
|
75
|
+
function objectLibraryEntry(kind) {
|
|
76
|
+
return OBJECT_LIBRARY.find((entry) => entry.kind === kind) ?? null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function sceneObjectHierarchyId(id) {
|
|
80
|
+
return `object:${id}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function sceneObjectIdFromHierarchy(hierarchyId) {
|
|
84
|
+
return hierarchyId.startsWith("object:") ? hierarchyId.slice("object:".length) : null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A fresh object of `kind`, named and identified uniquely against `existing`.
|
|
89
|
+
* `placement` seeds the floor position (the caller drops it in front of the
|
|
90
|
+
* camera); everything else starts neutral so the first drag is predictable.
|
|
91
|
+
*/
|
|
92
|
+
export function createSceneObject(kind, existing = [], placement = {}) {
|
|
93
|
+
const entry = objectLibraryEntry(kind);
|
|
94
|
+
if (!entry) return null;
|
|
95
|
+
const names = new Set(existing.map((object) => object.name));
|
|
96
|
+
let name = entry.label;
|
|
97
|
+
for (let n = 2; names.has(name); n += 1) name = `${entry.label} ${n}`;
|
|
98
|
+
const ids = new Set(existing.map((object) => object.id));
|
|
99
|
+
let id = kind;
|
|
100
|
+
for (let n = 2; ids.has(id); n += 1) id = `${kind}-${n}`;
|
|
101
|
+
return {
|
|
102
|
+
id,
|
|
103
|
+
name,
|
|
104
|
+
renderer: kind,
|
|
105
|
+
x: clamp(Number(placement.x) || 0, -ROOM_LIMIT, ROOM_LIMIT),
|
|
106
|
+
y: 0,
|
|
107
|
+
z: clamp(Number(placement.z) || 0, -ROOM_LIMIT, ROOM_LIMIT),
|
|
108
|
+
rot: wrapAngle(Number(placement.rot) || 0),
|
|
109
|
+
rotX: 0,
|
|
110
|
+
rotZ: 0,
|
|
111
|
+
scaleX: 1,
|
|
112
|
+
scaleY: 1,
|
|
113
|
+
scaleZ: 1,
|
|
114
|
+
color: entry.color,
|
|
115
|
+
footprint: { ...entry.footprint },
|
|
116
|
+
height: entry.height,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Every writable transform channel and the rule that keeps it in the room. */
|
|
121
|
+
const TRANSFORM_LIMITS = {
|
|
122
|
+
x: (value) => clamp(value, -ROOM_LIMIT, ROOM_LIMIT),
|
|
123
|
+
y: (value) => clamp(value, 0, CEILING),
|
|
124
|
+
z: (value) => clamp(value, -ROOM_LIMIT, ROOM_LIMIT),
|
|
125
|
+
rot: wrapAngle,
|
|
126
|
+
rotX: wrapAngle,
|
|
127
|
+
rotZ: wrapAngle,
|
|
128
|
+
scaleX: (value) => clamp(value, SCALE_MIN, SCALE_MAX),
|
|
129
|
+
scaleY: (value) => clamp(value, SCALE_MIN, SCALE_MAX),
|
|
130
|
+
scaleZ: (value) => clamp(value, SCALE_MIN, SCALE_MAX),
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
export function updateSceneObject(objects, id, patch) {
|
|
134
|
+
let changed = false;
|
|
135
|
+
const next = objects.map((object) => {
|
|
136
|
+
if (object.id !== id) return object;
|
|
137
|
+
const update = {};
|
|
138
|
+
for (const [key, limit] of Object.entries(TRANSFORM_LIMITS)) {
|
|
139
|
+
if (patch[key] === undefined) continue;
|
|
140
|
+
const value = Number(patch[key]);
|
|
141
|
+
if (!Number.isFinite(value)) continue;
|
|
142
|
+
const bounded = limit(value);
|
|
143
|
+
if (bounded === object[key]) continue;
|
|
144
|
+
update[key] = bounded;
|
|
145
|
+
}
|
|
146
|
+
for (const key of ["name", "color"]) {
|
|
147
|
+
if (typeof patch[key] !== "string" || !patch[key] || patch[key] === object[key]) continue;
|
|
148
|
+
update[key] = patch[key];
|
|
149
|
+
}
|
|
150
|
+
if (!Object.keys(update).length) return object;
|
|
151
|
+
changed = true;
|
|
152
|
+
return { ...object, ...update };
|
|
153
|
+
});
|
|
154
|
+
return changed ? next : objects;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function removeSceneObject(objects, id) {
|
|
158
|
+
const next = objects.filter((object) => object.id !== id);
|
|
159
|
+
return next.length === objects.length ? objects : next;
|
|
160
|
+
}
|
|
161
|
+
/* -------------------------------------------------- persistence ---- */
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Repair one stored record into a live record, or return null to drop it.
|
|
165
|
+
* Storage is never trusted: `footprint`/`height` are rebuilt from the
|
|
166
|
+
* library, every transform channel goes through the same clamps the editor
|
|
167
|
+
* uses, and missing channels take `createSceneObject` defaults. An unknown
|
|
168
|
+
* `renderer` (or a record without an id) has nothing to render or address,
|
|
169
|
+
* so it is dropped rather than half-restored (plan §8.2).
|
|
170
|
+
*/
|
|
171
|
+
export function normalizeSceneObject(record) {
|
|
172
|
+
if (!record || typeof record !== "object" || Array.isArray(record)) return null;
|
|
173
|
+
const entry = objectLibraryEntry(record.renderer);
|
|
174
|
+
if (!entry) return null;
|
|
175
|
+
if (typeof record.id !== "string" || !record.id) return null;
|
|
176
|
+
// Defensive import fallback, not a migration: hand-authored or external
|
|
177
|
+
// payloads may carry one `scale` (the pre-split record shape). It fans
|
|
178
|
+
// out to all three axes only when no axis is present — an explicit
|
|
179
|
+
// scaleX wins over the fallback.
|
|
180
|
+
const hasSingleScale = record.scaleX === undefined && record.scaleY === undefined && record.scaleZ === undefined;
|
|
181
|
+
const singleScale = hasSingleScale ? Number(record.scale) : NaN;
|
|
182
|
+
const scaleFallback = Number.isFinite(singleScale) ? singleScale : 1;
|
|
183
|
+
const pick = (value, fallback) => {
|
|
184
|
+
const n = value === undefined ? fallback : Number(value);
|
|
185
|
+
return Number.isFinite(n) ? n : fallback;
|
|
186
|
+
};
|
|
187
|
+
return {
|
|
188
|
+
id: record.id,
|
|
189
|
+
name: typeof record.name === "string" && record.name ? record.name : entry.label,
|
|
190
|
+
renderer: entry.kind,
|
|
191
|
+
x: TRANSFORM_LIMITS.x(pick(record.x, 0)),
|
|
192
|
+
y: TRANSFORM_LIMITS.y(pick(record.y, 0)),
|
|
193
|
+
z: TRANSFORM_LIMITS.z(pick(record.z, 0)),
|
|
194
|
+
rot: TRANSFORM_LIMITS.rot(pick(record.rot, 0)),
|
|
195
|
+
rotX: TRANSFORM_LIMITS.rotX(pick(record.rotX, 0)),
|
|
196
|
+
rotZ: TRANSFORM_LIMITS.rotZ(pick(record.rotZ, 0)),
|
|
197
|
+
scaleX: TRANSFORM_LIMITS.scaleX(pick(record.scaleX, scaleFallback)),
|
|
198
|
+
scaleY: TRANSFORM_LIMITS.scaleY(pick(record.scaleY, scaleFallback)),
|
|
199
|
+
scaleZ: TRANSFORM_LIMITS.scaleZ(pick(record.scaleZ, scaleFallback)),
|
|
200
|
+
color: typeof record.color === "string" && record.color ? record.color : entry.color,
|
|
201
|
+
footprint: { ...entry.footprint },
|
|
202
|
+
height: entry.height,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The only writer. The body carries the version alongside the key so a
|
|
207
|
+
* future build can read today's payload after a key rename. */
|
|
208
|
+
export function serializeScene(objects) {
|
|
209
|
+
return JSON.stringify({ version: SCENE_VERSION, objects });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Total, consistent tag predicate (plan §8.2): every input falls into exactly
|
|
214
|
+
* one row and this never throws. `absent` and `corrupt` fall back to the
|
|
215
|
+
* defaults; `future` is quarantined in App — never overwritten.
|
|
216
|
+
*/
|
|
217
|
+
export function loadScene(raw) {
|
|
218
|
+
if (raw === null || raw === undefined || raw === "") {
|
|
219
|
+
return { status: "absent", objects: [], dropped: 0 };
|
|
220
|
+
}
|
|
221
|
+
let payload;
|
|
222
|
+
try {
|
|
223
|
+
payload = JSON.parse(raw);
|
|
224
|
+
} catch {
|
|
225
|
+
return { status: "corrupt", objects: [], dropped: 0 };
|
|
226
|
+
}
|
|
227
|
+
// A scene body is a non-array plain object holding an array of records.
|
|
228
|
+
if (payload === null || typeof payload !== "object" || Array.isArray(payload) || !Array.isArray(payload.objects)) {
|
|
229
|
+
return { status: "corrupt", objects: [], dropped: 0 };
|
|
230
|
+
}
|
|
231
|
+
const { version } = payload;
|
|
232
|
+
// The supported range is exactly the integers 1..SCENE_VERSION; a
|
|
233
|
+
// malformed version ("1", 1.5, 0, -1, NaN) is corrupt, never future.
|
|
234
|
+
if (!Number.isInteger(version) || version < 1) {
|
|
235
|
+
return { status: "corrupt", objects: [], dropped: 0 };
|
|
236
|
+
}
|
|
237
|
+
if (version > SCENE_VERSION) {
|
|
238
|
+
return { status: "future", objects: [], dropped: 0 };
|
|
239
|
+
}
|
|
240
|
+
const seen = new Set();
|
|
241
|
+
const objects = [];
|
|
242
|
+
let dropped = 0;
|
|
243
|
+
for (const record of payload.objects) {
|
|
244
|
+
const normalized = normalizeSceneObject(record);
|
|
245
|
+
// Unknown renderers and duplicate ids are dropped and counted, so the
|
|
246
|
+
// caller can report what was lost instead of silently degrading.
|
|
247
|
+
if (!normalized || seen.has(normalized.id)) {
|
|
248
|
+
dropped += 1;
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
seen.add(normalized.id);
|
|
252
|
+
objects.push(normalized);
|
|
253
|
+
}
|
|
254
|
+
return { status: "valid", objects, dropped };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/* ------------------------------------------------------------- gizmo ---- */
|
|
258
|
+
|
|
259
|
+
/** The three world axes the gizmo drags along, and the record field each one
|
|
260
|
+
* rotates / scales. */
|
|
261
|
+
const WORLD_AXES = ["x", "y", "z"];
|
|
262
|
+
const ROTATION_KEYS = { x: "rotX", y: "rot", z: "rotZ" };
|
|
263
|
+
const SCALE_KEYS = { x: "scaleX", y: "scaleY", z: "scaleZ" };
|
|
264
|
+
/** 5 cm translate detents, 5° rotate detents and 5% scale steps: the same grid
|
|
265
|
+
* the plan board blocks on, so an object dragged in 3D lands where the top-down
|
|
266
|
+
* view expects. */
|
|
267
|
+
const TRANSLATE_SNAP = 0.05;
|
|
268
|
+
const ROTATE_SNAP = 5;
|
|
269
|
+
const SCALE_SNAP = 0.05;
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Axis-drag result. `start` is the object as it was when the drag began and
|
|
273
|
+
* `distance` the travel along that world axis, so a move is absolute and can
|
|
274
|
+
* never compound across pointer ticks.
|
|
275
|
+
*/
|
|
276
|
+
export function translatePatch(start, axis, distance, snap = TRANSLATE_SNAP) {
|
|
277
|
+
if (!WORLD_AXES.includes(axis) || !Number.isFinite(distance)) return null;
|
|
278
|
+
const value = snapTo((start[axis] ?? 0) + distance, snap);
|
|
279
|
+
return { [axis]: value };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Ring-drag result: `deltaDeg` degrees added to the drag-start angle. */
|
|
283
|
+
export function rotatePatch(start, axis, deltaDeg, snap = ROTATE_SNAP) {
|
|
284
|
+
const key = ROTATION_KEYS[axis];
|
|
285
|
+
if (!key || !Number.isFinite(deltaDeg)) return null;
|
|
286
|
+
return { [key]: wrapAngle(snapTo((start[key] ?? 0) + deltaDeg, snap)) };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Scale-drag result. `factor` is how much bigger the handle's axis got over the
|
|
291
|
+
* drag (1 = untouched); `axis` null scales all three at once, which is what the
|
|
292
|
+
* gizmo's centre box does.
|
|
293
|
+
*/
|
|
294
|
+
export function scalePatch(start, axis, factor, snap = SCALE_SNAP) {
|
|
295
|
+
if (!Number.isFinite(factor) || factor <= 0) return null;
|
|
296
|
+
const axes = axis === null ? WORLD_AXES : WORLD_AXES.includes(axis) ? [axis] : [];
|
|
297
|
+
if (!axes.length) return null;
|
|
298
|
+
const patch = {};
|
|
299
|
+
for (const each of axes) {
|
|
300
|
+
const key = SCALE_KEYS[each];
|
|
301
|
+
patch[key] = Math.max(SCALE_MIN, snapTo((start[key] ?? 1) * factor, snap));
|
|
302
|
+
}
|
|
303
|
+
return patch;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Screen-ring result: `deltaDeg` about an arbitrary world axis (the camera's
|
|
308
|
+
* view direction), composed onto the drag-start orientation. The record stores
|
|
309
|
+
* Euler degrees per world axis, so the spin is done in quaternion space and all
|
|
310
|
+
* three channels are written back together — anything less would drift.
|
|
311
|
+
*/
|
|
312
|
+
export function screenRotatePatch(start, viewAxis, deltaDeg, snap = ROTATE_SNAP) {
|
|
313
|
+
if (!Number.isFinite(deltaDeg) || !viewAxis) return null;
|
|
314
|
+
const turned = snapTo(deltaDeg, snap);
|
|
315
|
+
const orientation = new Quaternion().setFromEuler(
|
|
316
|
+
new Euler((start.rotX ?? 0) * DEG, (start.rot ?? 0) * DEG, (start.rotZ ?? 0) * DEG, EULER_ORDER),
|
|
317
|
+
);
|
|
318
|
+
// world-space delta: new = delta · old, so the object spins about the view
|
|
319
|
+
// axis no matter how it is already turned
|
|
320
|
+
orientation.premultiply(new Quaternion().setFromAxisAngle(viewAxis, turned * DEG));
|
|
321
|
+
const back = new Euler().setFromQuaternion(orientation, EULER_ORDER);
|
|
322
|
+
return {
|
|
323
|
+
rotX: wrapAngle(snapTo(back.x / DEG, 0)),
|
|
324
|
+
rot: wrapAngle(snapTo(back.y / DEG, 0)),
|
|
325
|
+
rotZ: wrapAngle(snapTo(back.z / DEG, 0)),
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** The object's world size along each axis, for the gizmo and the plan board. */
|
|
330
|
+
export function objectSize(object) {
|
|
331
|
+
return {
|
|
332
|
+
width: (object.footprint?.width ?? 1) * (object.scaleX ?? 1),
|
|
333
|
+
height: (object.height ?? 1) * (object.scaleY ?? 1),
|
|
334
|
+
depth: (object.footprint?.depth ?? 1) * (object.scaleZ ?? 1),
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
/* -------------------------------------------------- drop-to-surface ---- */
|
|
338
|
+
|
|
339
|
+
/** The tolerance that keeps an edge-abutting footprint from counting as
|
|
340
|
+
* overlap: a strict `<` against `max - EPS` makes a 0.5 - 0.5 touch false. */
|
|
341
|
+
const OVERLAP_EPS = 1e-4;
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* The object's world-space axis-aligned bounds. The footprint rectangle is
|
|
345
|
+
* rotated by `rot` (the yaw the plan board reads) and projected to its AABB:
|
|
346
|
+
* at 45 degrees a long plank widens on both axes. Pitch/roll are a documented
|
|
347
|
+
* approximation — the vertical extent still uses the unrotated height, so a
|
|
348
|
+
* tilted object's support level is approximate while yaw is exact.
|
|
349
|
+
*/
|
|
350
|
+
export function objectFootprintBounds(object) {
|
|
351
|
+
const size = objectSize(object);
|
|
352
|
+
const rot = (object.rot ?? 0) * DEG;
|
|
353
|
+
const c = Math.abs(Math.cos(rot));
|
|
354
|
+
const s = Math.abs(Math.sin(rot));
|
|
355
|
+
const halfW = (size.width * c + size.depth * s) / 2;
|
|
356
|
+
const halfD = (size.width * s + size.depth * c) / 2;
|
|
357
|
+
const x = object.x ?? 0;
|
|
358
|
+
const z = object.z ?? 0;
|
|
359
|
+
const baseY = object.y ?? 0;
|
|
360
|
+
return {
|
|
361
|
+
minX: x - halfW,
|
|
362
|
+
maxX: x + halfW,
|
|
363
|
+
minZ: z - halfD,
|
|
364
|
+
maxZ: z + halfD,
|
|
365
|
+
baseY,
|
|
366
|
+
topY: baseY + size.height,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Where the object would land if it fell straight down, as a `{ y }` patch —
|
|
372
|
+
* PURE: it reads `object` and `others` and mutates nothing. Returns null when
|
|
373
|
+
* the object is already resting exactly on the surface, so a redundant drop
|
|
374
|
+
* can never create a history entry.
|
|
375
|
+
*
|
|
376
|
+
* Strict drop-down (plan §9.2): among the `others` whose projected footprints
|
|
377
|
+
* strictly overlap this object's (EPS keeps edge abutments out), only
|
|
378
|
+
* surfaces whose top is at or below the object's current base are support;
|
|
379
|
+
* the object lands with its base exactly on the highest such top, or on the
|
|
380
|
+
* floor when there is none. An object already penetrating a surface is
|
|
381
|
+
* therefore NOT supported by it and falls through — the recovery is to raise
|
|
382
|
+
* Y above the box and press End again. The deferred alternative (a bounded
|
|
383
|
+
* penetration threshold) is cut because the user cannot see the threshold
|
|
384
|
+
* and a second End press would differ from the first.
|
|
385
|
+
*
|
|
386
|
+
* The contact height is exact, never snapped to the 5 cm grid, and never
|
|
387
|
+
* clamped here: the y clamp stays in updateSceneObject, the single owner.
|
|
388
|
+
*/
|
|
389
|
+
export function dropToSurfacePatch(object, others) {
|
|
390
|
+
const self = objectFootprintBounds(object);
|
|
391
|
+
let highestTop = 0;
|
|
392
|
+
for (const other of others) {
|
|
393
|
+
const bounds = objectFootprintBounds(other);
|
|
394
|
+
if (self.minX >= bounds.maxX - OVERLAP_EPS || bounds.minX >= self.maxX - OVERLAP_EPS) continue;
|
|
395
|
+
if (self.minZ >= bounds.maxZ - OVERLAP_EPS || bounds.minZ >= self.maxZ - OVERLAP_EPS) continue;
|
|
396
|
+
if (bounds.topY > self.baseY + OVERLAP_EPS) continue;
|
|
397
|
+
if (bounds.topY > highestTop) highestTop = bounds.topY;
|
|
398
|
+
}
|
|
399
|
+
return highestTop === self.baseY ? null : { y: highestTop };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Where a fresh object should land: on the floor a few metres down the lens, so
|
|
404
|
+
* it appears where you were looking.
|
|
405
|
+
*
|
|
406
|
+
* Position only — the object is created UNROTATED. Unity's primitives arrive
|
|
407
|
+
* axis-aligned at identity rotation, and that is also the only convention that
|
|
408
|
+
* keeps the invariant a blocking tool needs: equal rotation values face the
|
|
409
|
+
* same way. Turning new objects to face the camera (what this used to do) left
|
|
410
|
+
* every box sitting at a skewed angle to the room and to the character, whose
|
|
411
|
+
* own rotation starts at 0. (docs/unity-reference.md §7)
|
|
412
|
+
*/
|
|
413
|
+
export function placementInFront(cameraPos, yaw, distance = 2.6) {
|
|
414
|
+
const x = cameraPos.x - Math.sin(yaw) * distance;
|
|
415
|
+
const z = cameraPos.z - Math.cos(yaw) * distance;
|
|
416
|
+
return {
|
|
417
|
+
x: snapTo(clamp(x, -ROOM_LIMIT, ROOM_LIMIT), TRANSLATE_SNAP),
|
|
418
|
+
z: snapTo(clamp(z, -ROOM_LIMIT, ROOM_LIMIT), TRANSLATE_SNAP),
|
|
419
|
+
};
|
|
420
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shot-authoring persistence: the camera move keys, the root waypoints and
|
|
3
|
+
* the clip length survive a reload the same way the scene objects do. The
|
|
4
|
+
* rest of a shot (loaded motion, playhead, transport) is session state and
|
|
5
|
+
* deliberately not stored — a refresh should reopen the authored shot, not
|
|
6
|
+
* resume a half-played preview.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const SHOT_AUTHORING_KEY = "cozyclay.shot-authoring.v1";
|
|
10
|
+
|
|
11
|
+
/** clip length sanity bounds, frames @ 20 fps: 1 s .. 20 min */
|
|
12
|
+
const FRAME_COUNT_MIN = 20;
|
|
13
|
+
const FRAME_COUNT_MAX = 24000;
|
|
14
|
+
|
|
15
|
+
const finite = Number.isFinite;
|
|
16
|
+
|
|
17
|
+
/** framing = { pos: {x,y,z}, yaw, pitch, fovDeg }, all finite numbers */
|
|
18
|
+
function validFraming(framing) {
|
|
19
|
+
return (
|
|
20
|
+
!!framing &&
|
|
21
|
+
typeof framing === "object" &&
|
|
22
|
+
!!framing.pos &&
|
|
23
|
+
finite(framing.pos.x) &&
|
|
24
|
+
finite(framing.pos.y) &&
|
|
25
|
+
finite(framing.pos.z) &&
|
|
26
|
+
finite(framing.yaw) &&
|
|
27
|
+
finite(framing.pitch) &&
|
|
28
|
+
finite(framing.fovDeg)
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** follow-cam parameter bounds: anything outside is a corrupt write, clamp */
|
|
33
|
+
const FOLLOW_BOUNDS = {
|
|
34
|
+
distance: [0.5, 15],
|
|
35
|
+
height: [0.2, 6],
|
|
36
|
+
response: [0.1, 3],
|
|
37
|
+
lead: [0, 1],
|
|
38
|
+
};
|
|
39
|
+
const RAIL_MAX_POINTS = 512;
|
|
40
|
+
|
|
41
|
+
export function serializeShotAuthoring({ cameraKeys = [], waypoints = [], frameCount = null, followCam = null, cameraRail = null }) {
|
|
42
|
+
return JSON.stringify({ version: 1, frameCount, cameraKeys, waypoints, followCam, cameraRail });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Parse a stored payload back into authoring state. Malformed entries are
|
|
47
|
+
* dropped one by one (a bad key must not take the whole shot down with it);
|
|
48
|
+
* anything unusable at the top level returns null and the app starts fresh.
|
|
49
|
+
*/
|
|
50
|
+
export function loadShotAuthoring(raw) {
|
|
51
|
+
let parsed;
|
|
52
|
+
try {
|
|
53
|
+
parsed = JSON.parse(raw || "null");
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
58
|
+
|
|
59
|
+
const frameCount = finite(parsed.frameCount)
|
|
60
|
+
? Math.max(FRAME_COUNT_MIN, Math.min(FRAME_COUNT_MAX, Math.round(parsed.frameCount)))
|
|
61
|
+
: null;
|
|
62
|
+
|
|
63
|
+
const keysByFrame = new Map();
|
|
64
|
+
for (const key of Array.isArray(parsed.cameraKeys) ? parsed.cameraKeys : []) {
|
|
65
|
+
if (!key || !finite(key.frame) || key.frame < 0 || !validFraming(key.framing)) continue;
|
|
66
|
+
const frame = Math.round(key.frame);
|
|
67
|
+
// duplicate frames keep the later entry, matching re-key semantics
|
|
68
|
+
keysByFrame.set(frame, {
|
|
69
|
+
frame,
|
|
70
|
+
framing: {
|
|
71
|
+
pos: { x: key.framing.pos.x, y: key.framing.pos.y, z: key.framing.pos.z },
|
|
72
|
+
yaw: key.framing.yaw,
|
|
73
|
+
pitch: key.framing.pitch,
|
|
74
|
+
fovDeg: key.framing.fovDeg,
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
const cameraKeys = [...keysByFrame.values()].sort((a, b) => a.frame - b.frame);
|
|
79
|
+
|
|
80
|
+
const waypointsByFrame = new Map();
|
|
81
|
+
for (const waypoint of Array.isArray(parsed.waypoints) ? parsed.waypoints : []) {
|
|
82
|
+
if (!waypoint || !finite(waypoint.frame) || waypoint.frame < 0 || !finite(waypoint.x) || !finite(waypoint.z)) continue;
|
|
83
|
+
const frame = Math.round(waypoint.frame);
|
|
84
|
+
waypointsByFrame.set(frame, {
|
|
85
|
+
frame,
|
|
86
|
+
x: waypoint.x,
|
|
87
|
+
z: waypoint.z,
|
|
88
|
+
heading: finite(waypoint.heading) ? waypoint.heading : null,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
const waypoints = [...waypointsByFrame.values()].sort((a, b) => a.frame - b.frame);
|
|
92
|
+
|
|
93
|
+
let followCam = null;
|
|
94
|
+
if (parsed.followCam && typeof parsed.followCam === "object" && !Array.isArray(parsed.followCam)) {
|
|
95
|
+
followCam = { enabled: parsed.followCam.enabled === true };
|
|
96
|
+
for (const [key, [min, max]] of Object.entries(FOLLOW_BOUNDS)) {
|
|
97
|
+
const value = parsed.followCam[key];
|
|
98
|
+
if (finite(value)) followCam[key] = Math.max(min, Math.min(max, value));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let cameraRail = null;
|
|
103
|
+
if (Array.isArray(parsed.cameraRail)) {
|
|
104
|
+
const points = parsed.cameraRail
|
|
105
|
+
.filter((point) => point && finite(point.x) && finite(point.z))
|
|
106
|
+
.slice(0, RAIL_MAX_POINTS)
|
|
107
|
+
.map((point) => ({ x: point.x, z: point.z }));
|
|
108
|
+
// one point is not a rail; keep null so the app falls back to free follow
|
|
109
|
+
if (points.length >= 2) cameraRail = points;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return { frameCount, cameraKeys, waypoints, followCam, cameraRail };
|
|
113
|
+
}
|