cozyclay 1.2.0 → 1.3.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 (72) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +31 -0
  3. package/THIRD_PARTY_NOTICES.md +37 -1
  4. package/bin/cozyclay.mjs +51 -2
  5. package/dist/ai-camera-control/index.html +406 -0
  6. package/dist/app/index.html +5 -5
  7. package/dist/assets/app-B3U5aut1.js +4811 -0
  8. package/dist/assets/app-BrRF0wso.css +1 -0
  9. package/dist/assets/vision_bundle-jFkh-fIS.js +41 -0
  10. package/dist/fonts/InstrumentSerif-OFL.txt +93 -0
  11. package/dist/fonts/Inter-OFL.txt +92 -0
  12. package/dist/fonts/README.md +15 -0
  13. package/dist/index.html +60 -16
  14. package/dist/sitemap.xml +7 -1
  15. package/mcp/LIVE-PROTOCOL.md +63 -0
  16. package/mcp/README.md +142 -0
  17. package/mcp/ardy-prompts.mjs +170 -0
  18. package/mcp/live-hub.mjs +105 -0
  19. package/mcp/package.json +24 -0
  20. package/mcp/server.mjs +1394 -0
  21. package/package.json +122 -90
  22. package/src/App.jsx +2957 -514
  23. package/src/ardy/cskel27.js +7 -2
  24. package/src/ardy/ik.js +25 -15
  25. package/src/ardy/npz.js +64 -3
  26. package/src/ardy/playback.js +31 -1
  27. package/src/ardy/prompt-clips.js +7 -2
  28. package/src/ardy/retime.js +211 -0
  29. package/src/ardy/timeline-coordinates.js +13 -0
  30. package/src/ardy/timeline.jsx +124 -5
  31. package/src/ardy/to-cskel27.js +34 -12
  32. package/src/ardy/trim.js +33 -0
  33. package/src/asset-pane.jsx +36 -0
  34. package/src/dualview.jsx +14 -8
  35. package/src/hierarchy-model.js +95 -13
  36. package/src/hierarchy-panel.jsx +139 -6
  37. package/src/live-control.js +122 -0
  38. package/src/matte-editor.js +543 -0
  39. package/src/matte.js +503 -0
  40. package/src/multimodel-ingest.js +344 -0
  41. package/src/object-gizmo.jsx +43 -15
  42. package/src/planview.jsx +43 -32
  43. package/src/pose-extract/detector.js +75 -0
  44. package/src/pose-extract/index.js +3 -0
  45. package/src/pose-extract/take.js +87 -0
  46. package/src/pose-extract/video-frames.js +91 -0
  47. package/src/pose-thumbs.js +152 -0
  48. package/src/posestudio.jsx +361 -11
  49. package/src/project-browser.jsx +135 -0
  50. package/src/project.js +289 -0
  51. package/src/props.jsx +69 -3
  52. package/src/room.jsx +14 -35
  53. package/src/scene-asset-cache.js +125 -0
  54. package/src/scene-assets.js +288 -0
  55. package/src/scene-objects.js +245 -7
  56. package/src/scenes.js +207 -26
  57. package/src/shot-authoring.js +55 -13
  58. package/src/styles.css +1296 -129
  59. package/tools/ardy/BRIDGE.md +3 -2
  60. package/tools/ardy/README.md +9 -5
  61. package/tools/ardy/bridge.mjs +57 -1
  62. package/tools/ardy/bvh-cskel27.mjs +1209 -0
  63. package/tools/ardy/cclay_constrained_generate.py +123 -11
  64. package/tools/ardy/cclay_sequence_generate.py +49 -0
  65. package/tools/ardy/extract.mjs +367 -0
  66. package/tools/ardy/footage.mjs +462 -0
  67. package/tools/ardy/npz.mjs +74 -9
  68. package/tools/ardy/run-on-box.sh +25 -0
  69. package/tools/ardy/run-sequence-on-box.sh +15 -0
  70. package/tools/ardy/runners/remote.mjs +8 -2
  71. package/dist/assets/app-Cgpk2hwX.js +0 -4803
  72. package/dist/assets/app-DgZvaAE1.css +0 -1
