cozyclay 1.2.0 → 1.5.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 +193 -0
- package/LICENSE +67 -80
- package/LICENSES/GPL-3.0-or-later.txt +674 -0
- package/LICENSING.md +32 -0
- package/README.md +48 -6
- package/THIRD_PARTY_NOTICES.md +47 -1
- package/bin/cozyclay.mjs +120 -50
- package/bin/mcp-runtime.mjs +115 -0
- package/bin/open-browser.mjs +10 -0
- package/bin/update-check.mjs +158 -0
- package/dist/ai-camera-control/index.html +406 -0
- package/dist/app/index.html +5 -5
- package/dist/ardy/assembled/run-jump-15s.npz +0 -0
- package/dist/assets/app-CqOk49Bk.css +1 -0
- package/dist/assets/app-zu633VCI.js +4811 -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 +62 -17
- package/dist/sitemap.xml +7 -1
- package/mcp/LIVE-PROTOCOL.md +144 -0
- package/mcp/README.md +160 -0
- package/mcp/ardy-prompts.mjs +188 -0
- package/mcp/live-hub.mjs +319 -0
- package/mcp/package.json +27 -0
- package/mcp/runtime/package-lock.json +1211 -0
- package/mcp/runtime/package.json +16 -0
- package/mcp/server.mjs +2008 -0
- package/package.json +142 -90
- package/src/App.jsx +5475 -1028
- package/src/ardy/cskel27.js +7 -2
- package/src/ardy/ik.js +25 -15
- package/src/ardy/motion-edit.js +172 -0
- package/src/ardy/npz.js +64 -3
- package/src/ardy/playback.js +31 -1
- package/src/ardy/pose-pin.js +97 -0
- package/src/ardy/prompt-clips.js +7 -2
- package/src/ardy/retime.js +211 -0
- package/src/ardy/root-drop.js +180 -0
- package/src/ardy/timeline-coordinates.js +13 -0
- package/src/ardy/timeline-resize.js +3 -1
- package/src/ardy/timeline.jsx +471 -63
- package/src/ardy/to-cskel27.js +34 -12
- package/src/ardy/trim.js +33 -0
- package/src/ardy/waypoints.js +4 -1
- package/src/asset-pane.jsx +354 -0
- package/src/asset-shelf.js +82 -0
- package/src/camera-block.js +42 -0
- package/src/camera-follow.js +149 -27
- package/src/camera-move.js +45 -19
- package/src/camera-rail-schedule.js +6 -0
- package/src/cuts.js +67 -54
- package/src/dualview.jsx +58 -12
- package/src/generation/generation-request.js +17 -0
- package/src/generation/use-generation.js +3 -14
- package/src/hierarchy-model.js +100 -16
- package/src/hierarchy-panel.jsx +139 -6
- package/src/live-control.js +142 -0
- package/src/matte-editor.js +543 -0
- package/src/matte.js +503 -0
- package/src/mp4-muxer.js +52 -0
- package/src/multimodel-ingest.js +344 -0
- package/src/object-gizmo.jsx +248 -25
- package/src/offscreen-export.js +166 -0
- package/src/otio.js +217 -0
- package/src/planview.jsx +57 -38
- package/src/pose-extract/detector.js +80 -0
- package/src/pose-extract/image-frame.js +40 -0
- package/src/pose-extract/index.js +4 -0
- package/src/pose-extract/take.js +114 -0
- package/src/pose-extract/video-frames.js +91 -0
- package/src/pose-thumbs.js +152 -0
- package/src/poses.js +19 -265
- package/src/posestudio.jsx +441 -17
- package/src/project-browser.jsx +135 -0
- package/src/project-poses.js +9 -0
- package/src/project.js +373 -0
- package/src/props.jsx +69 -3
- package/src/room.jsx +89 -37
- package/src/sample-at.js +136 -0
- package/src/scene-asset-cache.js +166 -0
- package/src/scene-assets.js +378 -0
- package/src/scene-objects.js +308 -7
- package/src/scenes.js +235 -26
- package/src/shot-authoring.js +120 -41
- package/src/shot.js +53 -13
- package/src/source-offer.jsx +12 -0
- package/src/stable-items.js +83 -0
- package/src/styles.css +2283 -349
- package/src/ui.jsx +32 -4
- package/src/usd-camera.js +145 -0
- package/tools/ardy/BRIDGE.md +11 -7
- package/tools/ardy/README.md +24 -5
- package/tools/ardy/artifacts.mjs +34 -0
- package/tools/ardy/bridge.mjs +89 -12
- 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 +372 -0
- package/tools/ardy/footage.mjs +467 -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/local.mjs +4 -1
- package/tools/ardy/runners/remote.mjs +8 -2
- package/tools/ardy/setup-text-encoder.py +14 -4
- package/tools/dev-full.mjs +63 -5
- package/tools/generation/bridge.mjs +14 -1
- package/tools/process-supervisor.mjs +97 -1
- package/tools/run-tests.mjs +169 -0
- package/dist/assets/app-Cgpk2hwX.js +0 -4803
- package/dist/assets/app-DgZvaAE1.css +0 -1
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,71 @@ 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
|
+
const CUTOUT_HEIGHT_MIN = 0.05;
|
|
95
|
+
const CUTOUT_ASPECT_MIN = 0.02;
|
|
96
|
+
const CUTOUT_ASPECT_MAX = 50;
|
|
97
|
+
/** Cutouts carry their own colour: unlike a primitive, the card IS the thing
|
|
98
|
+
* it depicts, so it is tinted white and multiplies the image untouched. */
|
|
99
|
+
const CUTOUT_TINT = "#ffffff";
|
|
100
|
+
const CUTOUT_ENTRY = {
|
|
101
|
+
kind: CUTOUT_KIND,
|
|
102
|
+
label: "Cutout",
|
|
103
|
+
group: "Images",
|
|
104
|
+
footprint: { width: 1, depth: CUTOUT_THICKNESS },
|
|
105
|
+
height: CUTOUT_DEFAULT_HEIGHT,
|
|
106
|
+
color: CUTOUT_TINT,
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/** Every kind that can exist in a scene: the catalogue you can create from,
|
|
110
|
+
* plus the kinds that arrive by import and so are deliberately absent from the
|
|
111
|
+
* "Add object" menu (a cutout without an image has nothing to draw). */
|
|
75
112
|
function objectLibraryEntry(kind) {
|
|
113
|
+
if (kind === CUTOUT_KIND) return CUTOUT_ENTRY;
|
|
76
114
|
return OBJECT_LIBRARY.find((entry) => entry.kind === kind) ?? null;
|
|
77
115
|
}
|
|
78
116
|
|
|
117
|
+
const cutoutHeight = (value) => Math.max(CUTOUT_HEIGHT_MIN, value);
|
|
118
|
+
const cutoutAspect = (value) => clamp(value, CUTOUT_ASPECT_MIN, CUTOUT_ASPECT_MAX);
|
|
119
|
+
|
|
120
|
+
/** How far a card may be pulled off the picture's own proportions. */
|
|
121
|
+
export const CUTOUT_STRETCH_MIN = 0.1;
|
|
122
|
+
export const CUTOUT_STRETCH_MAX = 10;
|
|
123
|
+
export const cutoutStretch = (value) => {
|
|
124
|
+
const n = Number(value);
|
|
125
|
+
return Number.isFinite(n) ? clamp(n, CUTOUT_STRETCH_MIN, CUTOUT_STRETCH_MAX) : 1;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The plan-board rectangle a card of this height and picture aspect occupies.
|
|
130
|
+
* `aspect` is the image's width / height.
|
|
131
|
+
*
|
|
132
|
+
* `stretch` is how far the card has been pulled off those proportions: 1 is the
|
|
133
|
+
* picture undistorted. It is kept as its own factor rather than folded into
|
|
134
|
+
* `aspect` so the photograph's true proportions survive every edit — a re-cut
|
|
135
|
+
* recomputes `aspect` from the trimmed image, and a card that had been widened
|
|
136
|
+
* would otherwise silently lose or compound that widening.
|
|
137
|
+
*/
|
|
138
|
+
export function cutoutFootprint(height, aspect, stretch = 1) {
|
|
139
|
+
return {
|
|
140
|
+
width: cutoutHeight(height) * cutoutAspect(aspect) * cutoutStretch(stretch),
|
|
141
|
+
depth: CUTOUT_THICKNESS,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
79
145
|
export function sceneObjectHierarchyId(id) {
|
|
80
146
|
return `object:${id}`;
|
|
81
147
|
}
|
|
@@ -90,6 +156,9 @@ export function sceneObjectIdFromHierarchy(hierarchyId) {
|
|
|
90
156
|
* camera); everything else starts neutral so the first drag is predictable.
|
|
91
157
|
*/
|
|
92
158
|
export function createSceneObject(kind, existing = [], placement = {}) {
|
|
159
|
+
// Cutouts come from an import, never from the catalogue: without an asset
|
|
160
|
+
// id the record has nothing to draw. `createCutoutObject` is their door.
|
|
161
|
+
if (kind === CUTOUT_KIND) return null;
|
|
93
162
|
const entry = objectLibraryEntry(kind);
|
|
94
163
|
if (!entry) return null;
|
|
95
164
|
const names = new Set(existing.map((object) => object.name));
|
|
@@ -112,11 +181,93 @@ export function createSceneObject(kind, existing = [], placement = {}) {
|
|
|
112
181
|
scaleY: 1,
|
|
113
182
|
scaleZ: 1,
|
|
114
183
|
color: entry.color,
|
|
184
|
+
parent: null,
|
|
115
185
|
footprint: { ...entry.footprint },
|
|
116
186
|
height: entry.height,
|
|
117
187
|
};
|
|
118
188
|
}
|
|
119
189
|
|
|
190
|
+
/**
|
|
191
|
+
* A fresh cutout for an imported image. `assetId` addresses the picture in the
|
|
192
|
+
* asset store — the record never carries the bytes, which is what keeps a
|
|
193
|
+
* scene small enough to live in localStorage — and `aspect` is the image's
|
|
194
|
+
* width / height so the card can be sized by height alone.
|
|
195
|
+
*
|
|
196
|
+
* `name` seeds the display name (the file's own name is the obvious caller
|
|
197
|
+
* choice); everything else starts neutral, exactly like a catalogue object.
|
|
198
|
+
*/
|
|
199
|
+
export function createCutoutObject({ assetId, aspect = 1, height = CUTOUT_DEFAULT_HEIGHT, name = "", sourceAssetId, matteAssetId, matteScale, stretch } = {}, existing = [], placement = {}) {
|
|
200
|
+
if (typeof assetId !== "string" || !assetId) return null;
|
|
201
|
+
const pictureAspect = cutoutAspect(Number(aspect));
|
|
202
|
+
const cardHeight = cutoutHeight(Number(height));
|
|
203
|
+
if (!Number.isFinite(pictureAspect) || !Number.isFinite(cardHeight)) return null;
|
|
204
|
+
// A duplicate hands the lineage in so the copy stays re-editable: the
|
|
205
|
+
// picture it renders, the photograph it came from, the selection mask,
|
|
206
|
+
// the trim factor and the stretch. A fresh import passes none, so the
|
|
207
|
+
// defaults below leave it as its own unmasked original — exactly the
|
|
208
|
+
// record an untouched card has always carried.
|
|
209
|
+
const cardStretch = cutoutStretch(stretch);
|
|
210
|
+
const base = typeof name === "string" && name.trim() ? name.trim() : CUTOUT_ENTRY.label;
|
|
211
|
+
const names = new Set(existing.map((object) => object.name));
|
|
212
|
+
let displayName = base;
|
|
213
|
+
for (let n = 2; names.has(displayName); n += 1) displayName = `${base} ${n}`;
|
|
214
|
+
const ids = new Set(existing.map((object) => object.id));
|
|
215
|
+
let id = CUTOUT_KIND;
|
|
216
|
+
for (let n = 2; ids.has(id); n += 1) id = `${CUTOUT_KIND}-${n}`;
|
|
217
|
+
return {
|
|
218
|
+
id,
|
|
219
|
+
name: displayName,
|
|
220
|
+
renderer: CUTOUT_KIND,
|
|
221
|
+
x: clamp(Number(placement.x) || 0, -ROOM_LIMIT, ROOM_LIMIT),
|
|
222
|
+
y: 0,
|
|
223
|
+
z: clamp(Number(placement.z) || 0, -ROOM_LIMIT, ROOM_LIMIT),
|
|
224
|
+
rot: wrapAngle(Number(placement.rot) || 0),
|
|
225
|
+
rotX: 0,
|
|
226
|
+
rotZ: 0,
|
|
227
|
+
scaleX: 1,
|
|
228
|
+
scaleY: 1,
|
|
229
|
+
scaleZ: 1,
|
|
230
|
+
color: CUTOUT_TINT,
|
|
231
|
+
parent: null,
|
|
232
|
+
// Key order matches what `normalizeSceneObject` writes, so a record
|
|
233
|
+
// survives a storage round trip byte-for-byte.
|
|
234
|
+
assetId,
|
|
235
|
+
// A picture nobody has cut is its own original, with no purple on it;
|
|
236
|
+
// a duplicate passes the lineage through so the copy stays re-editable.
|
|
237
|
+
sourceAssetId: typeof sourceAssetId === "string" && sourceAssetId ? sourceAssetId : assetId,
|
|
238
|
+
matteAssetId: typeof matteAssetId === "string" ? matteAssetId : "",
|
|
239
|
+
matteScale: Number.isFinite(Number(matteScale)) ? Number(matteScale) : 1,
|
|
240
|
+
aspect: pictureAspect,
|
|
241
|
+
// A new card wears the picture's own proportions; a widened one carries
|
|
242
|
+
// its factor through so the copy keeps the shape it was pulled to.
|
|
243
|
+
stretch: cardStretch,
|
|
244
|
+
footprint: cutoutFootprint(cardHeight, pictureAspect, cardStretch),
|
|
245
|
+
height: cardHeight,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The option bundle a cutout duplicate hands to `createCutoutObject`. A copy
|
|
251
|
+
* is minted through the same door an import is, so it must carry the picture
|
|
252
|
+
* it renders AND the photograph it came from, the selection mask, the trim
|
|
253
|
+
* factor and the stretch — otherwise the matte is silently reset and the
|
|
254
|
+
* duplicate is no longer re-editable. Kept as its own pure helper so the
|
|
255
|
+
* duplicate path and its test speak the very same option-building, not a
|
|
256
|
+
* hand-rolled copy.
|
|
257
|
+
*/
|
|
258
|
+
export function duplicateCutoutOptions(object) {
|
|
259
|
+
return {
|
|
260
|
+
assetId: object.assetId,
|
|
261
|
+
aspect: object.aspect,
|
|
262
|
+
height: object.height,
|
|
263
|
+
name: object.name,
|
|
264
|
+
sourceAssetId: object.sourceAssetId,
|
|
265
|
+
matteAssetId: object.matteAssetId,
|
|
266
|
+
matteScale: object.matteScale,
|
|
267
|
+
stretch: object.stretch,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
120
271
|
/** Every writable transform channel and the rule that keeps it in the room. */
|
|
121
272
|
const TRANSFORM_LIMITS = {
|
|
122
273
|
x: (value) => clamp(value, -ROOM_LIMIT, ROOM_LIMIT),
|
|
@@ -130,10 +281,60 @@ const TRANSFORM_LIMITS = {
|
|
|
130
281
|
scaleZ: (value) => clamp(value, SCALE_MIN, SCALE_MAX),
|
|
131
282
|
};
|
|
132
283
|
|
|
284
|
+
/** Every object that hangs off `id`, at any depth. A cycle cannot form because
|
|
285
|
+
* setParent refuses one, but the seen-set keeps this total even if data is
|
|
286
|
+
* hand-edited into a loop. */
|
|
287
|
+
export function descendantsOf(objects, id) {
|
|
288
|
+
const out = [];
|
|
289
|
+
const seen = new Set([id]);
|
|
290
|
+
let frontier = [id];
|
|
291
|
+
while (frontier.length) {
|
|
292
|
+
const next = [];
|
|
293
|
+
for (const object of objects) {
|
|
294
|
+
if (object.parent && frontier.includes(object.parent) && !seen.has(object.id)) {
|
|
295
|
+
seen.add(object.id);
|
|
296
|
+
out.push(object);
|
|
297
|
+
next.push(object.id);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
frontier = next;
|
|
301
|
+
}
|
|
302
|
+
return out;
|
|
303
|
+
}
|
|
304
|
+
|
|
133
305
|
export function updateSceneObject(objects, id, patch) {
|
|
134
306
|
let changed = false;
|
|
307
|
+
const target = objects.find((object) => object.id === id);
|
|
308
|
+
// A parent carries its children: the group is dragged, nudged and dropped as
|
|
309
|
+
// one body. Only translation rides along — rotating or scaling a group would
|
|
310
|
+
// have to orbit and rescale every child about the parent's origin, which is a
|
|
311
|
+
// different feature and is deliberately not pretended at here.
|
|
312
|
+
const carried = target ? descendantsOf(objects, id) : [];
|
|
313
|
+
const delta = { x: 0, y: 0, z: 0 };
|
|
314
|
+
if (target) {
|
|
315
|
+
for (const axis of ["x", "y", "z"]) {
|
|
316
|
+
if (patch[axis] === undefined) continue;
|
|
317
|
+
const value = Number(patch[axis]);
|
|
318
|
+
if (!Number.isFinite(value)) continue;
|
|
319
|
+
delta[axis] = TRANSFORM_LIMITS[axis](value) - target[axis];
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
const moving = new Set(carried.map((object) => object.id));
|
|
323
|
+
const shifts = delta.x || delta.y || delta.z;
|
|
324
|
+
|
|
135
325
|
const next = objects.map((object) => {
|
|
136
|
-
if (object.id !== id)
|
|
326
|
+
if (object.id !== id) {
|
|
327
|
+
if (!shifts || !moving.has(object.id)) return object;
|
|
328
|
+
const update = {};
|
|
329
|
+
for (const axis of ["x", "y", "z"]) {
|
|
330
|
+
if (!delta[axis]) continue;
|
|
331
|
+
const bounded = TRANSFORM_LIMITS[axis](object[axis] + delta[axis]);
|
|
332
|
+
if (bounded !== object[axis]) update[axis] = bounded;
|
|
333
|
+
}
|
|
334
|
+
if (!Object.keys(update).length) return object;
|
|
335
|
+
changed = true;
|
|
336
|
+
return { ...object, ...update };
|
|
337
|
+
}
|
|
137
338
|
const update = {};
|
|
138
339
|
for (const [key, limit] of Object.entries(TRANSFORM_LIMITS)) {
|
|
139
340
|
if (patch[key] === undefined) continue;
|
|
@@ -147,6 +348,51 @@ export function updateSceneObject(objects, id, patch) {
|
|
|
147
348
|
if (typeof patch[key] !== "string" || !patch[key] || patch[key] === object[key]) continue;
|
|
148
349
|
update[key] = patch[key];
|
|
149
350
|
}
|
|
351
|
+
// A cutout is sized in metres — you measure something in the picture and
|
|
352
|
+
// type its height — so `height` and `aspect` are writable where every
|
|
353
|
+
// other kind takes them from the library. The footprint is DERIVED here
|
|
354
|
+
// and never patched: one owner for the card's width is what keeps it
|
|
355
|
+
// from disagreeing with its own image.
|
|
356
|
+
if (object.renderer === CUTOUT_KIND) {
|
|
357
|
+
// The picture itself is writable: cutting the background out stores a
|
|
358
|
+
// NEW asset (different bytes, different id) and points the card at it,
|
|
359
|
+
// which is what makes the cut undoable — the original stays addressed
|
|
360
|
+
// by the history entry before it.
|
|
361
|
+
// Three ids, because a cut card is not one picture: `assetId` is what
|
|
362
|
+
// the set renders, `sourceAssetId` is the photograph it came from and
|
|
363
|
+
// keeps being edited from, and `matteAssetId` is the purple itself.
|
|
364
|
+
// Keeping all three is what makes the cut re-editable instead of
|
|
365
|
+
// destructive — the original is never replaced, only masked.
|
|
366
|
+
for (const key of ["assetId", "sourceAssetId", "matteAssetId"]) {
|
|
367
|
+
if (typeof patch[key] === "string" && patch[key] && patch[key] !== object[key]) update[key] = patch[key];
|
|
368
|
+
}
|
|
369
|
+
// How much of the original frame the trimmed card is. Stored so a
|
|
370
|
+
// second edit can work out the height the card would have at full
|
|
371
|
+
// frame instead of compounding one trim onto the last.
|
|
372
|
+
if (Number.isFinite(Number(patch.matteScale))) {
|
|
373
|
+
const scale = clamp(Number(patch.matteScale), 0.01, 1);
|
|
374
|
+
if (scale !== object.matteScale) update.matteScale = scale;
|
|
375
|
+
}
|
|
376
|
+
const patchedHeight = patch.height === undefined ? NaN : cutoutHeight(Number(patch.height));
|
|
377
|
+
const patchedAspect = patch.aspect === undefined ? NaN : cutoutAspect(Number(patch.aspect));
|
|
378
|
+
const height = Number.isFinite(patchedHeight) ? patchedHeight : object.height;
|
|
379
|
+
const aspect = Number.isFinite(patchedAspect) ? patchedAspect : object.aspect;
|
|
380
|
+
const previousStretch = cutoutStretch(object.stretch);
|
|
381
|
+
// A width in metres is what the inspector and the drag both speak, so it
|
|
382
|
+
// is accepted directly and kept as the factor the record stores.
|
|
383
|
+
const patchedStretch =
|
|
384
|
+
patch.width !== undefined && Number.isFinite(Number(patch.width))
|
|
385
|
+
? cutoutStretch(Number(patch.width) / (height * aspect))
|
|
386
|
+
: patch.stretch === undefined
|
|
387
|
+
? previousStretch
|
|
388
|
+
: cutoutStretch(patch.stretch);
|
|
389
|
+
if (height !== object.height || aspect !== object.aspect || patchedStretch !== previousStretch) {
|
|
390
|
+
update.height = height;
|
|
391
|
+
update.aspect = aspect;
|
|
392
|
+
update.stretch = patchedStretch;
|
|
393
|
+
update.footprint = cutoutFootprint(height, aspect, patchedStretch);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
150
396
|
if (!Object.keys(update).length) return object;
|
|
151
397
|
changed = true;
|
|
152
398
|
return { ...object, ...update };
|
|
@@ -154,9 +400,33 @@ export function updateSceneObject(objects, id, patch) {
|
|
|
154
400
|
return changed ? next : objects;
|
|
155
401
|
}
|
|
156
402
|
|
|
403
|
+
/**
|
|
404
|
+
* Attach `id` to `parentId` (or detach with null). Refuses the two shapes that
|
|
405
|
+
* would corrupt the tree: an object parented to itself, and a cycle formed by
|
|
406
|
+
* parenting an object to one of its own descendants.
|
|
407
|
+
*/
|
|
408
|
+
export function setSceneObjectParent(objects, id, parentId) {
|
|
409
|
+
if (id === parentId) return objects;
|
|
410
|
+
if (parentId !== null && !objects.some((object) => object.id === parentId)) return objects;
|
|
411
|
+
if (parentId !== null && descendantsOf(objects, id).some((object) => object.id === parentId)) return objects;
|
|
412
|
+
let changed = false;
|
|
413
|
+
const next = objects.map((object) => {
|
|
414
|
+
if (object.id !== id) return object;
|
|
415
|
+
const parent = parentId ?? null;
|
|
416
|
+
if ((object.parent ?? null) === parent) return object;
|
|
417
|
+
changed = true;
|
|
418
|
+
return { ...object, parent };
|
|
419
|
+
});
|
|
420
|
+
return changed ? next : objects;
|
|
421
|
+
}
|
|
422
|
+
|
|
157
423
|
export function removeSceneObject(objects, id) {
|
|
158
424
|
const next = objects.filter((object) => object.id !== id);
|
|
159
|
-
|
|
425
|
+
if (next.length === objects.length) return objects;
|
|
426
|
+
// Deleting a parent must not leave its children pointing at a ghost: they
|
|
427
|
+
// are promoted to top level rather than vanishing with it, because a group
|
|
428
|
+
// is an editing convenience and never an owner of the parts.
|
|
429
|
+
return next.map((object) => (object.parent === id ? { ...object, parent: null } : object));
|
|
160
430
|
}
|
|
161
431
|
/* -------------------------------------------------- persistence ---- */
|
|
162
432
|
|
|
@@ -173,6 +443,11 @@ export function normalizeSceneObject(record) {
|
|
|
173
443
|
const entry = objectLibraryEntry(record.renderer);
|
|
174
444
|
if (!entry) return null;
|
|
175
445
|
if (typeof record.id !== "string" || !record.id) return null;
|
|
446
|
+
// A cutout addresses its picture by id. A record without one has nothing to
|
|
447
|
+
// draw, so it is dropped rather than restored as a blank card — the same
|
|
448
|
+
// rule an unknown renderer already gets.
|
|
449
|
+
const isCutout = entry.kind === CUTOUT_KIND;
|
|
450
|
+
if (isCutout && (typeof record.assetId !== "string" || !record.assetId)) return null;
|
|
176
451
|
// Defensive import fallback, not a migration: hand-authored or external
|
|
177
452
|
// payloads may carry one `scale` (the pre-split record shape). It fans
|
|
178
453
|
// out to all three axes only when no axis is present — an explicit
|
|
@@ -198,8 +473,34 @@ export function normalizeSceneObject(record) {
|
|
|
198
473
|
scaleY: TRANSFORM_LIMITS.scaleY(pick(record.scaleY, scaleFallback)),
|
|
199
474
|
scaleZ: TRANSFORM_LIMITS.scaleZ(pick(record.scaleZ, scaleFallback)),
|
|
200
475
|
color: typeof record.color === "string" && record.color ? record.color : entry.color,
|
|
201
|
-
|
|
202
|
-
|
|
476
|
+
// Group membership. null is a top-level object; the id of another object
|
|
477
|
+
// makes this one ride along when that object moves.
|
|
478
|
+
parent: typeof record.parent === "string" && record.parent ? record.parent : null,
|
|
479
|
+
// Library kinds take their size from the library — a stored footprint is
|
|
480
|
+
// stale data, not a fact. A cutout is the exception: its size IS
|
|
481
|
+
// per-instance, so height and aspect are repaired from the record and
|
|
482
|
+
// the footprint is rebuilt from the pair.
|
|
483
|
+
...(isCutout
|
|
484
|
+
? {
|
|
485
|
+
assetId: record.assetId,
|
|
486
|
+
// An older record (or a hand-authored one) has no source: the
|
|
487
|
+
// picture it points at IS the original, because nothing has
|
|
488
|
+
// been cut from it yet.
|
|
489
|
+
sourceAssetId: typeof record.sourceAssetId === "string" && record.sourceAssetId ? record.sourceAssetId : record.assetId,
|
|
490
|
+
matteAssetId: typeof record.matteAssetId === "string" ? record.matteAssetId : "",
|
|
491
|
+
matteScale: clamp(pick(record.matteScale, 1), 0.01, 1),
|
|
492
|
+
aspect: cutoutAspect(pick(record.aspect, 1)),
|
|
493
|
+
// A record written before cards could be stretched has none, and
|
|
494
|
+
// 1 is exactly what it meant: the picture's own proportions.
|
|
495
|
+
stretch: cutoutStretch(pick(record.stretch, 1)),
|
|
496
|
+
footprint: cutoutFootprint(
|
|
497
|
+
pick(record.height, CUTOUT_DEFAULT_HEIGHT),
|
|
498
|
+
pick(record.aspect, 1),
|
|
499
|
+
pick(record.stretch, 1),
|
|
500
|
+
),
|
|
501
|
+
height: cutoutHeight(pick(record.height, CUTOUT_DEFAULT_HEIGHT)),
|
|
502
|
+
}
|
|
503
|
+
: { footprint: { ...entry.footprint }, height: entry.height }),
|
|
203
504
|
};
|
|
204
505
|
}
|
|
205
506
|
|