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,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
+ }