@@ -0,0 +1,288 @@
1
+ /**
2
+ * Scene assets: the bytes an object points at.
3
+ *
4
+ * A cutout carries an `assetId`, never its picture. The scene document is one
5
+ * JSON string in localStorage (`cozyclay.scenes.v2`), and a single base64 PNG
6
+ * is bigger than the whole budget that string gets — so images live in
7
+ * IndexedDB, keyed by an id the scene can hold on to.
8
+ *
9
+ * The id is derived from the bytes themselves (SHA-256), which buys three
10
+ * things for free: the same picture imported twice is stored once, a
11
+ * duplicated scene shares its originals instead of copying them, and an id is
12
+ * meaningful across a reload, an export and another machine.
13
+ *
14
+ * Everything above the storage adapter is pure and testable in node; the
15
+ * adapter is the only part that needs a browser.
16
+ */
17
+
18
+ /** Content-addressed, so the same bytes always land on the same id. */
19
+ export const ASSET_ID_PREFIX = "img-";
20
+ /** 128 bits of a SHA-256 digest: collision-proof for a project's worth of
21
+ * pictures, and short enough to read in a scene file. */
22
+ const ASSET_ID_HEX = 32;
23
+
24
+ export const ASSET_DB_NAME = "cozyclay.assets";
25
+ export const ASSET_DB_VERSION = 1;
26
+ export const ASSET_STORE_NAME = "images";
27
+
28
+ /** What an import will accept. SVG is excluded on purpose: it is a document
29
+ * that can carry script, and a set piece is not worth that. */
30
+ export const ASSET_IMAGE_TYPES = Object.freeze(["image/png", "image/webp", "image/jpeg", "image/gif"]);
31
+ /** Source-file ceiling. A 40 MP phone photo is fine as an INPUT — it gets
32
+ * decoded and downscaled before anything is stored — but the file itself has
33
+ * to be readable in one bite first. */
34
+ export const ASSET_MAX_SOURCE_BYTES = 32 * 1024 * 1024;
35
+ /** Longest edge kept. A card is a staging surface, not a texture for a hero
36
+ * render, and 2048 is the size every WebGL implementation can hold. */
37
+ export const ASSET_MAX_DIMENSION = 2048;
38
+
39
+ export function isSupportedImageType(type) {
40
+ return typeof type === "string" && ASSET_IMAGE_TYPES.includes(type.toLowerCase());
41
+ }
42
+
43
+ /** An id from a hex digest, tolerant of the caller's case and length. */
44
+ export function assetIdFromDigest(hex) {
45
+ if (typeof hex !== "string") return null;
46
+ const clean = hex.trim().toLowerCase();
47
+ if (!/^[0-9a-f]+$/.test(clean) || clean.length < ASSET_ID_HEX) return null;
48
+ return `${ASSET_ID_PREFIX}${clean.slice(0, ASSET_ID_HEX)}`;
49
+ }
50
+
51
+ export function isAssetId(value) {
52
+ return typeof value === "string" && new RegExp(`^${ASSET_ID_PREFIX}[0-9a-f]{${ASSET_ID_HEX}}$`).test(value);
53
+ }
54
+
55
+ /**
56
+ * The id these bytes will be stored under. `subtle` is injectable so the node
57
+ * tests can hand in `crypto.webcrypto.subtle`; in the browser it is the page's
58
+ * own SubtleCrypto, which needs a secure context (localhost counts).
59
+ */
60
+ export async function assetIdForBytes(bytes, subtle = globalThis.crypto?.subtle) {
61
+ if (!subtle?.digest) throw new Error("SubtleCrypto is unavailable — a secure context is required to import images");
62
+ const buffer = bytes instanceof ArrayBuffer ? bytes : bytes?.buffer ? bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) : null;
63
+ if (!buffer) throw new TypeError("assetIdForBytes needs an ArrayBuffer or a typed array");
64
+ const digest = await subtle.digest("SHA-256", buffer);
65
+ const hex = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
66
+ return assetIdFromDigest(hex);
67
+ }
68
+
69
+ /**
70
+ * The stored size for a decoded image: the longest edge capped, aspect kept,
71
+ * and never enlarged. Returns the source size unchanged (`scaled: false`) when
72
+ * it already fits, so a small picture is stored exactly as it arrived.
73
+ */
74
+ export function downscaleTarget(width, height, max = ASSET_MAX_DIMENSION) {
75
+ const w = Math.max(0, Math.round(Number(width) || 0));
76
+ const h = Math.max(0, Math.round(Number(height) || 0));
77
+ if (!w || !h) return null;
78
+ const longest = Math.max(w, h);
79
+ if (longest <= max) return { width: w, height: h, scaled: false };
80
+ const factor = max / longest;
81
+ // Round to at least 1: a 4096 x 3 strip must not become 2048 x 0.
82
+ return { width: Math.max(1, Math.round(w * factor)), height: Math.max(1, Math.round(h * factor)), scaled: true };
83
+ }
84
+
85
+ /** The card's width / height, from the picture the card wears. */
86
+ export function assetAspect(asset) {
87
+ const width = Number(asset?.width);
88
+ const height = Number(asset?.height);
89
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return null;
90
+ return width / height;
91
+ }
92
+
93
+ /**
94
+ * Repair one stored asset record, or return null to drop it. Storage is never
95
+ * trusted here either: a record without usable bytes or a usable size cannot
96
+ * be drawn, and a card pointing at it is better shown as missing than as a
97
+ * blank quad of the wrong shape.
98
+ */
99
+ export function normalizeAsset(record) {
100
+ if (!record || typeof record !== "object" || Array.isArray(record)) return null;
101
+ if (!isAssetId(record.id)) return null;
102
+ if (!isSupportedImageType(record.type)) return null;
103
+ const width = Math.round(Number(record.width));
104
+ const height = Math.round(Number(record.height));
105
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) return null;
106
+ const bytes = record.bytes instanceof ArrayBuffer ? record.bytes : null;
107
+ if (!bytes || !bytes.byteLength) return null;
108
+ return { id: record.id, type: record.type.toLowerCase(), width, height, bytes, name: typeof record.name === "string" ? record.name : "" };
109
+ }
110
+
111
+ /**
112
+ * The image files in a drop (or a paste), in the order they were dropped.
113
+ *
114
+ * Two quirks make this worth a function rather than a filter inline. A file
115
+ * dragged from some applications arrives with an EMPTY type — the browser has
116
+ * a name and bytes but no sniffed MIME — so the extension is the fallback, not
117
+ * a shortcut. And a drag can carry directories and text as well as files, which
118
+ * `files` reports as entries with no type and no extension; those are dropped
119
+ * rather than handed to a decoder that will fail on them.
120
+ */
121
+ export function imageFilesFrom(transfer) {
122
+ const files = Array.from(transfer?.files ?? []);
123
+ return files.filter((file) => {
124
+ if (!file) return false;
125
+ if (isSupportedImageType(file.type)) return true;
126
+ if (file.type) return false;
127
+ const extension = String(file.name ?? "").toLowerCase().match(/\.([a-z0-9]+)$/)?.[1];
128
+ return ["png", "webp", "jpg", "jpeg", "gif"].includes(extension);
129
+ });
130
+ }
131
+
132
+ /* ------------------------------------------------------------- import ---- */
133
+
134
+ /** Scaling a picture re-encodes it, and the encoder has to be one that can
135
+ * still carry alpha — a cutout IS its transparency. JPEG has none to lose, so
136
+ * a photo stays a photo instead of tripling in size as a PNG. */
137
+ function storedTypeFor(sourceType) {
138
+ return sourceType === "image/jpeg" ? "image/jpeg" : "image/png";
139
+ }
140
+
141
+ function defaultCanvas(width, height) {
142
+ if (typeof OffscreenCanvas === "function") return new OffscreenCanvas(width, height);
143
+ throw new Error("this browser cannot resize images — OffscreenCanvas is unavailable");
144
+ }
145
+
146
+ /**
147
+ * One imported file as a storable asset record: decoded, capped to
148
+ * ASSET_MAX_DIMENSION, and identified by the bytes that will actually be
149
+ * stored (so two imports of the same photo dedupe even after a resize).
150
+ *
151
+ * Every browser API it needs is injectable, which is what lets the node tests
152
+ * drive the whole path with stubs. Failures throw with a sentence fit to show
153
+ * in a toast — the caller has no way to explain "NotReadableError" to anyone.
154
+ */
155
+ export async function importImageFile(file, {
156
+ subtle = globalThis.crypto?.subtle,
157
+ createBitmap = globalThis.createImageBitmap,
158
+ makeCanvas = defaultCanvas,
159
+ maxDimension = ASSET_MAX_DIMENSION,
160
+ maxSourceBytes = ASSET_MAX_SOURCE_BYTES,
161
+ } = {}) {
162
+ if (!file || typeof file.arrayBuffer !== "function") throw new TypeError("importImageFile needs a File or Blob");
163
+ const sourceType = typeof file.type === "string" ? file.type.toLowerCase() : "";
164
+ if (!isSupportedImageType(sourceType)) throw new Error("that file is not an image CozyClay can import (PNG, WebP, JPEG or GIF)");
165
+ if (Number(file.size) > maxSourceBytes) throw new Error(`that image is larger than ${Math.round(maxSourceBytes / (1024 * 1024))} MB`);
166
+ if (typeof createBitmap !== "function") throw new Error("this browser cannot decode images — createImageBitmap is unavailable");
167
+
168
+ const bitmap = await createBitmap(file);
169
+ try {
170
+ const target = downscaleTarget(bitmap.width, bitmap.height, maxDimension);
171
+ if (!target) throw new Error("that image has no usable size");
172
+ let type = sourceType;
173
+ let bytes;
174
+ if (target.scaled) {
175
+ const canvas = makeCanvas(target.width, target.height);
176
+ const context = canvas.getContext("2d");
177
+ if (!context) throw new Error("this browser cannot resize images — no 2D context");
178
+ context.drawImage(bitmap, 0, 0, target.width, target.height);
179
+ type = storedTypeFor(sourceType);
180
+ const blob = await canvas.convertToBlob({ type });
181
+ bytes = await blob.arrayBuffer();
182
+ } else {
183
+ // Unscaled, the original bytes are stored verbatim: no re-encode means
184
+ // no generation loss, and an untouched PNG keeps its exact alpha.
185
+ bytes = await file.arrayBuffer();
186
+ }
187
+ const asset = normalizeAsset({
188
+ id: await assetIdForBytes(bytes, subtle),
189
+ type,
190
+ width: target.width,
191
+ height: target.height,
192
+ bytes,
193
+ name: typeof file.name === "string" ? file.name : "",
194
+ });
195
+ if (!asset) throw new Error("that image could not be prepared for the set");
196
+ return asset;
197
+ } finally {
198
+ bitmap?.close?.();
199
+ }
200
+ }
201
+
202
+ /* ------------------------------------------------------- reachability ---- */
203
+
204
+ /**
205
+ * Every asset id the scenes still point at. Assets outlive the object that
206
+ * imported them — undo brings a deleted cutout back, and two scenes can wear
207
+ * the same picture — so nothing is deleted on the strength of one object
208
+ * going away. This is the reachable set; `unreachableAssetIds` is the sweep.
209
+ */
210
+ export function referencedAssetIds(scenes) {
211
+ const ids = new Set();
212
+ for (const scene of Array.isArray(scenes) ? scenes : []) {
213
+ for (const object of Array.isArray(scene?.objects) ? scene.objects : []) {
214
+ if (isAssetId(object?.assetId)) ids.add(object.assetId);
215
+ }
216
+ }
217
+ return ids;
218
+ }
219
+
220
+ /** Stored ids that nothing points at any more, ready to be swept. */
221
+ export function unreachableAssetIds(storedIds, scenes) {
222
+ const reachable = referencedAssetIds(scenes);
223
+ return (Array.isArray(storedIds) ? storedIds : []).filter((id) => isAssetId(id) && !reachable.has(id));
224
+ }
225
+
226
+ /* ------------------------------------------------------------ storage ---- */
227
+
228
+ const request = (req) =>
229
+ new Promise((resolve, reject) => {
230
+ req.onsuccess = () => resolve(req.result);
231
+ req.onerror = () => reject(req.error);
232
+ });
233
+
234
+ /** Open (and create) the asset database. Injectable factory so a test or a
235
+ * non-browser host can hand in its own IndexedDB implementation. */
236
+ export function openAssetDb(factory = globalThis.indexedDB) {
237
+ if (!factory) return Promise.reject(new Error("IndexedDB is unavailable"));
238
+ return new Promise((resolve, reject) => {
239
+ const open = factory.open(ASSET_DB_NAME, ASSET_DB_VERSION);
240
+ open.onupgradeneeded = () => {
241
+ if (!open.result.objectStoreNames.contains(ASSET_STORE_NAME)) {
242
+ open.result.createObjectStore(ASSET_STORE_NAME, { keyPath: "id" });
243
+ }
244
+ };
245
+ open.onsuccess = () => resolve(open.result);
246
+ open.onerror = () => reject(open.error);
247
+ open.onblocked = () => reject(new Error("the asset database is blocked by another tab"));
248
+ });
249
+ }
250
+
251
+ /** Store a record. Content-addressed ids make this idempotent: re-importing
252
+ * the same picture overwrites it with identical bytes. */
253
+ export async function putAsset(db, record) {
254
+ const asset = normalizeAsset(record);
255
+ if (!asset) throw new TypeError("putAsset needs a normalizable asset record");
256
+ const tx = db.transaction(ASSET_STORE_NAME, "readwrite");
257
+ tx.objectStore(ASSET_STORE_NAME).put(asset);
258
+ await new Promise((resolve, reject) => {
259
+ tx.oncomplete = resolve;
260
+ tx.onerror = () => reject(tx.error);
261
+ tx.onabort = () => reject(tx.error);
262
+ });
263
+ return asset;
264
+ }
265
+
266
+ export async function getAsset(db, id) {
267
+ if (!isAssetId(id)) return null;
268
+ const tx = db.transaction(ASSET_STORE_NAME, "readonly");
269
+ return normalizeAsset(await request(tx.objectStore(ASSET_STORE_NAME).get(id)));
270
+ }
271
+
272
+ export async function listAssetIds(db) {
273
+ const tx = db.transaction(ASSET_STORE_NAME, "readonly");
274
+ const keys = await request(tx.objectStore(ASSET_STORE_NAME).getAllKeys());
275
+ return (keys ?? []).filter(isAssetId);
276
+ }
277
+
278
+ export async function deleteAsset(db, id) {
279
+ if (!isAssetId(id)) return false;
280
+ const tx = db.transaction(ASSET_STORE_NAME, "readwrite");
281
+ tx.objectStore(ASSET_STORE_NAME).delete(id);
282
+ await new Promise((resolve, reject) => {
283
+ tx.oncomplete = resolve;
284
+ tx.onerror = () => reject(tx.error);
285
+ tx.onabort = () => reject(tx.error);
286
+ });
287
+ return true;
288
+ }
@@ -31,9 +31,14 @@ export const SCENE_VERSION = 1;
31
31
  const EULER_ORDER = "XYZ";
