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.
- package/CHANGELOG.md +85 -0
- package/README.md +31 -0
- package/THIRD_PARTY_NOTICES.md +37 -1
- package/bin/cozyclay.mjs +51 -2
- package/dist/ai-camera-control/index.html +406 -0
- package/dist/app/index.html +5 -5
- package/dist/assets/app-B3U5aut1.js +4811 -0
- package/dist/assets/app-BrRF0wso.css +1 -0
- package/dist/assets/vision_bundle-jFkh-fIS.js +41 -0
- package/dist/fonts/InstrumentSerif-OFL.txt +93 -0
- package/dist/fonts/Inter-OFL.txt +92 -0
- package/dist/fonts/README.md +15 -0
- package/dist/index.html +60 -16
- package/dist/sitemap.xml +7 -1
- package/mcp/LIVE-PROTOCOL.md +63 -0
- package/mcp/README.md +142 -0
- package/mcp/ardy-prompts.mjs +170 -0
- package/mcp/live-hub.mjs +105 -0
- package/mcp/package.json +24 -0
- package/mcp/server.mjs +1394 -0
- package/package.json +122 -90
- package/src/App.jsx +2957 -514
- package/src/ardy/cskel27.js +7 -2
- package/src/ardy/ik.js +25 -15
- package/src/ardy/npz.js +64 -3
- package/src/ardy/playback.js +31 -1
- package/src/ardy/prompt-clips.js +7 -2
- package/src/ardy/retime.js +211 -0
- package/src/ardy/timeline-coordinates.js +13 -0
- package/src/ardy/timeline.jsx +124 -5
- package/src/ardy/to-cskel27.js +34 -12
- package/src/ardy/trim.js +33 -0
- package/src/asset-pane.jsx +36 -0
- package/src/dualview.jsx +14 -8
- package/src/hierarchy-model.js +95 -13
- package/src/hierarchy-panel.jsx +139 -6
- package/src/live-control.js +122 -0
- package/src/matte-editor.js +543 -0
- package/src/matte.js +503 -0
- package/src/multimodel-ingest.js +344 -0
- package/src/object-gizmo.jsx +43 -15
- package/src/planview.jsx +43 -32
- package/src/pose-extract/detector.js +75 -0
- package/src/pose-extract/index.js +3 -0
- package/src/pose-extract/take.js +87 -0
- package/src/pose-extract/video-frames.js +91 -0
- package/src/pose-thumbs.js +152 -0
- package/src/posestudio.jsx +361 -11
- package/src/project-browser.jsx +135 -0
- package/src/project.js +289 -0
- package/src/props.jsx +69 -3
- package/src/room.jsx +14 -35
- package/src/scene-asset-cache.js +125 -0
- package/src/scene-assets.js +288 -0
- package/src/scene-objects.js +245 -7
- package/src/scenes.js +207 -26
- package/src/shot-authoring.js +55 -13
- package/src/styles.css +1296 -129
- package/tools/ardy/BRIDGE.md +3 -2
- package/tools/ardy/README.md +9 -5
- package/tools/ardy/bridge.mjs +57 -1
- package/tools/ardy/bvh-cskel27.mjs +1209 -0
- package/tools/ardy/cclay_constrained_generate.py +123 -11
- package/tools/ardy/cclay_sequence_generate.py +49 -0
- package/tools/ardy/extract.mjs +367 -0
- package/tools/ardy/footage.mjs +462 -0
- package/tools/ardy/npz.mjs +74 -9
- package/tools/ardy/run-on-box.sh +25 -0
- package/tools/ardy/run-sequence-on-box.sh +15 -0
- package/tools/ardy/runners/remote.mjs +8 -2
- package/dist/assets/app-Cgpk2hwX.js +0 -4803
- 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
|
+
}
|
package/src/scene-objects.js
CHANGED
|
@@ -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
|
-
/**
|
|
35
|
-
|
|
36
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
202
|
-
|
|
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
|
|