cozyclay 2.0.2 → 2.1.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 +56 -0
- package/README.md +17 -2
- package/THIRD_PARTY_NOTICES.md +8 -0
- package/bin/agent/agent-routes.mjs +211 -36
- package/bin/agent/agent-runner.mjs +98 -4
- package/bin/agent/codex-client.mjs +2 -1
- package/bin/agent/fal-contract.mjs +0 -17
- package/bin/agent/h3-preservation.mjs +6 -8
- package/bin/agent/provider-keys.mjs +1 -1
- package/bin/agent/providers.mjs +124 -3
- package/bin/agent/script-tool.mjs +74 -0
- package/bin/agent/session-store.mjs +5 -5
- package/bin/agent/studio-prompt.mjs +2 -1
- package/bin/agent/studio-rebase.mjs +314 -0
- package/bin/agent/studio-tools.mjs +21 -14
- package/bin/agent/supervisor.mjs +311 -0
- package/bin/agent/video-adapters.mjs +0 -7
- package/bin/codex-auth.mjs +4 -4
- package/bin/live/cli.mjs +2 -3
- package/bin/live/client.mjs +1 -1
- package/bin/live/studio.mjs +5 -2
- package/bin/mcp-runtime.mjs +2 -2
- package/bin/telemetry-state.mjs +3 -3
- package/dist/app/index.html +6 -6
- package/dist/assets/analytics-B41FmPYI.js +1 -0
- package/dist/assets/app-Do-Og5Pu.css +1 -0
- package/dist/assets/app-DwP-tl2x.js +4920 -0
- package/dist/assets/{demo-ByeYp-dY.js → demo-Dt0qZfS7.js} +1 -1
- package/dist/assets/{first-shot-handoff-DgT28ZiV.js → first-shot-handoff-BlT33CGf.js} +1 -1
- package/dist/assets/{landing-BaXkpFip.js → landing-BSesrCpL.js} +1 -1
- package/dist/assets/scene-asset-sync-BCWDJB1d.js +19 -0
- package/dist/assets/{shot-prompt-CbqWcWYi.css → scene-asset-sync-DjJ_cJ2R.css} +1 -1
- package/dist/assets/{ticket-BcmsnHn5.js → ticket-jqMhqL88.js} +1 -1
- package/dist/assets/workflow-Br-K7Srs.js +179 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/index.html +328 -222
- package/dist/sitemap.xml +1 -1
- package/dist/workflow/index.html +4 -4
- package/mcp/LIVE-PROTOCOL.md +23 -2
- package/mcp/README.md +37 -2
- package/mcp/ardy-prompts.mjs +1 -1
- package/mcp/image-file.mjs +132 -0
- package/mcp/live-hub.mjs +2 -2
- package/mcp/package.json +1 -0
- package/mcp/runtime/package-lock.json +7 -0
- package/mcp/runtime/package.json +1 -0
- package/mcp/server.mjs +0 -6
- package/mcp/tool-handlers.mjs +289 -87
- package/package.json +11 -2
- package/src/App.jsx +789 -263
- package/src/agent-image-client.js +94 -0
- package/src/analytics.js +47 -3
- package/src/app-stage.jsx +129 -2745
- package/src/ardy/auto-physics.js +6 -16
- package/src/ardy/collision-blockers.js +3 -7
- package/src/ardy/fix-collisions.js +5 -35
- package/src/ardy/ik-drag.js +86 -5
- package/src/ardy/ik-key-json.js +3 -1
- package/src/ardy/ik.js +15 -13
- package/src/ardy/motion-calibration.js +1 -1
- package/src/ardy/npz.js +0 -8
- package/src/ardy/physics-panel.jsx +3 -2
- package/src/ardy/physics-review.js +3 -2
- package/src/ardy/physics-support.js +1 -1
- package/src/ardy/physics-surface.js +1 -1
- package/src/ardy/platform-fit-panel.jsx +2 -1
- package/src/ardy/playback.js +1 -5
- package/src/ardy/range-pin.js +2 -2
- package/src/ardy/root-drop.js +0 -1
- package/src/ardy/timeline.css +184 -6
- package/src/ardy/timeline.jsx +113 -28
- package/src/ardy/to-cskel27.js +0 -2
- package/src/ardy/waypoints.js +0 -3
- package/src/ardy-bridge-contract.js +281 -0
- package/src/asset-pane.jsx +19 -9
- package/src/auto-color.js +1 -1
- package/src/burn-in.js +1 -1
- package/src/camera-block.js +1 -1
- package/src/camera-follow.js +75 -32
- package/src/camera-move.js +0 -6
- package/src/camera-tutorial.jsx +6 -7
- package/src/character-kind.js +79 -0
- package/src/command-bus.js +76 -17
- package/src/commands/cast.js +31 -7
- package/src/commands/elements/object.js +1 -1
- package/src/commands/elements/shot.js +1 -1
- package/src/commands/elements.js +1 -1
- package/src/commands/export.js +3 -3
- package/src/commands/motion.js +2 -2
- package/src/commands/objects.js +105 -10
- package/src/commands/project.js +15 -2
- package/src/commands/sculpt-images.js +53 -0
- package/src/commands/shot.js +88 -10
- package/src/commands/view.js +10 -4
- package/src/coplanar-depth.js +216 -0
- package/src/cuts.js +7 -6
- package/src/domains/cast.js +60 -43
- package/src/domains/motion/motion-intent-codec.js +50 -0
- package/src/domains/motion/motion-range-pin-rules.js +23 -0
- package/src/domains/motion/motion-video-capture.js +664 -0
- package/src/domains/motion.js +253 -742
- package/src/domains/objects.js +106 -5
- package/src/domains/scenes.js +79 -28
- package/src/domains/shots.js +33 -7
- package/src/error-boundary.jsx +1 -1
- package/src/execution-telemetry.js +5 -5
- package/src/facing-marks.js +14 -1
- package/src/first-success-guide.jsx +35 -4
- package/src/gizmo-claim.js +3 -1
- package/src/grid-floor.jsx +1 -4
- package/src/hierarchy-model.js +48 -35
- package/src/hierarchy-panel.css +1 -0
- package/src/hierarchy-panel.jsx +54 -102
- package/src/history.js +0 -2
- package/src/i2v-motion-client.js +1 -4
- package/src/ime.js +6 -0
- package/src/keyframe-pack.js +15 -6
- package/src/line-edit.js +4 -4
- package/src/live-control.js +6 -6
- package/src/matte-editor.js +2 -8
- package/src/matte.js +0 -19
- package/src/mesh-graph-clone.js +16 -2
- package/src/mesh-sniff.js +0 -1
- package/src/monotone-cubic.js +40 -0
- package/src/motion-store.js +4 -4
- package/src/motion-trail.js +5 -11
- package/src/multimodel-ingest.js +1 -3
- package/src/object-catalog.jsx +7 -5
- package/src/object-gizmo.jsx +45 -31
- package/src/object-path.js +310 -67
- package/src/object-travel.js +224 -0
- package/src/otio.js +8 -3
- package/src/panels/CharacterTransformPanel.jsx +24 -6
- package/src/panels/ObjectTransformPanel.jsx +4 -3
- package/src/panels/PoseImagePanel.jsx +205 -0
- package/src/panels/PromptBlocksPanel.jsx +3 -1
- package/src/panels/ProxyFigurePanel.jsx +56 -0
- package/src/panels/ReferenceImageField.jsx +1 -1
- package/src/panels/RigControlPanel.jsx +24 -3
- package/src/panels/RigPanel.jsx +2 -1
- package/src/panels/SculptPanel.jsx +405 -0
- package/src/panels/SubjectBox.jsx +1 -1
- package/src/panels/SubjectsPanel.jsx +0 -2
- package/src/panels/VideoCapturePanel.jsx +2 -1
- package/src/panels/pose-image.css +42 -0
- package/src/panels/pose.css +3 -0
- package/src/panels/sculpt.css +162 -0
- package/src/plan-diagram.js +184 -0
- package/src/planview.jsx +127 -80
- package/src/playground.js +3 -5
- package/src/pose-fade.js +77 -0
- package/src/pose-gizmo.css +23 -0
- package/src/pose-gizmo.js +96 -0
- package/src/posestudio.jsx +134 -83
- package/src/project-browser.css +53 -0
- package/src/project-browser.jsx +49 -4
- package/src/project.js +55 -24
- package/src/props.jsx +177 -36
- package/src/proxy-figure.jsx +69 -0
- package/src/range-pin-panel.jsx +0 -1
- package/src/receipt-findings.js +20 -0
- package/src/render-passes.js +0 -1
- package/src/resource-status.jsx +1 -1
- package/src/root-path.js +48 -0
- package/src/route-owner.js +52 -0
- package/src/scene-asset-cache.js +1 -2
- package/src/scene-assets.js +12 -4
- package/src/scene-fbx.js +1 -1
- package/src/scene-mesh-cache.js +0 -1
- package/src/scene-objects.js +319 -19
- package/src/scenes.js +58 -9
- package/src/sculpt-blueprint.js +65 -0
- package/src/sculpt-compare.js +384 -0
- package/src/sculpt-geometry.js +141 -0
- package/src/sculpt-mode.js +168 -0
- package/src/sculpt-recipe.js +385 -0
- package/src/sculpt-run.js +351 -0
- package/src/sculpt-stage.jsx +221 -0
- package/src/settings-menu.jsx +1 -1
- package/src/shell/BoardSlot.jsx +341 -0
- package/src/shell/BottomDock.jsx +58 -14
- package/src/shell/DetailsSlot.jsx +26 -6
- package/src/shell/MenuBar.jsx +78 -6
- package/src/shell/OutlinerSlot.jsx +3 -1
- package/src/shell/TopBar.jsx +17 -3
- package/src/shell/ViewportToolbar.jsx +18 -3
- package/src/shell/dock.css +239 -0
- package/src/shell/glass-regions.css +3 -2
- package/src/shell/log-store.js +4 -4
- package/src/shell/mode.css +20 -0
- package/src/shell/studio-shell-context.js +4 -1
- package/src/shot-authoring.js +69 -6
- package/src/stable-items.js +1 -1
- package/src/stage-attach-frames.js +169 -0
- package/src/stage-canvas-rigs.jsx +402 -0
- package/src/stage-capture.jsx +216 -0
- package/src/stage-characters.jsx +302 -0
- package/src/stage-editor-gizmos.jsx +1229 -0
- package/src/stage-labels-ko.js +204 -0
- package/src/stage-startup-defaults.js +182 -0
- package/src/starter-scenes.js +1 -1
- package/src/storyboard.js +24 -5
- package/src/studio-actions.js +42 -9
- package/src/studio-agent-commands.js +186 -23
- package/src/studio-agent-context.js +11 -9
- package/src/studio-agent-motion.js +2 -1
- package/src/studio-agent-protocol.js +56 -23
- package/src/studio-app-binding.js +52 -23
- package/src/studio-contact-sheet.js +3 -3
- package/src/studio-elements.js +11 -2
- package/src/studio-geometry-facts.js +114 -0
- package/src/styles.css +92 -9
- package/src/ui-scrub.js +4 -4
- package/src/ui.jsx +6 -4
- package/src/use-asset-shelf.js +60 -0
- package/src/use-camera-tutorial.js +50 -0
- package/src/use-embed-bridge.js +12 -0
- package/src/use-first-success-prompt.js +7 -0
- package/src/use-i2v-capture-modal.js +15 -0
- package/src/use-sculpt-mode.js +95 -0
- package/src/workflow/AgentPanel.jsx +106 -18
- package/src/workflow/WorkflowBuilder.jsx +1 -1
- package/src/workflow/agent-client.js +113 -45
- package/src/workflow/agent-panel.css +35 -0
- package/src/workflow/attachment-image.js +1 -1
- package/src/workflow/canvas-commands.js +3 -3
- package/src/workflow/cozy-scene-node.js +3 -3
- package/src/workflow/local-workflow.js +2 -2
- package/src/workflow/motion-input.js +3 -3
- package/src/workflow/node-schema.js +1 -1
- package/src/workflow/scene-asset-sync.js +2 -9
- package/src/workflow/shot-prompt-node.js +1 -1
- package/src/workflow/video-contract.js +1 -1
- package/src/workflow/workflow-resources.js +0 -8
- package/src/workflow-send.js +87 -0
- package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
- package/tools/check-eslint.mjs +57 -0
- package/tools/check-knip.mjs +60 -0
- package/tools/check-loc.mjs +54 -0
- package/tools/demo-worker/api-client.mjs +7 -13
- package/tools/demo-worker/generate.mjs +2 -4
- package/tools/eslint-baseline.json +23 -0
- package/tools/kimodo/effector-constraints.mjs +4 -4
- package/tools/kimodo/generate.mjs +1 -1
- package/tools/knip-baseline.json +331 -0
- package/tools/loc-baseline.json +126 -0
- package/tools/process-supervisor.mjs +1 -1
- package/tools/projflow/replay.mjs +4 -4
- package/tools/projflow/runner.mjs +3 -3
- package/tools/qa/studio-control-count.mjs +2 -0
- package/tools/run-tests.mjs +94 -7
- package/dist/assets/analytics-MVZOEW8O.js +0 -1
- package/dist/assets/app-BxBLC6GT.js +0 -4876
- package/dist/assets/app-CJdxvnrl.css +0 -1
- package/dist/assets/shot-prompt-hN0j_O-p.js +0 -17
- package/dist/assets/workflow-B5XZwVfe.js +0 -179
package/src/scene-objects.js
CHANGED
|
@@ -21,6 +21,7 @@ import { Euler, Quaternion } from "three";
|
|
|
21
21
|
import { createObjectPath, translateObjectPath } from "./object-path.js";
|
|
22
22
|
import { elementByPath } from "./studio-elements.js";
|
|
23
23
|
import { MESH_DEFAULT_HEIGHT, MESH_HEIGHT_MIN } from "./scene-mesh.js";
|
|
24
|
+
import { normalizeSculptRecipe, sculptStandingBox } from "./sculpt-recipe.js";
|
|
24
25
|
|
|
25
26
|
export const DEFAULT_SCENE_OBJECTS = [];
|
|
26
27
|
/** The persistence contract (plan §8.1): the version lives in the key AND in
|
|
@@ -96,9 +97,9 @@ export const OBJECT_COLORS = [
|
|
|
96
97
|
/** How many hand-mixed colours the inspector remembers. Six is one palette
|
|
97
98
|
* row: enough to keep a scene's custom tints reachable, short enough that the
|
|
98
99
|
* popover never turns into a second, unruly palette. */
|
|
99
|
-
|
|
100
|
+
const RECENT_OBJECT_COLORS_MAX = 6;
|
|
100
101
|
/** Session memory of hand-mixed tints; versioned like every other key here. */
|
|
101
|
-
|
|
102
|
+
const RECENT_OBJECT_COLORS_KEY = "cozyclay.objectColors.recent";
|
|
102
103
|
|
|
103
104
|
const HEX_SHORT = /^#?([0-9a-f]{3})$/i;
|
|
104
105
|
const HEX_LONG = /^#?([0-9a-f]{6})$/i;
|
|
@@ -209,13 +210,67 @@ const MESH_ENTRY = {
|
|
|
209
210
|
color: "#c49a6c",
|
|
210
211
|
};
|
|
211
212
|
|
|
213
|
+
/**
|
|
214
|
+
* A sculpt is a clay prop built from a recipe of soft parts (sculpt-recipe.js):
|
|
215
|
+
* data an agent writes from a reference image, never code. Like a mesh its size
|
|
216
|
+
* is per-instance, but it is MEASURED from the recipe rather than stored as a
|
|
217
|
+
* fact, so a record can never disagree with the parts it draws.
|
|
218
|
+
*/
|
|
219
|
+
export const SCULPT_KIND = "sculpt";
|
|
220
|
+
const SCULPT_ENTRY = {
|
|
221
|
+
kind: SCULPT_KIND,
|
|
222
|
+
label: "Sculpt",
|
|
223
|
+
group: "Sculpts",
|
|
224
|
+
footprint: { width: 1, depth: 1 },
|
|
225
|
+
height: 1,
|
|
226
|
+
color: "#c2c6c8",
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* An empty is a pure grouping node: a transform and a place in the hierarchy,
|
|
231
|
+
* with nothing to draw (Unity's Create Empty, Blender's Empty). It exists so a
|
|
232
|
+
* multi-part assembly can have one top-level handle — move it, turn it, give it
|
|
233
|
+
* a route — without any of the parts having to stand in for it.
|
|
234
|
+
*
|
|
235
|
+
* Its size is zero on purpose. Every consumer that reads geometry (blockers,
|
|
236
|
+
* drop surfaces, ground sampling, support rise, fits, the plan footprint) goes
|
|
237
|
+
* through `footprint` × `height`, so a zero box makes the empty invisible to
|
|
238
|
+
* all of them without a per-consumer special case; the few that would
|
|
239
|
+
* misread a zero box as a point (dropToSurfacePatch) check `isEmptyObject`.
|
|
240
|
+
* It is creatable from the menu, so it lives beside the library entries rather
|
|
241
|
+
* than being import-only.
|
|
242
|
+
*/
|
|
243
|
+
export const EMPTY_KIND = "empty";
|
|
244
|
+
/** Editor-only marker size in metres: the axes cross drawn for an empty, and
|
|
245
|
+
* the side of the invisible volume that makes it clickable. */
|
|
246
|
+
export const EMPTY_MARKER_SIZE = 0.3;
|
|
247
|
+
const EMPTY_ENTRY = {
|
|
248
|
+
kind: EMPTY_KIND,
|
|
249
|
+
label: "Empty",
|
|
250
|
+
group: "Primitives",
|
|
251
|
+
footprint: { width: 0, depth: 0 },
|
|
252
|
+
height: 0,
|
|
253
|
+
color: "#ffffff",
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
/** True for a record that is only a node: no mesh, no collision, no surface. */
|
|
257
|
+
export function isEmptyObject(object) {
|
|
258
|
+
return object?.renderer === EMPTY_KIND;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** The catalogue grouped for the create menu: the library plus the empty,
|
|
262
|
+
* which is creatable but has no footprint to satisfy the library's contract. */
|
|
263
|
+
export const OBJECT_MENU_ENTRIES = Object.freeze([EMPTY_ENTRY, ...OBJECT_LIBRARY]);
|
|
264
|
+
|
|
212
265
|
/** Every kind that can exist in a scene: the catalogue you can create from,
|
|
213
266
|
* plus the kinds that arrive by import and so are deliberately absent from the
|
|
214
267
|
* "Add object" menu (a cutout without an image, a mesh without a GLB, has
|
|
215
268
|
* nothing to draw). */
|
|
216
|
-
|
|
269
|
+
function objectLibraryEntry(kind) {
|
|
217
270
|
if (kind === CUTOUT_KIND) return CUTOUT_ENTRY;
|
|
218
271
|
if (kind === MESH_KIND) return MESH_ENTRY;
|
|
272
|
+
if (kind === SCULPT_KIND) return SCULPT_ENTRY;
|
|
273
|
+
if (kind === EMPTY_KIND) return EMPTY_ENTRY;
|
|
219
274
|
return OBJECT_LIBRARY.find((entry) => entry.kind === kind) ?? null;
|
|
220
275
|
}
|
|
221
276
|
|
|
@@ -231,9 +286,9 @@ const cutoutHeight = (value) => Math.max(CUTOUT_HEIGHT_MIN, value);
|
|
|
231
286
|
const cutoutAspect = (value) => clamp(value, CUTOUT_ASPECT_MIN, CUTOUT_ASPECT_MAX);
|
|
232
287
|
|
|
233
288
|
/** How far a card may be pulled off the picture's own proportions. */
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
289
|
+
const CUTOUT_STRETCH_MIN = 0.1;
|
|
290
|
+
const CUTOUT_STRETCH_MAX = 10;
|
|
291
|
+
const cutoutStretch = (value) => {
|
|
237
292
|
const n = Number(value);
|
|
238
293
|
return Number.isFinite(n) ? clamp(n, CUTOUT_STRETCH_MIN, CUTOUT_STRETCH_MAX) : 1;
|
|
239
294
|
};
|
|
@@ -255,8 +310,6 @@ export function cutoutFootprint(height, aspect, stretch = 1) {
|
|
|
255
310
|
};
|
|
256
311
|
}
|
|
257
312
|
|
|
258
|
-
/* ----------------------------------------------------- attachment ---- */
|
|
259
|
-
|
|
260
313
|
/**
|
|
261
314
|
* The frames a prop can be pinned to on a character. These are the APP's IK
|
|
262
315
|
* track ids — the same names the rig panel and the animation tracks speak —
|
|
@@ -332,7 +385,7 @@ export function sceneObjectIdFromHierarchy(hierarchyId) {
|
|
|
332
385
|
export function createSceneObject(kind, existing = [], placement = {}) {
|
|
333
386
|
// Cutouts and meshes come from an import, never from the catalogue:
|
|
334
387
|
// without an asset id the record has nothing to draw.
|
|
335
|
-
if (kind === CUTOUT_KIND || kind === MESH_KIND) return null;
|
|
388
|
+
if (kind === CUTOUT_KIND || kind === MESH_KIND || kind === SCULPT_KIND) return null;
|
|
336
389
|
const entry = objectLibraryEntry(kind);
|
|
337
390
|
if (!entry) return null;
|
|
338
391
|
const names = new Set(existing.map((object) => object.name));
|
|
@@ -510,6 +563,52 @@ export function duplicateMeshOptions(object) {
|
|
|
510
563
|
return { assetId: object.assetId, height: object.height, footprint: object.footprint, name: object.name, clay: object.clay === true };
|
|
511
564
|
}
|
|
512
565
|
|
|
566
|
+
/**
|
|
567
|
+
* A fresh sculpt from a recipe. Returns `{ object }` or `{ error }` with the
|
|
568
|
+
* recipe's own refusal, so a caller (the agent above all) hears why a recipe
|
|
569
|
+
* cannot be drawn instead of getting a silent null.
|
|
570
|
+
*/
|
|
571
|
+
export function createSculptObject({ recipe, name = "" } = {}, existing = [], placement = {}) {
|
|
572
|
+
const normalized = normalizeSculptRecipe(recipe);
|
|
573
|
+
if (!normalized.ok) return { error: normalized.error };
|
|
574
|
+
const { footprint, height } = sculptStandingBox(normalized.recipe);
|
|
575
|
+
const base = typeof name === "string" && name.trim() ? name.trim() : SCULPT_ENTRY.label;
|
|
576
|
+
const names = new Set(existing.map((object) => object.name));
|
|
577
|
+
let displayName = base;
|
|
578
|
+
for (let n = 2; names.has(displayName); n += 1) displayName = `${base} ${n}`;
|
|
579
|
+
const ids = new Set(existing.map((object) => object.id));
|
|
580
|
+
let id = SCULPT_KIND;
|
|
581
|
+
for (let n = 2; ids.has(id); n += 1) id = `${SCULPT_KIND}-${n}`;
|
|
582
|
+
return {
|
|
583
|
+
object: {
|
|
584
|
+
id,
|
|
585
|
+
name: displayName,
|
|
586
|
+
renderer: SCULPT_KIND,
|
|
587
|
+
x: TRANSFORM_LIMITS.x(Number(placement.x) || 0),
|
|
588
|
+
y: TRANSFORM_LIMITS.y(Number(placement.y) || 0),
|
|
589
|
+
z: TRANSFORM_LIMITS.z(Number(placement.z) || 0),
|
|
590
|
+
rot: wrapAngle(Number(placement.rot) || 0),
|
|
591
|
+
rotX: 0,
|
|
592
|
+
rotZ: 0,
|
|
593
|
+
scaleX: 1,
|
|
594
|
+
scaleY: 1,
|
|
595
|
+
scaleZ: 1,
|
|
596
|
+
path: null,
|
|
597
|
+
color: SCULPT_ENTRY.color,
|
|
598
|
+
parent: null,
|
|
599
|
+
attach: null,
|
|
600
|
+
hidden: false,
|
|
601
|
+
recipe: normalized.recipe,
|
|
602
|
+
footprint,
|
|
603
|
+
height,
|
|
604
|
+
},
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
export function duplicateSculptOptions(object) {
|
|
609
|
+
return { recipe: object.recipe, name: object.name };
|
|
610
|
+
}
|
|
611
|
+
|
|
513
612
|
/** Every writable transform channel and the rule that keeps it in the room. */
|
|
514
613
|
const TRANSFORM_LIMITS = {
|
|
515
614
|
x: (value) => clamp(value, OBJECT_POSITION_LIMITS.min.x, OBJECT_POSITION_LIMITS.max.x),
|
|
@@ -523,6 +622,16 @@ const TRANSFORM_LIMITS = {
|
|
|
523
622
|
scaleZ: (value) => clamp(value, OBJECT_SCALE_LIMITS.min.z, OBJECT_SCALE_LIMITS.max.z),
|
|
524
623
|
};
|
|
525
624
|
|
|
625
|
+
/** The patch keys updateSceneObject reads for this object; any other key is
|
|
626
|
+
* silently ignored there, so callers that must not be silent check against this. */
|
|
627
|
+
export function objectPatchFields(object) {
|
|
628
|
+
const fields = [...Object.keys(TRANSFORM_LIMITS), "name", "color", "hidden", "opacity", "path"];
|
|
629
|
+
if (object?.renderer === CUTOUT_KIND) fields.push("assetId", "sourceAssetId", "matteAssetId", "matteScale", "height", "aspect", "width", "stretch");
|
|
630
|
+
if (object?.renderer === MESH_KIND) fields.push("clay", "assetId", "height");
|
|
631
|
+
if (object?.renderer === SCULPT_KIND) fields.push("recipe");
|
|
632
|
+
return fields;
|
|
633
|
+
}
|
|
634
|
+
|
|
526
635
|
/** Every object that hangs off `id`, at any depth. A cycle cannot form because
|
|
527
636
|
* setParent refuses one, but the seen-set keeps this total even if data is
|
|
528
637
|
* hand-edited into a loop. */
|
|
@@ -574,13 +683,117 @@ export function isEffectivelyHidden(entity, objects = [], characters = []) {
|
|
|
574
683
|
return false;
|
|
575
684
|
}
|
|
576
685
|
|
|
686
|
+
/** Float dust (6e-17 where 0 belongs) is snapped away, and everything else is
|
|
687
|
+
* kept to 1e-9: fine enough that hundreds of small gizmo steps cannot drift a
|
|
688
|
+
* carried child visibly, coarse enough that stored values stay readable. */
|
|
689
|
+
const tidy = (value) => {
|
|
690
|
+
const rounded = Math.round(value * 1e9) / 1e9;
|
|
691
|
+
return rounded === 0 ? 0 : rounded;
|
|
692
|
+
};
|
|
693
|
+
export const tidyAngle = (deg) => tidy(wrapAngle(tidy(deg)));
|
|
694
|
+
|
|
695
|
+
const orientationOf = (record) =>
|
|
696
|
+
new Quaternion().setFromEuler(new Euler((record.rotX ?? 0) * DEG, (record.rot ?? 0) * DEG, (record.rotZ ?? 0) * DEG, EULER_ORDER));
|
|
697
|
+
|
|
698
|
+
/** The Euler triple (degrees, XYZ) for an orientation that stays closest to
|
|
699
|
+
* `near`. Every orientation has two XYZ triples — (x, y, z) and
|
|
700
|
+
* (x+180, 180−y, z+180) — and a bare setFromQuaternion always answers with the
|
|
701
|
+
* one whose Y is within ±90. Taking that blindly would turn a plain yaw of 170
|
|
702
|
+
* into "rotX 180, rot −10, rotZ 180": the same pose, but the bird's-eye board
|
|
703
|
+
* and the inspector read `rot` as the yaw. The nearer triple keeps a flat
|
|
704
|
+
* object flat across every turn. */
|
|
705
|
+
function eulerNear(quaternion, near) {
|
|
706
|
+
const e = new Euler().setFromQuaternion(quaternion, EULER_ORDER);
|
|
707
|
+
const here = [near.rotX ?? 0, near.rot ?? 0, near.rotZ ?? 0];
|
|
708
|
+
const candidates = [
|
|
709
|
+
[e.x / DEG, e.y / DEG, e.z / DEG],
|
|
710
|
+
[e.x / DEG + 180, 180 - e.y / DEG, e.z / DEG + 180],
|
|
711
|
+
].map((triple) => triple.map(tidyAngle));
|
|
712
|
+
const distance = (triple) => triple.reduce((sum, value, i) => sum + Math.abs(wrapAngle(value - here[i])), 0);
|
|
713
|
+
const [rotX, rot, rotZ] = distance(candidates[1]) < distance(candidates[0]) ? candidates[1] : candidates[0];
|
|
714
|
+
return { rotX, rot, rotZ };
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* The rigid motion that takes a pose to another: where its pivot was and is,
|
|
719
|
+
* and the turn Δ = q_after · q_before⁻¹ between the two orientations (XYZ
|
|
720
|
+
* Eulers, the renderer's order). Anything riding the pose goes to
|
|
721
|
+
* `to + Δ·(p − from)` and turns by Δ on the spot. Returns null when the
|
|
722
|
+
* orientation did not change — a pure move stays a plain translation.
|
|
723
|
+
*
|
|
724
|
+
* `yaw` is the part of Δ that turns about the vertical (swing-twist about +Y,
|
|
725
|
+
* degrees). A character only has a yaw, so it takes this and nothing more: the
|
|
726
|
+
* pitch or roll of what it is grouped under moves its position and leaves its
|
|
727
|
+
* facing alone.
|
|
728
|
+
*
|
|
729
|
+
* @returns {{ quaternion: Quaternion, matrix: number[], yaw: number, from: object, to: object } | null}
|
|
730
|
+
*/
|
|
731
|
+
export function rigidMotionBetween(before, after) {
|
|
732
|
+
if ((before.rotX ?? 0) === (after.rotX ?? 0) && (before.rot ?? 0) === (after.rot ?? 0) && (before.rotZ ?? 0) === (after.rotZ ?? 0)) return null;
|
|
733
|
+
const quaternion = orientationOf(after).multiply(orientationOf(before).invert());
|
|
734
|
+
if (quaternion.w < 0) quaternion.set(-quaternion.x, -quaternion.y, -quaternion.z, -quaternion.w);
|
|
735
|
+
if (quaternion.w > 1 - 1e-15) return null;
|
|
736
|
+
const { x, y, z, w } = quaternion;
|
|
737
|
+
return {
|
|
738
|
+
quaternion,
|
|
739
|
+
// row-major 3x3 of Δ
|
|
740
|
+
matrix: [
|
|
741
|
+
1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w),
|
|
742
|
+
2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w),
|
|
743
|
+
2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y),
|
|
744
|
+
],
|
|
745
|
+
yaw: tidyAngle((2 * Math.atan2(y, w)) / DEG),
|
|
746
|
+
from: { x: before.x ?? 0, y: before.y ?? 0, z: before.z ?? 0 },
|
|
747
|
+
to: { x: after.x ?? 0, y: after.y ?? 0, z: after.z ?? 0 },
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/** A point carried by a rigid motion from `rigidMotionBetween`. */
|
|
752
|
+
export function carryPointByMotion(point, motion) {
|
|
753
|
+
const { matrix: m, from, to } = motion;
|
|
754
|
+
const dx = point.x - from.x;
|
|
755
|
+
const dy = (point.y ?? 0) - from.y;
|
|
756
|
+
const dz = point.z - from.z;
|
|
757
|
+
return {
|
|
758
|
+
x: tidy(to.x + m[0] * dx + m[1] * dy + m[2] * dz),
|
|
759
|
+
y: tidy(to.y + m[3] * dx + m[4] * dy + m[5] * dz),
|
|
760
|
+
z: tidy(to.z + m[6] * dx + m[7] * dy + m[8] * dz),
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/** Descendants that turn with a rotating parent: everything under it except
|
|
765
|
+
* records riding a character (their numbers are bone-local, so they follow the
|
|
766
|
+
* bone, not the parent) and whatever hangs below such a record. */
|
|
767
|
+
function descendantsThatTurn(objects, id) {
|
|
768
|
+
const out = new Set();
|
|
769
|
+
let frontier = [id];
|
|
770
|
+
while (frontier.length) {
|
|
771
|
+
const next = [];
|
|
772
|
+
for (const object of objects) {
|
|
773
|
+
if (!object.parent || !frontier.includes(object.parent) || object.id === id || out.has(object.id) || object.attach) continue;
|
|
774
|
+
out.add(object.id);
|
|
775
|
+
next.push(object.id);
|
|
776
|
+
}
|
|
777
|
+
frontier = next;
|
|
778
|
+
}
|
|
779
|
+
return out;
|
|
780
|
+
}
|
|
781
|
+
|
|
577
782
|
export function updateSceneObject(objects, id, patch) {
|
|
578
783
|
let changed = false;
|
|
579
784
|
const target = objects.find((object) => object.id === id);
|
|
580
|
-
// A parent carries its children: the group is dragged, nudged and
|
|
581
|
-
// one body.
|
|
582
|
-
//
|
|
583
|
-
//
|
|
785
|
+
// A parent carries its children: the group is dragged, nudged, turned and
|
|
786
|
+
// dropped as one body. Translation shifts every descendant by the same
|
|
787
|
+
// delta. Rotation turns the group rigidly about the parent's pivot: each
|
|
788
|
+
// descendant orbits it by Δ = q_new · q_old⁻¹ and composes Δ onto its own
|
|
789
|
+
// orientation, so every child keeps its pose relative to the parent. A
|
|
790
|
+
// child's travel route is NOT turned with it: the route is a road in the
|
|
791
|
+
// world, and a parent's turn turns the bodies (their orientation and the
|
|
792
|
+
// authored position they orbit), never the road they would drive. A move and a turn in one patch is both at once,
|
|
793
|
+
// about the pivot's old and new place. Scale is NOT carried — rescaling a
|
|
794
|
+
// group would have to resize every child about the parent, a different
|
|
795
|
+
// feature. Records riding a character (`attach`) hold bone-local numbers and
|
|
796
|
+
// are left alone by the turn, and so is everything below them.
|
|
584
797
|
const carried = target ? descendantsOf(objects, id) : [];
|
|
585
798
|
const delta = { x: 0, y: 0, z: 0 };
|
|
586
799
|
if (target) {
|
|
@@ -593,10 +806,43 @@ export function updateSceneObject(objects, id, patch) {
|
|
|
593
806
|
}
|
|
594
807
|
const moving = new Set(carried.map((object) => object.id));
|
|
595
808
|
const shifts = delta.x || delta.y || delta.z;
|
|
809
|
+
let turn = null;
|
|
810
|
+
let turning = null;
|
|
811
|
+
if (target && !target.attach && carried.length) {
|
|
812
|
+
const after = { x: (target.x ?? 0) + delta.x, y: (target.y ?? 0) + delta.y, z: (target.z ?? 0) + delta.z };
|
|
813
|
+
for (const key of ["rotX", "rot", "rotZ"]) {
|
|
814
|
+
const value = patch[key] === undefined ? NaN : Number(patch[key]);
|
|
815
|
+
after[key] = Number.isFinite(value) ? TRANSFORM_LIMITS[key](value) : (target[key] ?? 0);
|
|
816
|
+
}
|
|
817
|
+
turn = rigidMotionBetween(target, after);
|
|
818
|
+
if (turn) turning = descendantsThatTurn(objects, id);
|
|
819
|
+
}
|
|
596
820
|
|
|
597
821
|
const next = objects.map((object) => {
|
|
598
822
|
if (object.id !== id) {
|
|
599
|
-
if (!shifts || !moving.has(object.id)) return object;
|
|
823
|
+
if ((!shifts && !turn) || !moving.has(object.id)) return object;
|
|
824
|
+
if (turn && turning.has(object.id)) {
|
|
825
|
+
const update = {};
|
|
826
|
+
const orbit = carryPointByMotion({ x: object.x, y: object.y, z: object.z }, turn);
|
|
827
|
+
for (const axis of ["x", "y", "z"]) {
|
|
828
|
+
const bounded = TRANSFORM_LIMITS[axis](orbit[axis]);
|
|
829
|
+
if (bounded !== (object[axis] ?? 0)) update[axis] = bounded;
|
|
830
|
+
}
|
|
831
|
+
const orientation = new Quaternion().multiplyQuaternions(turn.quaternion, orientationOf(object));
|
|
832
|
+
const euler = eulerNear(orientation, object);
|
|
833
|
+
for (const key of ["rotX", "rot", "rotZ"]) {
|
|
834
|
+
if (euler[key] !== (object[key] ?? 0)) update[key] = euler[key];
|
|
835
|
+
}
|
|
836
|
+
// A move in the same edit still carries the road along, unturned.
|
|
837
|
+
if (object.path && shifts) {
|
|
838
|
+
const moved = translateObjectPath(object.path, delta);
|
|
839
|
+
if (JSON.stringify(moved ?? null) !== JSON.stringify(object.path ?? null)) update.path = moved;
|
|
840
|
+
}
|
|
841
|
+
if (!Object.keys(update).length) return object;
|
|
842
|
+
changed = true;
|
|
843
|
+
return { ...object, ...update };
|
|
844
|
+
}
|
|
845
|
+
if (!shifts) return object;
|
|
600
846
|
const update = {};
|
|
601
847
|
const carriedDelta = { x: 0, y: 0, z: 0 };
|
|
602
848
|
for (const axis of ["x", "y", "z"]) {
|
|
@@ -629,6 +875,10 @@ export function updateSceneObject(objects, id, patch) {
|
|
|
629
875
|
update[key] = patch[key];
|
|
630
876
|
}
|
|
631
877
|
if (typeof patch.hidden === "boolean" && patch.hidden !== (object.hidden === true)) update.hidden = patch.hidden;
|
|
878
|
+
if (patch.opacity !== undefined && Number.isFinite(Number(patch.opacity))) {
|
|
879
|
+
const opacity = objectOpacity(patch.opacity);
|
|
880
|
+
if (opacity !== objectOpacity(object.opacity)) update.opacity = opacity;
|
|
881
|
+
}
|
|
632
882
|
// The travel path is authored geometry, not a bounded transform: it is
|
|
633
883
|
// normalized by createObjectPath (which repairs or refuses it) and set
|
|
634
884
|
// wholesale, with null clearing it back to a standing object.
|
|
@@ -704,6 +954,15 @@ export function updateSceneObject(objects, id, patch) {
|
|
|
704
954
|
}
|
|
705
955
|
}
|
|
706
956
|
}
|
|
957
|
+
// A recipe that cannot be drawn leaves the sculpt as it was; the command
|
|
958
|
+
// layer (object.sculpt) is where the refusal is reported.
|
|
959
|
+
if (object.renderer === SCULPT_KIND && patch.recipe !== undefined) {
|
|
960
|
+
const normalized = normalizeSculptRecipe(patch.recipe);
|
|
961
|
+
if (normalized.ok && JSON.stringify(normalized.recipe) !== JSON.stringify(object.recipe)) {
|
|
962
|
+
update.recipe = normalized.recipe;
|
|
963
|
+
Object.assign(update, sculptStandingBox(normalized.recipe));
|
|
964
|
+
}
|
|
965
|
+
}
|
|
707
966
|
if (!Object.keys(update).length) return object;
|
|
708
967
|
changed = true;
|
|
709
968
|
return { ...object, ...update };
|
|
@@ -788,8 +1047,34 @@ export function removeSceneObject(objects, id) {
|
|
|
788
1047
|
// is an editing convenience and never an owner of the parts.
|
|
789
1048
|
return next.map((object) => (object.parent === id ? { ...object, parent: null } : object));
|
|
790
1049
|
}
|
|
791
|
-
/* -------------------------------------------------- persistence ---- */
|
|
792
1050
|
|
|
1051
|
+
/**
|
|
1052
|
+
* Wrap `id` in a new Empty: the Empty takes the object's place in the tree
|
|
1053
|
+
* (its parent, its position) and the object becomes its child. One pure edit,
|
|
1054
|
+
* so the caller can publish it as ONE history entry.
|
|
1055
|
+
*
|
|
1056
|
+
* Returns `{ objects, emptyId }`, or null when the object is unknown or is
|
|
1057
|
+
* carried by a character (its numbers are local to a bone frame, so an Empty
|
|
1058
|
+
* "at its position" would sit at a fictional place — detach first).
|
|
1059
|
+
*
|
|
1060
|
+
* Rotation is not copied: the Empty starts unturned, because a group node
|
|
1061
|
+
* should be a clean handle, and the children keep their world transforms
|
|
1062
|
+
* (grouping moves nothing on screen).
|
|
1063
|
+
*/
|
|
1064
|
+
export function groupUnderNewEmpty(objects, id) {
|
|
1065
|
+
const target = objects.find((object) => object.id === id);
|
|
1066
|
+
if (!target || target.attach) return null;
|
|
1067
|
+
const created = createSceneObject(EMPTY_KIND, objects, { x: target.x, z: target.z });
|
|
1068
|
+
if (!created) return null;
|
|
1069
|
+
const names = new Set(objects.map((object) => object.name));
|
|
1070
|
+
const base = `${target.name} Group`;
|
|
1071
|
+
let name = base;
|
|
1072
|
+
for (let n = 2; names.has(name); n += 1) name = `${base} ${n}`;
|
|
1073
|
+
const empty = { ...created, name, y: TRANSFORM_LIMITS.y(target.y ?? 0), parent: target.parent ?? null };
|
|
1074
|
+
// The Empty goes in just before the object, so the Outliner row keeps its place.
|
|
1075
|
+
const next = objects.flatMap((object) => (object.id === id ? [empty, { ...object, parent: empty.id }] : [object]));
|
|
1076
|
+
return { objects: next, emptyId: empty.id };
|
|
1077
|
+
}
|
|
793
1078
|
/**
|
|
794
1079
|
* Repair one stored record into a live record, or return null to drop it.
|
|
795
1080
|
* Storage is never trusted: `footprint`/`height` are rebuilt from the
|
|
@@ -798,6 +1083,14 @@ export function removeSceneObject(objects, id) {
|
|
|
798
1083
|
* `renderer` (or a record without an id) has nothing to render or address,
|
|
799
1084
|
* so it is dropped rather than half-restored (plan §8.2).
|
|
800
1085
|
*/
|
|
1086
|
+
const OBJECT_OPACITY_MIN = 0.05;
|
|
1087
|
+
|
|
1088
|
+
/** 1 = solid. Clamped above zero so a see-through object never becomes an invisible, unpickable one. */
|
|
1089
|
+
function objectOpacity(value) {
|
|
1090
|
+
const n = Number(value);
|
|
1091
|
+
return Number.isFinite(n) ? Math.min(1, Math.max(OBJECT_OPACITY_MIN, n)) : 1;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
801
1094
|
export function normalizeSceneObject(record) {
|
|
802
1095
|
if (!record || typeof record !== "object" || Array.isArray(record)) return null;
|
|
803
1096
|
const entry = objectLibraryEntry(record.renderer);
|
|
@@ -808,6 +1101,9 @@ export function normalizeSceneObject(record) {
|
|
|
808
1101
|
// rule an unknown renderer already gets.
|
|
809
1102
|
const isCutout = entry.kind === CUTOUT_KIND;
|
|
810
1103
|
const isMesh = entry.kind === MESH_KIND;
|
|
1104
|
+
const sculpt = entry.kind === SCULPT_KIND ? normalizeSculptRecipe(record.recipe) : null;
|
|
1105
|
+
// Same rule as a cutout without its picture: nothing to draw, so dropped.
|
|
1106
|
+
if (sculpt && !sculpt.ok) return null;
|
|
811
1107
|
if (isCutout && (typeof record.assetId !== "string" || !record.assetId)) return null;
|
|
812
1108
|
if (isMesh && (typeof record.assetId !== "string" || !record.assetId)) return null;
|
|
813
1109
|
// Defensive import fallback, not a migration: hand-authored or external
|
|
@@ -855,6 +1151,8 @@ export function normalizeSceneObject(record) {
|
|
|
855
1151
|
// all, and null is exactly what it meant: world-anchored.
|
|
856
1152
|
attach: normalizeSceneAttach(record.attach) ?? null,
|
|
857
1153
|
hidden: record.hidden === true,
|
|
1154
|
+
// Absent means solid, so documents written before see-through objects are unchanged.
|
|
1155
|
+
...(objectOpacity(record.opacity) < 1 ? { opacity: objectOpacity(record.opacity) } : {}),
|
|
858
1156
|
// Library kinds take their size from the library — a stored footprint is
|
|
859
1157
|
// stale data, not a fact. Cutouts and meshes are the exceptions: their
|
|
860
1158
|
// size IS per-instance. A cutout rebuilds the footprint from height and
|
|
@@ -880,6 +1178,8 @@ export function normalizeSceneObject(record) {
|
|
|
880
1178
|
),
|
|
881
1179
|
height: cutoutHeight(pick(record.height, CUTOUT_DEFAULT_HEIGHT)),
|
|
882
1180
|
}
|
|
1181
|
+
: sculpt
|
|
1182
|
+
? { recipe: sculpt.recipe, ...sculptStandingBox(sculpt.recipe) }
|
|
883
1183
|
: isMesh
|
|
884
1184
|
? {
|
|
885
1185
|
assetId: record.assetId,
|
|
@@ -945,8 +1245,6 @@ export function loadScene(raw) {
|
|
|
945
1245
|
return { status: "valid", objects, dropped };
|
|
946
1246
|
}
|
|
947
1247
|
|
|
948
|
-
/* ------------------------------------------------------------- gizmo ---- */
|
|
949
|
-
|
|
950
1248
|
/** The three world axes the gizmo drags along, and the record field each one
|
|
951
1249
|
* rotates / scales. */
|
|
952
1250
|
const WORLD_AXES = ["x", "y", "z"];
|
|
@@ -1025,8 +1323,6 @@ export function objectSize(object) {
|
|
|
1025
1323
|
depth: (object.footprint?.depth ?? 1) * (object.scaleZ ?? 1),
|
|
1026
1324
|
};
|
|
1027
1325
|
}
|
|
1028
|
-
/* -------------------------------------------------- drop-to-surface ---- */
|
|
1029
|
-
|
|
1030
1326
|
/** The tolerance that keeps an edge-abutting footprint from counting as
|
|
1031
1327
|
* overlap: a strict `<` against `max - EPS` makes a 0.5 - 0.5 touch false. */
|
|
1032
1328
|
const OVERLAP_EPS = 1e-4;
|
|
@@ -1078,9 +1374,13 @@ export function objectFootprintBounds(object) {
|
|
|
1078
1374
|
* clamped here: the y clamp stays in updateSceneObject, the single owner.
|
|
1079
1375
|
*/
|
|
1080
1376
|
export function dropToSurfacePatch(object, others, characters = []) {
|
|
1377
|
+
// An empty has no base to rest on and no extent to rest with: dropping it
|
|
1378
|
+
// would carry a whole assembly to wherever a zero-size point happens to sit.
|
|
1379
|
+
if (isEmptyObject(object)) return null;
|
|
1081
1380
|
const self = objectFootprintBounds(object);
|
|
1082
1381
|
let highestTop = 0;
|
|
1083
1382
|
for (const other of others) {
|
|
1383
|
+
if (isEmptyObject(other)) continue;
|
|
1084
1384
|
if (isEffectivelyHidden(other, others, characters)) continue;
|
|
1085
1385
|
const bounds = objectFootprintBounds(other);
|
|
1086
1386
|
if (self.minX >= bounds.maxX - OVERLAP_EPS || bounds.minX >= self.maxX - OVERLAP_EPS) continue;
|
package/src/scenes.js
CHANGED
|
@@ -13,21 +13,22 @@ import { wrapAngle } from "./scene-objects.js";
|
|
|
13
13
|
import { LEGACY_FRAME_FPS, TIMELINE_FRAME_FPS, toTimelineFrame } from "./timeline-fps.js";
|
|
14
14
|
|
|
15
15
|
const CHARACTER_POSITION_LIMITS = elementByPath("character.position");
|
|
16
|
-
const CHARACTER_ROTATION_LIMITS = elementByPath("character.rot");
|
|
17
16
|
const CHARACTER_SCALE_LIMITS = elementByPath("character.scale");
|
|
18
17
|
const KEY_LIGHT_LIMITS = Object.fromEntries(["x", "y", "z", "intensity", "warmth"].map((axis) => [axis, elementByPath(`stage.keyLight.${axis}`)]));
|
|
19
18
|
|
|
20
|
-
export const SCENES_VERSION =
|
|
21
|
-
export const SCENES_STORAGE_KEY = "cozyclay.scenes.
|
|
22
|
-
export const SCENES_QUARANTINE_KEY = "cozyclay.scenes.
|
|
23
|
-
export const PREVIOUS_SCENES_STORAGE_KEY = "cozyclay.scenes.
|
|
24
|
-
|
|
25
|
-
|
|
19
|
+
export const SCENES_VERSION = 5;
|
|
20
|
+
export const SCENES_STORAGE_KEY = "cozyclay.scenes.v5";
|
|
21
|
+
export const SCENES_QUARANTINE_KEY = "cozyclay.scenes.v5.quarantine";
|
|
22
|
+
export const PREVIOUS_SCENES_STORAGE_KEY = "cozyclay.scenes.v4";
|
|
23
|
+
const V3_SCENES_STORAGE_KEY = "cozyclay.scenes.v3";
|
|
24
|
+
const V2_SCENES_STORAGE_KEY = "cozyclay.scenes.v2";
|
|
25
|
+
const V1_SCENES_STORAGE_KEY = "cozyclay.scenes.v1";
|
|
26
26
|
export const LEGACY_SCENE_STORAGE_KEY = "cozyclay.scene.v1";
|
|
27
27
|
/** Newest-first fallback chain: a user arriving from any older build still
|
|
28
28
|
* finds their scenes, and the reader migrates whatever body it lands on. */
|
|
29
29
|
export const LEGACY_SCENES_STORAGE_KEYS = Object.freeze([
|
|
30
30
|
PREVIOUS_SCENES_STORAGE_KEY,
|
|
31
|
+
V3_SCENES_STORAGE_KEY,
|
|
31
32
|
V2_SCENES_STORAGE_KEY,
|
|
32
33
|
V1_SCENES_STORAGE_KEY,
|
|
33
34
|
]);
|
|
@@ -38,10 +39,20 @@ export { LEGACY_FRAME_FPS, TIMELINE_FRAME_FPS, toTimelineFrame };
|
|
|
38
39
|
* file stem and the wire `source.rig` value sent to ARDY. */
|
|
39
40
|
export const CHARACTER_MODEL_IDS = Object.freeze(["y-bot-tpose", "x-bot-tpose"]);
|
|
40
41
|
export const DEFAULT_CHARACTER_MODEL = "y-bot-tpose";
|
|
42
|
+
/** A rig-free capsule stand-in for blocking. It has no FBX and is never sent
|
|
43
|
+
* to ARDY, so it extends the accepted kinds without joining
|
|
44
|
+
* CHARACTER_MODEL_IDS. */
|
|
45
|
+
export const PROXY_FIGURE_MODEL = "proxy-figure";
|
|
46
|
+
export const CHARACTER_KIND_IDS = Object.freeze([...CHARACTER_MODEL_IDS, PROXY_FIGURE_MODEL]);
|
|
47
|
+
export const POSTURES = Object.freeze(["stand", "sit", "lie"]);
|
|
48
|
+
|
|
49
|
+
export function isProxyFigure(character) {
|
|
50
|
+
return character?.model === PROXY_FIGURE_MODEL;
|
|
51
|
+
}
|
|
41
52
|
export const DEFAULT_SUBJECT_ONE = "a young woman in a tan coat";
|
|
42
53
|
export const DEFAULT_SUBJECT_TWO = "a man in a dark coat";
|
|
43
54
|
|
|
44
|
-
|
|
55
|
+
const DEFAULT_SCENE_STAGE = Object.freeze({
|
|
45
56
|
characters: Object.freeze([
|
|
46
57
|
Object.freeze({ id: "char-a", model: DEFAULT_CHARACTER_MODEL, x: 0, z: 0, rot: 0, hidden: false, pose: null, subject: DEFAULT_SUBJECT_ONE }),
|
|
47
58
|
]),
|
|
@@ -153,7 +164,8 @@ export function createCharacterEntry(source = null, index = 0) {
|
|
|
153
164
|
const s = plainObject(source) ? source : {};
|
|
154
165
|
return {
|
|
155
166
|
id: typeof s.id === "string" && s.id ? s.id : `char-${index + 1}`,
|
|
156
|
-
model:
|
|
167
|
+
model: CHARACTER_KIND_IDS.includes(s.model) ? s.model : DEFAULT_CHARACTER_MODEL,
|
|
168
|
+
posture: POSTURES.includes(s.posture) ? s.posture : "stand",
|
|
157
169
|
x: Math.max(CHARACTER_POSITION_LIMITS.min.x, Math.min(CHARACTER_POSITION_LIMITS.max.x, finiteOr(s.x, 0))),
|
|
158
170
|
// Lift is bounded by the deck and the shared room headroom, so every
|
|
159
171
|
// writer (inspector field, viewport gizmo, load path) shares one envelope.
|
|
@@ -161,6 +173,10 @@ export function createCharacterEntry(source = null, index = 0) {
|
|
|
161
173
|
z: Math.max(CHARACTER_POSITION_LIMITS.min.z, Math.min(CHARACTER_POSITION_LIMITS.max.z, finiteOr(s.z, 0))),
|
|
162
174
|
rot: wrapAngle(finiteOr(s.rot, 0)),
|
|
163
175
|
hidden: s.hidden === true,
|
|
176
|
+
// The scene object this character is grouped under, or null. A grouped
|
|
177
|
+
// character rides its object's travel path the way grouped parts do; its
|
|
178
|
+
// own x/y/z stay world numbers at the object's authored pose.
|
|
179
|
+
parent: typeof s.parent === "string" && s.parent ? s.parent : null,
|
|
164
180
|
// User-picked body tint; null means "model default" (y-bot clay, x-bot
|
|
165
181
|
// whiter clay) so the entry survives future default tweaks.
|
|
166
182
|
tint: typeof s.tint === "string" && /^#[0-9a-fA-F]{6}$/.test(s.tint) ? s.tint : null,
|
|
@@ -180,6 +196,10 @@ export function createCharacterEntry(source = null, index = 0) {
|
|
|
180
196
|
// re-fetch and decode it on the next session.
|
|
181
197
|
layer: normalizeLayer(s.layer),
|
|
182
198
|
motionRef: normalizeMotionRef(s.motionRef),
|
|
199
|
+
// The Pose-mode / IK edits on this character's layer (encoded keys, range
|
|
200
|
+
// pins and their residuals). The motion domain owns them while the scene
|
|
201
|
+
// is open; this is what the saved scene carries so a reload keeps them.
|
|
202
|
+
ikEdits: normalizeIkEdits(s.ikEdits),
|
|
183
203
|
};
|
|
184
204
|
}
|
|
185
205
|
|
|
@@ -189,6 +209,15 @@ const MOTION_ID_RE = /^[0-9a-f]{64}$/i;
|
|
|
189
209
|
// in the project's embedded motions) and/or by location (url: a bridge run,
|
|
190
210
|
// which is what pre-motionId documents carry). Either one alone is a valid
|
|
191
211
|
// ref; the restore path prefers the embedded bytes when both are present.
|
|
212
|
+
function normalizeIkEdits(value) {
|
|
213
|
+
if (!plainObject(value)) return null;
|
|
214
|
+
const rows = (list, valid) => (Array.isArray(list) ? list.filter(valid).map((entry) => cloneValue(entry)) : []);
|
|
215
|
+
const keys = rows(value.keys, (row) => plainObject(row) && Number.isFinite(row.frame) && plainObject(row.tracks));
|
|
216
|
+
const pins = rows(value.pins, (pin) => plainObject(pin) && typeof pin.id === "string");
|
|
217
|
+
const pinResiduals = rows(value.pinResiduals, (row) => Array.isArray(row) && typeof row[0] === "string");
|
|
218
|
+
return keys.length || pins.length ? { keys, pins, pinResiduals } : null;
|
|
219
|
+
}
|
|
220
|
+
|
|
192
221
|
function normalizeMotionRef(ref) {
|
|
193
222
|
if (!plainObject(ref)) return null;
|
|
194
223
|
const motionId = typeof ref.motionId === "string" && MOTION_ID_RE.test(ref.motionId) ? ref.motionId.toLowerCase() : null;
|
|
@@ -491,11 +520,31 @@ export function readSceneDocument(raw, legacyRaw = null) {
|
|
|
491
520
|
// Anything below v4 was authored while the timeline ran at 20 fps. The
|
|
492
521
|
// fallback stage (a v1 global cast) rides the same conversion, applied
|
|
493
522
|
// once, inside repairScene — whichever stage that scene ends up with.
|
|
523
|
+
// v4 → v5 needs no step of its own: every cast member passes through
|
|
524
|
+
// createCharacterEntry, which fills the missing posture with "stand".
|
|
494
525
|
const legacyClock = payload.version < 4;
|
|
495
526
|
const repaired = repairDocument(payload, createSceneStage(payload.stage), legacyClock);
|
|
496
527
|
return { status: migrating ? "migrated" : "valid", ...repaired };
|
|
497
528
|
}
|
|
498
529
|
|
|
530
|
+
/** Lift a scene document that skipped the storage reader (a project file, a
|
|
531
|
+
* playground preset) onto SCENES_VERSION: v3 and older move their frames onto
|
|
532
|
+
* the 24 fps clock, v4 and older gain a standing posture per cast member. */
|
|
533
|
+
export function migrateScenesDocument(source) {
|
|
534
|
+
if (!Number.isInteger(source.version) || source.version >= SCENES_VERSION) return source;
|
|
535
|
+
const legacyClock = source.version < 4;
|
|
536
|
+
return {
|
|
537
|
+
...source,
|
|
538
|
+
version: SCENES_VERSION,
|
|
539
|
+
scenes: source.scenes.map((scene) => {
|
|
540
|
+
const stage = legacyClock ? migrateStageFrames(scene.stage) : scene.stage;
|
|
541
|
+
return { ...scene, stage: plainObject(stage) && Array.isArray(stage.characters)
|
|
542
|
+
? { ...stage, characters: stage.characters.map((entry) => (plainObject(entry) && !POSTURES.includes(entry.posture) ? { ...entry, posture: "stand" } : entry)) }
|
|
543
|
+
: stage };
|
|
544
|
+
}),
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
|
|
499
548
|
export function serializeSceneDocument(document) {
|
|
500
549
|
return JSON.stringify({ version: SCENES_VERSION, activeSceneId: document.activeSceneId, scenes: document.scenes });
|
|
501
550
|
}
|