32
32
  const DEG = Math.PI / 180;
33
33
 
34
- /** Room half-extent; matches the plan board's ROOM_LIMIT. */
35
- const ROOM_LIMIT = 11;
36
- const CEILING = 6;
34
+ /** Stage half-extent; matches the plan board's ROOM_LIMIT. The set is an
35
+ * open 500 m deck now, so the clamp is a guard against runaway coordinates,
36
+ * not a wall — it stops just inside the floor's edge. */
37
+ const ROOM_LIMIT = 240;
38
+ // Headroom, not a ceiling: the walls (and the 6.2 m room they implied) are
39
+ // gone, so this only stops a runaway coordinate. A rocket, a crane or a
40
+ // skyline piece all have to fit under it.
41
+ const CEILING = 240;
37
42
  const SCALE_MIN = 0.1;
38
43
  const SCALE_MAX = 100;
39
44
 
@@ -72,10 +77,57 @@ export const OBJECT_LIBRARY = [
72
77
  * inspector. */
73
78
  export const OBJECT_COLORS = ["#e2e5e6", GREY_BOX, "#9aa1a5", "#767d81", "#d9b18c", "#8fae9b"];
74
79
 
80
+ /**
81
+ * A cutout is a standee: an imported image standing on a card. Its size is NOT
82
+ * library data — the height is measured by the user and the width follows the
83
+ * picture's own aspect — so the record carries `assetId`, `aspect` and
84
+ * `height`, and the footprint is DERIVED from them. Deriving rather than
85
+ * storing is what stops a card persisting a width its picture disagrees with.
86
+ */
87
+ export const CUTOUT_KIND = "cutout";
88
+ /** Card thickness in metres: thin enough to read as flat, thick enough that
89
+ * the plan board and dropToSurfacePatch still have a rectangle to work on. */
90
+ export const CUTOUT_THICKNESS = 0.02;
91
+ /** A fresh cutout stands as tall as the figure it blocks against
92
+ * (SUBJECT_HEIGHT_M), so the first thing you see is honest scale. */
93
+ export const CUTOUT_DEFAULT_HEIGHT = 1.8;
94
+ /** A card can be as tall as anything else on the deck: the walls are gone and
95
+ * a skyline piece or a building facade is a legitimate cutout. Exported so the
96
+ * inspector's field and the tests take the limit from here rather than
97
+ * repeating a number that has already changed once. */
98
+ export const CUTOUT_MAX_HEIGHT = CEILING;
99
+ const CUTOUT_HEIGHT_MIN = 0.05;
100
+ const CUTOUT_ASPECT_MIN = 0.02;
101
+ const CUTOUT_ASPECT_MAX = 50;
102
+ /** Cutouts carry their own colour: unlike a primitive, the card IS the thing
103
+ * it depicts, so it is tinted white and multiplies the image untouched. */
104
+ const CUTOUT_TINT = "#ffffff";
105
+ const CUTOUT_ENTRY = {
106
+ kind: CUTOUT_KIND,
107
+ label: "Cutout",
108
+ group: "Images",
109
+ footprint: { width: 1, depth: CUTOUT_THICKNESS },
110
+ height: CUTOUT_DEFAULT_HEIGHT,
111
+ color: CUTOUT_TINT,
112
+ };
113
+
114
+ /** Every kind that can exist in a scene: the catalogue you can create from,
115
+ * plus the kinds that arrive by import and so are deliberately absent from the
116
+ * "Add object" menu (a cutout without an image has nothing to draw). */
75
117
  function objectLibraryEntry(kind) {
118
+ if (kind === CUTOUT_KIND) return CUTOUT_ENTRY;
76
119
  return OBJECT_LIBRARY.find((entry) => entry.kind === kind) ?? null;
77
120
  }
78
121
 
122
+ const cutoutHeight = (value) => clamp(value, CUTOUT_HEIGHT_MIN, CUTOUT_MAX_HEIGHT);
123
+ const cutoutAspect = (value) => clamp(value, CUTOUT_ASPECT_MIN, CUTOUT_ASPECT_MAX);
124
+
125
+ /** The plan-board rectangle a card of this height and picture aspect occupies.
126
+ * `aspect` is the image's width / height. */
127
+ export function cutoutFootprint(height, aspect) {
128
+ return { width: cutoutHeight(height) * cutoutAspect(aspect), depth: CUTOUT_THICKNESS };
129
+ }
130
+
79
131
  export function sceneObjectHierarchyId(id) {
80
132
  return `object:${id}`;
81
133
  }
@@ -90,6 +142,9 @@ export function sceneObjectIdFromHierarchy(hierarchyId) {
90
142
  * camera); everything else starts neutral so the first drag is predictable.
91
143
  */
92
144
  export function createSceneObject(kind, existing = [], placement = {}) {
145
+ // Cutouts come from an import, never from the catalogue: without an asset
146
+ // id the record has nothing to draw. `createCutoutObject` is their door.
147
+ if (kind === CUTOUT_KIND) return null;
93
148
  const entry = objectLibraryEntry(kind);
94
149
  if (!entry) return null;
95
150
  const names = new Set(existing.map((object) => object.name));
@@ -112,11 +167,61 @@ export function createSceneObject(kind, existing = [], placement = {}) {
112
167
  scaleY: 1,
113
168
  scaleZ: 1,
114
169
  color: entry.color,
170
+ parent: null,
115
171
  footprint: { ...entry.footprint },
116
172
  height: entry.height,
117
173
  };
118
174
  }
119
175
 
176
+ /**
177
+ * A fresh cutout for an imported image. `assetId` addresses the picture in the
178
+ * asset store — the record never carries the bytes, which is what keeps a
179
+ * scene small enough to live in localStorage — and `aspect` is the image's
180
+ * width / height so the card can be sized by height alone.
181
+ *
182
+ * `name` seeds the display name (the file's own name is the obvious caller
183
+ * choice); everything else starts neutral, exactly like a catalogue object.
184
+ */
185
+ export function createCutoutObject({ assetId, aspect = 1, height = CUTOUT_DEFAULT_HEIGHT, name = "" } = {}, existing = [], placement = {}) {
186
+ if (typeof assetId !== "string" || !assetId) return null;
187
+ const pictureAspect = cutoutAspect(Number(aspect));
188
+ const cardHeight = cutoutHeight(Number(height));
189
+ if (!Number.isFinite(pictureAspect) || !Number.isFinite(cardHeight)) return null;
190
+ const base = typeof name === "string" && name.trim() ? name.trim() : CUTOUT_ENTRY.label;
191
+ const names = new Set(existing.map((object) => object.name));
192
+ let displayName = base;
193
+ for (let n = 2; names.has(displayName); n += 1) displayName = `${base} ${n}`;
194
+ const ids = new Set(existing.map((object) => object.id));
195
+ let id = CUTOUT_KIND;
196
+ for (let n = 2; ids.has(id); n += 1) id = `${CUTOUT_KIND}-${n}`;
197
+ return {
198
+ id,
199
+ name: displayName,
200
+ renderer: CUTOUT_KIND,
201
+ x: clamp(Number(placement.x) || 0, -ROOM_LIMIT, ROOM_LIMIT),
202
+ y: 0,
203
+ z: clamp(Number(placement.z) || 0, -ROOM_LIMIT, ROOM_LIMIT),
204
+ rot: wrapAngle(Number(placement.rot) || 0),
205
+ rotX: 0,
206
+ rotZ: 0,
207
+ scaleX: 1,
208
+ scaleY: 1,
209
+ scaleZ: 1,
210
+ color: CUTOUT_TINT,
211
+ parent: null,
212
+ // Key order matches what `normalizeSceneObject` writes, so a record
213
+ // survives a storage round trip byte-for-byte.
214
+ assetId,
215
+ // A picture nobody has cut is its own original, with no purple on it.
216
+ sourceAssetId: assetId,
217
+ matteAssetId: "",
218
+ matteScale: 1,
219
+ aspect: pictureAspect,
220
+ footprint: cutoutFootprint(cardHeight, pictureAspect),
221
+ height: cardHeight,
222
+ };
223
+ }
224
+
120
225
  /** Every writable transform channel and the rule that keeps it in the room. */
121
226
  const TRANSFORM_LIMITS = {
122
227
  x: (value) => clamp(value, -ROOM_LIMIT, ROOM_LIMIT),
@@ -130,10 +235,60 @@ const TRANSFORM_LIMITS = {
130
235
  scaleZ: (value) => clamp(value, SCALE_MIN, SCALE_MAX),
131
236
  };
132
237
 
238
+ /** Every object that hangs off `id`, at any depth. A cycle cannot form because
239
+ * setParent refuses one, but the seen-set keeps this total even if data is
240
+ * hand-edited into a loop. */
241
+ export function descendantsOf(objects, id) {
242
+ const out = [];
243
+ const seen = new Set([id]);
244
+ let frontier = [id];
245
+ while (frontier.length) {
246
+ const next = [];
247
+ for (const object of objects) {
248
+ if (object.parent && frontier.includes(object.parent) && !seen.has(object.id)) {
249
+ seen.add(object.id);
250
+ out.push(object);
251
+ next.push(object.id);
252
+ }
253
+ }
254
+ frontier = next;
255
+ }
256
+ return out;
257
+ }
258
+
133
259
  export function updateSceneObject(objects, id, patch) {
134
260
  let changed = false;
261
+ const target = objects.find((object) => object.id === id);
262
+ // A parent carries its children: the group is dragged, nudged and dropped as
263
+ // one body. Only translation rides along — rotating or scaling a group would
264
+ // have to orbit and rescale every child about the parent's origin, which is a
265
+ // different feature and is deliberately not pretended at here.
266
+ const carried = target ? descendantsOf(objects, id) : [];
267
+ const delta = { x: 0, y: 0, z: 0 };
268
+ if (target) {
269
+ for (const axis of ["x", "y", "z"]) {
270
+ if (patch[axis] === undefined) continue;
271
+ const value = Number(patch[axis]);
272
+ if (!Number.isFinite(value)) continue;
273
+ delta[axis] = TRANSFORM_LIMITS[axis](value) - target[axis];
274
+ }
275
+ }
276
+ const moving = new Set(carried.map((object) => object.id));
277
+ const shifts = delta.x || delta.y || delta.z;
278
+
135
279
  const next = objects.map((object) => {
136
- if (object.id !== id) return object;
280
+ if (object.id !== id) {
281
+ if (!shifts || !moving.has(object.id)) return object;
282
+ const update = {};
283
+ for (const axis of ["x", "y", "z"]) {
284
+ if (!delta[axis]) continue;
285
+ const bounded = TRANSFORM_LIMITS[axis](object[axis] + delta[axis]);
286
+ if (bounded !== object[axis]) update[axis] = bounded;
287
+ }
288
+ if (!Object.keys(update).length) return object;
289
+ changed = true;
290
+ return { ...object, ...update };
291
+ }
137
292
  const update = {};
138
293
  for (const [key, limit] of Object.entries(TRANSFORM_LIMITS)) {
139
294
  if (patch[key] === undefined) continue;
@@ -147,6 +302,41 @@ export function updateSceneObject(objects, id, patch) {
147
302
  if (typeof patch[key] !== "string" || !patch[key] || patch[key] === object[key]) continue;
148
303
  update[key] = patch[key];
149
304
  }
305
+ // A cutout is sized in metres — you measure something in the picture and
306
+ // type its height — so `height` and `aspect` are writable where every
307
+ // other kind takes them from the library. The footprint is DERIVED here
308
+ // and never patched: one owner for the card's width is what keeps it
309
+ // from disagreeing with its own image.
310
+ if (object.renderer === CUTOUT_KIND) {
311
+ // The picture itself is writable: cutting the background out stores a
312
+ // NEW asset (different bytes, different id) and points the card at it,
313
+ // which is what makes the cut undoable — the original stays addressed
314
+ // by the history entry before it.
315
+ // Three ids, because a cut card is not one picture: `assetId` is what
316
+ // the set renders, `sourceAssetId` is the photograph it came from and
317
+ // keeps being edited from, and `matteAssetId` is the purple itself.
318
+ // Keeping all three is what makes the cut re-editable instead of
319
+ // destructive — the original is never replaced, only masked.
320
+ for (const key of ["assetId", "sourceAssetId", "matteAssetId"]) {
321
+ if (typeof patch[key] === "string" && patch[key] && patch[key] !== object[key]) update[key] = patch[key];
322
+ }
323
+ // How much of the original frame the trimmed card is. Stored so a
324
+ // second edit can work out the height the card would have at full
325
+ // frame instead of compounding one trim onto the last.
326
+ if (Number.isFinite(Number(patch.matteScale))) {
327
+ const scale = clamp(Number(patch.matteScale), 0.01, 1);
328
+ if (scale !== object.matteScale) update.matteScale = scale;
329
+ }
330
+ const patchedHeight = patch.height === undefined ? NaN : cutoutHeight(Number(patch.height));
331
+ const patchedAspect = patch.aspect === undefined ? NaN : cutoutAspect(Number(patch.aspect));
332
+ const height = Number.isFinite(patchedHeight) ? patchedHeight : object.height;
333
+ const aspect = Number.isFinite(patchedAspect) ? patchedAspect : object.aspect;
334
+ if (height !== object.height || aspect !== object.aspect) {
335
+ update.height = height;
336
+ update.aspect = aspect;
337
+ update.footprint = cutoutFootprint(height, aspect);
338
+ }
339
+ }
150
340
  if (!Object.keys(update).length) return object;
151
341
  changed = true;
152
342
  return { ...object, ...update };
@@ -154,9 +344,33 @@ export function updateSceneObject(objects, id, patch) {
154
344
  return changed ? next : objects;
155
345
  }
156
346
 
347
+ /**
348
+ * Attach `id` to `parentId` (or detach with null). Refuses the two shapes that
349
+ * would corrupt the tree: an object parented to itself, and a cycle formed by
350
+ * parenting an object to one of its own descendants.
351
+ */
352
+ export function setSceneObjectParent(objects, id, parentId) {
353
+ if (id === parentId) return objects;
354
+ if (parentId !== null && !objects.some((object) => object.id === parentId)) return objects;
355
+ if (parentId !== null && descendantsOf(objects, id).some((object) => object.id === parentId)) return objects;
356
+ let changed = false;
357
+ const next = objects.map((object) => {
358
+ if (object.id !== id) return object;
359
+ const parent = parentId ?? null;
360
+ if ((object.parent ?? null) === parent) return object;
361
+ changed = true;
362
+ return { ...object, parent };
363
+ });
364
+ return changed ? next : objects;
365
+ }
366
+
157
367
  export function removeSceneObject(objects, id) {
158
368
  const next = objects.filter((object) => object.id !== id);
159
- return next.length === objects.length ? objects : next;
369
+ if (next.length === objects.length) return objects;
370
+ // Deleting a parent must not leave its children pointing at a ghost: they
371
+ // are promoted to top level rather than vanishing with it, because a group
372
+ // is an editing convenience and never an owner of the parts.
373
+ return next.map((object) => (object.parent === id ? { ...object, parent: null } : object));
160
374
  }
161
375
  /* -------------------------------------------------- persistence ---- */
162
376
 
@@ -173,6 +387,11 @@ export function normalizeSceneObject(record) {
173
387
  const entry = objectLibraryEntry(record.renderer);
174
388
  if (!entry) return null;
175
389
  if (typeof record.id !== "string" || !record.id) return null;
390
+ // A cutout addresses its picture by id. A record without one has nothing to
391
+ // draw, so it is dropped rather than restored as a blank card — the same
392
+ // rule an unknown renderer already gets.
393
+ const isCutout = entry.kind === CUTOUT_KIND;
394
+ if (isCutout && (typeof record.assetId !== "string" || !record.assetId)) return null;
176
395
  // Defensive import fallback, not a migration: hand-authored or external
177
396
  // payloads may carry one `scale` (the pre-split record shape). It fans
178
397
  // out to all three axes only when no axis is present — an explicit
@@ -198,8 +417,27 @@ export function normalizeSceneObject(record) {
198
417
  scaleY: TRANSFORM_LIMITS.scaleY(pick(record.scaleY, scaleFallback)),
199
418
  scaleZ: TRANSFORM_LIMITS.scaleZ(pick(record.scaleZ, scaleFallback)),
200
419
  color: typeof record.color === "string" && record.color ? record.color : entry.color,
201
- footprint: { ...entry.footprint },
202
- height: entry.height,
420
+ // Group membership. null is a top-level object; the id of another object
421
+ // makes this one ride along when that object moves.
422
+ parent: typeof record.parent === "string" && record.parent ? record.parent : null,
423
+ // Library kinds take their size from the library — a stored footprint is
424
+ // stale data, not a fact. A cutout is the exception: its size IS
425
+ // per-instance, so height and aspect are repaired from the record and
426
+ // the footprint is rebuilt from the pair.
427
+ ...(isCutout
428
+ ? {
429
+ assetId: record.assetId,
430
+ // An older record (or a hand-authored one) has no source: the
431
+ // picture it points at IS the original, because nothing has
432
+ // been cut from it yet.
433
+ sourceAssetId: typeof record.sourceAssetId === "string" && record.sourceAssetId ? record.sourceAssetId : record.assetId,
434
+ matteAssetId: typeof record.matteAssetId === "string" ? record.matteAssetId : "",
435
+ matteScale: clamp(pick(record.matteScale, 1), 0.01, 1),
436
+ aspect: cutoutAspect(pick(record.aspect, 1)),
437
+ footprint: cutoutFootprint(pick(record.height, CUTOUT_DEFAULT_HEIGHT), pick(record.aspect, 1)),
438
+ height: cutoutHeight(pick(record.height, CUTOUT_DEFAULT_HEIGHT)),
439
+ }
440
+ : { footprint: { ...entry.footprint }, height: entry.height }),
203
441
  };
204
442
  }
205
443