cozyclay 3.2.0 → 3.2.1
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 +87 -0
- package/INSTALL_FOR_AGENTS.md +4 -4
- package/README.md +4 -2
- package/bin/agent/agent-failure-codes.mjs +34 -8
- package/bin/agent/agent-routes.mjs +6 -6
- package/bin/agent/agent-runner.mjs +16 -13
- package/bin/agent/agent-stream.mjs +26 -3
- package/bin/agent/main-prompt.mjs +1 -1
- package/bin/agent/studio-prompt.mjs +2 -2
- package/bin/agent/supervisor-followup.mjs +34 -0
- package/bin/agent/supervisor.mjs +6 -6
- package/bin/cozyclay.mjs +23 -60
- package/bin/star-prompt.mjs +15 -14
- package/bin/studio-routes.mjs +6 -3
- package/bin/telemetry-state.mjs +5 -7
- package/bin/update-check.mjs +61 -7
- package/bin/update-install.mjs +16 -3
- package/bin/update-restart.mjs +95 -0
- package/bin/update-routes.mjs +25 -3
- package/dist/app/index.html +13 -7
- package/dist/assets/TaskBoard-tgO0MNKL.js +1 -0
- package/dist/assets/analytics-CaTUuLPM.js +1 -0
- package/dist/assets/app-CnDkB5WO.css +1 -0
- package/dist/assets/app-DJt06Oyb.js +5144 -0
- package/dist/assets/{compile-worker-DRWRa9pr.js → compile-worker-D1pl6G7x.js} +5 -5
- package/dist/assets/landing-2Fy7DU41.js +1 -0
- package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +1 -0
- package/dist/assets/motion3-compile-BxaM0Xwl.js +1 -0
- package/dist/assets/scene-asset-sync-CLGNbEBq.js +13 -0
- package/dist/assets/task-board-UVGTZLRF.js +15 -0
- package/dist/assets/workflow-CSiBlX1s.js +179 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/index.html +4 -1
- package/dist/privacy/index.html +9 -6
- package/dist/sitemap.xml +2 -2
- package/dist/workflow/index.html +5 -4
- package/mcp/README.md +1 -1
- package/mcp/live-hub.mjs +4 -4
- package/mcp/tool-handlers.mjs +14 -16
- package/package.json +2 -1
- package/src/App.jsx +37 -70
- package/src/analytics-controls.js +25 -3
- package/src/analytics-strict.js +16 -4
- package/src/analytics.js +32 -26
- package/src/app-version.js +13 -0
- package/src/asset-drop.js +39 -0
- package/src/asset-pane.css +34 -0
- package/src/asset-pane.jsx +4 -35
- package/src/bus-tasks.js +1 -1
- package/src/character-kind.js +8 -6
- package/src/click-friction.js +5 -4
- package/src/command-bus-telemetry.js +8 -7
- package/src/command-bus.js +2 -16
- package/src/commands/cast.js +2 -1
- package/src/commands/motion3.js +3 -1
- package/src/commands/objects.js +2 -37
- package/src/commands/project.js +3 -3
- package/src/commands/sculpt-compare.js +53 -0
- package/src/content-tile.jsx +36 -0
- package/src/domains/cast.js +7 -6
- package/src/domains/motion.js +6 -5
- package/src/domains/scenes.js +41 -31
- package/src/execution-telemetry.js +7 -3
- package/src/export-menu-telemetry.js +26 -0
- package/src/finger-handles.jsx +128 -0
- package/src/friction-hint.js +82 -0
- package/src/live-control.js +2 -1
- package/src/locale-zh/panels.js +20 -0
- package/src/locale-zh/shell.js +4 -0
- package/src/locale-zh/workspace.js +6 -0
- package/src/motion/find-bone.js +13 -0
- package/src/motion/fingers.js +170 -0
- package/src/motion/fingertip-pick.js +21 -0
- package/src/motion/ik-exposure.js +48 -0
- package/src/motion/ik.js +4 -11
- package/src/motion3/compiler/head-lead.js +4 -2
- package/src/motion3/hybrid-hint.js +19 -0
- package/src/motion3/load.js +19 -12
- package/src/motion3/qa/hints-contact.js +12 -0
- package/src/motion3/qa/patch.js +4 -1
- package/src/motion3/qa/verdict.js +7 -2
- package/src/motion3/repair.js +28 -8
- package/src/motion3/skills/catch.js +2 -1
- package/src/panels/FingerPanel.jsx +38 -0
- package/src/panels/RigControlPanel.jsx +9 -1
- package/src/panels/SculptPanel.jsx +12 -12
- package/src/panels/SculptPartTree.jsx +46 -0
- package/src/panels/SubjectsPanel.jsx +1 -1
- package/src/panels/sculpt.css +11 -0
- package/src/pose-fade.js +7 -3
- package/src/posestudio.jsx +14 -27
- package/src/project-browser.css +6 -0
- package/src/project-browser.jsx +9 -18
- package/src/project-start-choices.jsx +45 -0
- package/src/project.js +16 -12
- package/src/proxy-figure-shape.js +11 -0
- package/src/proxy-figure.jsx +25 -18
- package/src/scene-assets.js +17 -13
- package/src/scenes.js +22 -11
- package/src/sculpt-blueprint.js +56 -0
- package/src/sculpt-mode.js +5 -0
- package/src/sculpt-part-edit.js +115 -0
- package/src/sculpt-part-gizmo.jsx +159 -0
- package/src/sculpt-part-outline.jsx +72 -0
- package/src/sculpt-pick.js +53 -0
- package/src/sculpt-run-text.js +51 -0
- package/src/sculpt-run.js +10 -46
- package/src/sculpt-shelf-file.js +92 -0
- package/src/sculpt-shelf-tiles.jsx +69 -0
- package/src/sculpt-shelf.js +108 -0
- package/src/sculpt-stage.jsx +39 -2
- package/src/sculpt-thumb.js +72 -0
- package/src/sculpt-views.js +75 -0
- package/src/semantic-edit.js +0 -87
- package/src/settings-menu.jsx +2 -2
- package/src/shell/DetailsSlot.jsx +5 -6
- package/src/shell/LibrarySlot.jsx +8 -10
- package/src/shell/MenuBar.jsx +6 -6
- package/src/shell/PreferencesDialog.jsx +2 -3
- package/src/shell/ViewportToolbar.jsx +15 -18
- package/src/shell/add-character-entries.js +13 -0
- package/src/shell/pose-controls.js +32 -0
- package/src/shell/preference-pages.jsx +2 -11
- package/src/shell/storyboard-caption.js +21 -0
- package/src/shell/studio-shell-context.js +1 -0
- package/src/shortcut-friction.js +2 -1
- package/src/site/landing-analytics.js +28 -0
- package/src/studio-actions.js +3 -2
- package/src/studio-agent-protocol.js +4 -4
- package/src/studio-app-binding.js +4 -3
- package/src/update/UpdateChip.jsx +21 -2
- package/src/update/UpdateDialog.jsx +26 -6
- package/src/update/update-client.js +123 -17
- package/src/update/update-telemetry.js +15 -3
- package/src/use-sculpt-mode.js +62 -8
- package/src/workflow/agent-failure-codes.js +28 -9
- package/src/workflow/agent-turn-telemetry.js +1 -1
- package/src/workflow/task-board.js +2 -2
- package/tools/dev/page-shell.py +1 -1
- package/tools/dev/pages/greybox-to-video.html +21 -2
- package/tools/dev/pages/previs-software.html +3 -3
- package/tools/dev/pages/privacy.html +9 -6
- package/tools/dev/pages/seedance-camera-control.html +2 -2
- package/tools/loc-baseline.json +10 -10
- package/tools/run-tests.mjs +45 -69
- package/tools/select-tests.mjs +119 -0
- package/tools/test-durations.json +455 -0
- package/tools/test-graph.mjs +181 -0
- package/tools/test-shards.mjs +78 -0
- package/tools/tutorial/README.md +74 -0
- package/tools/tutorial/assemble.mjs +43 -0
- package/tools/tutorial/captions.mjs +43 -0
- package/tools/tutorial/cdp.mjs +55 -0
- package/tools/tutorial/cursor.mjs +159 -0
- package/tools/tutorial/episode.mjs +173 -0
- package/tools/tutorial/episodes/beginner-guide/00-install.mjs +42 -0
- package/tools/tutorial/episodes/beginner-guide/01-start.mjs +50 -0
- package/tools/tutorial/episodes/beginner-guide/02-layout.mjs +67 -0
- package/tools/tutorial/episodes/beginner-guide/03-stage.mjs +58 -0
- package/tools/tutorial/episodes/beginner-guide/04-motion.mjs +98 -0
- package/tools/tutorial/episodes/beginner-guide/05-camera.mjs +47 -0
- package/tools/tutorial/episodes/beginner-guide/06-export.mjs +59 -0
- package/tools/tutorial/episodes/beginner-guide/steps.mjs +32 -0
- package/tools/tutorial/episodes/beginner-guide.mjs +17 -0
- package/tools/tutorial/episodes/cast.mjs +12 -0
- package/tools/tutorial/episodes/first-video.mjs +121 -0
- package/tools/tutorial/episodes/stub-tour.mjs +97 -0
- package/tools/tutorial/launch.mjs +63 -0
- package/tools/tutorial/mux.mjs +61 -0
- package/tools/tutorial/overlay.mjs +142 -0
- package/tools/tutorial/record.mjs +156 -0
- package/tools/tutorial/scenes/terminal.html +63 -0
- package/tools/tutorial/steps.mjs +111 -0
- package/tools/tutorial/terminal.mjs +43 -0
- package/tools/tutorial/voice.mjs +49 -0
- package/tools/update-test-durations.mjs +42 -0
- package/tools/verify-package-tarball.mjs +18 -12
- package/dist/assets/TaskBoard-BUjjuvm5.js +0 -1
- package/dist/assets/app-Dige7q3X.css +0 -1
- package/dist/assets/app-DxoI941o.js +0 -5144
- package/dist/assets/motion3-compile-B4SX4TGx.js +0 -1
- package/dist/assets/rolldown-runtime-hePW80VL.js +0 -1
- package/dist/assets/scene-asset-sync-LeI3eSnO.js +0 -13
- package/dist/assets/task-board-5PKn7ZaA.js +0 -15
- package/dist/assets/workflow-Dg9jCKOq.js +0 -179
package/src/project.js
CHANGED
|
@@ -25,9 +25,12 @@ export const PROJECT_VERSION = 5;
|
|
|
25
25
|
export const PREVIS_MODES = Object.freeze(["storyboard", "animation"]);
|
|
26
26
|
export const DEFAULT_PREVIS_MODE = "animation";
|
|
27
27
|
|
|
28
|
-
export
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
export const normalizePrevisMode = (value) => (PREVIS_MODES.includes(value) ? value : DEFAULT_PREVIS_MODE);
|
|
29
|
+
// Which bodies the project is cast with: rigged humanoids for detailed previs,
|
|
30
|
+
// capsule figures for simple previs, or a mix. A file without the field
|
|
31
|
+
// predates the choice and already holds a rig character, so it reads as hybrid.
|
|
32
|
+
export const CAST_STYLES = Object.freeze(["rig", "capsule", "hybrid"]), DEFAULT_CAST_STYLE = "hybrid";
|
|
33
|
+
export const normalizeCastStyle = (value) => (CAST_STYLES.includes(value) ? value : DEFAULT_CAST_STYLE);
|
|
31
34
|
export const PROJECT_EXTENSION = ".cclayproject";
|
|
32
35
|
export const WORKFLOW_VERSION = 1;
|
|
33
36
|
export const WORKFLOW_STORAGE_KEY = "cozyclay.workflow.v1";
|
|
@@ -301,7 +304,7 @@ function readEmbeddedAssets(value, report) {
|
|
|
301
304
|
* assetRef references them. Throws `resources-too-large` when the whole
|
|
302
305
|
* manifest exceeds its budget.
|
|
303
306
|
*/
|
|
304
|
-
export function createProjectDocument({ scenesDocument, workspaceLayout, customPoses, name, previsMode, assets, savedAt, workflow }) {
|
|
307
|
+
export function createProjectDocument({ scenesDocument, workspaceLayout, customPoses, name, previsMode, castStyle, assets, savedAt, workflow }) {
|
|
305
308
|
const assetRecords = new Map();
|
|
306
309
|
for (const record of Array.isArray(assets) ? assets : []) {
|
|
307
310
|
const asset = embeddedAsset(record);
|
|
@@ -323,7 +326,7 @@ export function createProjectDocument({ scenesDocument, workspaceLayout, customP
|
|
|
323
326
|
kind: "project",
|
|
324
327
|
version: PROJECT_VERSION,
|
|
325
328
|
name: typeof name === "string" && name.trim() ? name.trim() : "Untitled",
|
|
326
|
-
previsMode: normalizePrevisMode(previsMode),
|
|
329
|
+
previsMode: normalizePrevisMode(previsMode), castStyle: normalizeCastStyle(castStyle),
|
|
327
330
|
...(Number.isFinite(savedAt) && savedAt > 0 ? { savedAt } : {}),
|
|
328
331
|
scenes: scenesDocument,
|
|
329
332
|
workspace: workspaceLayout ?? null,
|
|
@@ -355,7 +358,7 @@ export function buildAnimationProjectFromStoryboard(project) {
|
|
|
355
358
|
workspaceLayout: structuredClone(project?.workspaceLayout ?? null),
|
|
356
359
|
customPoses: structuredClone(project?.customPoses ?? []),
|
|
357
360
|
name: `${project?.name || "Untitled"} - ${ko("Animation", "애니메이션")}`,
|
|
358
|
-
previsMode: "animation",
|
|
361
|
+
previsMode: "animation", castStyle: project?.castStyle,
|
|
359
362
|
assets: project?.assets ?? [],
|
|
360
363
|
workflow: structuredClone(project?.workflow ?? createWorkflowGraph()),
|
|
361
364
|
savedAt: Date.now(),
|
|
@@ -367,8 +370,8 @@ export function buildAnimationProjectFromStoryboard(project) {
|
|
|
367
370
|
* problems?, project? }. Invalid embedded records are skipped, each one
|
|
368
371
|
* reported as a warning string and a `{ kind, id, code, message }` problem.
|
|
369
372
|
* v2/v3 files carry images at the top-level `assets`; v4 moves everything
|
|
370
|
-
* under `resources`; v5 adds the top-level `previsMode`
|
|
371
|
-
* reads as the default). Older files are read as-is, never rewritten.
|
|
373
|
+
* under `resources`; v5 adds the top-level `previsMode` and `castStyle`
|
|
374
|
+
* (absent or unknown reads as the default). Older files are read as-is, never rewritten.
|
|
372
375
|
*/
|
|
373
376
|
export function readProjectDocument(raw) {
|
|
374
377
|
let parsed;
|
|
@@ -402,7 +405,7 @@ export function readProjectDocument(raw) {
|
|
|
402
405
|
problems,
|
|
403
406
|
project: {
|
|
404
407
|
name: typeof parsed.name === "string" && parsed.name.trim() ? parsed.name.trim() : "Untitled",
|
|
405
|
-
previsMode: normalizePrevisMode(parsed.previsMode),
|
|
408
|
+
previsMode: normalizePrevisMode(parsed.previsMode), castStyle: normalizeCastStyle(parsed.castStyle),
|
|
406
409
|
savedAt: Number.isFinite(parsed.savedAt) && parsed.savedAt > 0 ? parsed.savedAt : null,
|
|
407
410
|
scenesDocument: { version: scenes.version, activeSceneId: scenes.activeSceneId ?? null, scenes: scenes.scenes },
|
|
408
411
|
workspaceLayout: parsed.workspace && typeof parsed.workspace === "object" ? parsed.workspace : null,
|
|
@@ -549,17 +552,18 @@ export function loadProjectSession(storage = globalThis.localStorage) {
|
|
|
549
552
|
const value = JSON.parse(storage?.getItem(PROJECT_SESSION_KEY) || "null");
|
|
550
553
|
if (!value || typeof value !== "object") return null;
|
|
551
554
|
const name = typeof value.name === "string" ? value.name.trim() : "";
|
|
552
|
-
return name ? { name, previsMode: normalizePrevisMode(value.previsMode), updatedAt: Number.isFinite(value.updatedAt) ? value.updatedAt : 0 } : null;
|
|
555
|
+
return name ? { name, previsMode: normalizePrevisMode(value.previsMode), castStyle: normalizeCastStyle(value.castStyle), updatedAt: Number.isFinite(value.updatedAt) ? value.updatedAt : 0 } : null;
|
|
553
556
|
} catch {
|
|
554
557
|
return null;
|
|
555
558
|
}
|
|
556
559
|
}
|
|
557
560
|
|
|
558
|
-
|
|
561
|
+
// `identity` is the previs mode, or `{ previsMode, castStyle }`.
|
|
562
|
+
export function storeProjectSession(name, identity = DEFAULT_PREVIS_MODE, storage = globalThis.localStorage) {
|
|
559
563
|
const normalized = typeof name === "string" ? name.trim() : "";
|
|
560
564
|
if (!normalized) return false;
|
|
561
565
|
try {
|
|
562
|
-
storage?.setItem(PROJECT_SESSION_KEY, JSON.stringify({ name: normalized, previsMode: normalizePrevisMode(previsMode), updatedAt: Date.now() }));
|
|
566
|
+
storage?.setItem(PROJECT_SESSION_KEY, JSON.stringify({ name: normalized, previsMode: normalizePrevisMode(identity?.previsMode ?? identity), castStyle: normalizeCastStyle(identity?.castStyle), updatedAt: Date.now() }));
|
|
563
567
|
return true;
|
|
564
568
|
} catch {
|
|
565
569
|
return false;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Capsule figure dimensions in metres, shared by the renderer
|
|
2
|
+
// (proxy-figure.jsx) and the rig-free bounds (character-kind.js).
|
|
3
|
+
export const PROXY_BODY_RADIUS = 0.22;
|
|
4
|
+
export const PROXY_BODY_HEIGHT = 1.45;
|
|
5
|
+
export const PROXY_HEAD_RADIUS = 0.12;
|
|
6
|
+
// Head centre sits this far above the top of the body or torso.
|
|
7
|
+
export const PROXY_HEAD_LIFT = 0.13;
|
|
8
|
+
// Sit is an L: a vertical torso at the hip and one leg capsule on the floor
|
|
9
|
+
// reaching this far forward of the hip, along the way the figure faces (+z).
|
|
10
|
+
export const PROXY_SIT_TORSO_HEIGHT = 0.85;
|
|
11
|
+
export const PROXY_SIT_LEG_REACH = 0.55;
|
package/src/proxy-figure.jsx
CHANGED
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
import { memo, useEffect, useMemo } from "react";
|
|
2
2
|
import * as THREE from "three";
|
|
3
3
|
import { defaultCharacterTint } from "./app-stage.jsx";
|
|
4
|
+
import {
|
|
5
|
+
PROXY_BODY_HEIGHT as BODY_HEIGHT, PROXY_BODY_RADIUS as BODY_RADIUS, PROXY_HEAD_LIFT,
|
|
6
|
+
PROXY_HEAD_RADIUS as HEAD_RADIUS, PROXY_SIT_LEG_REACH, PROXY_SIT_TORSO_HEIGHT,
|
|
7
|
+
} from "./proxy-figure-shape.js";
|
|
4
8
|
|
|
5
|
-
|
|
6
|
-
const
|
|
7
|
-
|
|
9
|
+
function addPart(group, geometry, material, position) {
|
|
10
|
+
const mesh = new THREE.Mesh(geometry, material);
|
|
11
|
+
mesh.position.set(...position);
|
|
12
|
+
mesh.castShadow = true;
|
|
13
|
+
mesh.receiveShadow = true;
|
|
14
|
+
mesh.frustumCulled = false;
|
|
15
|
+
group.add(mesh);
|
|
16
|
+
return mesh;
|
|
17
|
+
}
|
|
8
18
|
|
|
9
19
|
export function proxyYaw(rot, posture = "stand") {
|
|
10
20
|
return (rot * Math.PI) / 180 + (posture === "lie" ? Math.PI : 0);
|
|
@@ -19,8 +29,8 @@ export const ProxyFigure = memo(function ProxyFigure({
|
|
|
19
29
|
pickId,
|
|
20
30
|
selected,
|
|
21
31
|
}) {
|
|
22
|
-
const
|
|
23
|
-
const
|
|
32
|
+
const seated = posture === "sit";
|
|
33
|
+
const bodyHeight = seated ? PROXY_SIT_TORSO_HEIGHT : BODY_HEIGHT;
|
|
24
34
|
const model = useMemo(() => {
|
|
25
35
|
const bodyMaterial = new THREE.MeshStandardMaterial({
|
|
26
36
|
color: tint ?? defaultCharacterTint({ model: "proxy-figure" }, 0),
|
|
@@ -29,20 +39,17 @@ export const ProxyFigure = memo(function ProxyFigure({
|
|
|
29
39
|
envMapIntensity: 0.35,
|
|
30
40
|
});
|
|
31
41
|
const group = new THREE.Group();
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
head.receiveShadow = true;
|
|
42
|
-
head.frustumCulled = false;
|
|
43
|
-
group.add(head);
|
|
42
|
+
addPart(group, new THREE.CapsuleGeometry(BODY_RADIUS, bodyHeight - BODY_RADIUS * 2, 12, 20), bodyMaterial, [0, bodyHeight / 2, 0]);
|
|
43
|
+
addPart(group, new THREE.SphereGeometry(HEAD_RADIUS, 16, 12), bodyMaterial, [0, bodyHeight + PROXY_HEAD_LIFT, 0]);
|
|
44
|
+
if (seated) {
|
|
45
|
+
// Legs on the floor from behind the hip to PROXY_SIT_LEG_REACH ahead of
|
|
46
|
+
// it, along local +z: the way the figure (and a rig at the same rot) faces.
|
|
47
|
+
const legs = addPart(group, new THREE.CapsuleGeometry(BODY_RADIUS, PROXY_SIT_LEG_REACH - BODY_RADIUS, 12, 20), bodyMaterial,
|
|
48
|
+
[0, BODY_RADIUS, (PROXY_SIT_LEG_REACH - BODY_RADIUS) / 2]);
|
|
49
|
+
legs.rotation.x = Math.PI / 2;
|
|
50
|
+
}
|
|
44
51
|
return group;
|
|
45
|
-
}, [bodyHeight,
|
|
52
|
+
}, [bodyHeight, seated, tint]);
|
|
46
53
|
useEffect(() => () => {
|
|
47
54
|
const materials = new Set();
|
|
48
55
|
model.traverse(node => {
|
package/src/scene-assets.js
CHANGED
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
* adapter is the only part that needs a browser.
|
|
20
20
|
*/
|
|
21
21
|
import { isHdrAssetId, normalizeHdrAsset } from "./stage-hdr.js";
|
|
22
|
+
// A `sculpt-` id is a sculpt card on the shelf: the user's own library, never swept.
|
|
23
|
+
import { isSculptAssetId, normalizeSculptAsset } from "./sculpt-shelf-file.js";
|
|
22
24
|
|
|
23
25
|
/** Content-addressed, so the same bytes always land on the same id. */
|
|
24
26
|
export const ASSET_ID_PREFIX = "img-";
|
|
@@ -86,7 +88,7 @@ export function isMeshAssetId(value) {
|
|
|
86
88
|
}
|
|
87
89
|
|
|
88
90
|
export function isAssetId(value) {
|
|
89
|
-
return isImageAssetId(value) || isMeshAssetId(value) || isHdrAssetId(value);
|
|
91
|
+
return isImageAssetId(value) || isMeshAssetId(value) || isHdrAssetId(value) || isSculptAssetId(value);
|
|
90
92
|
}
|
|
91
93
|
|
|
92
94
|
async function sha256Hex(bytes, subtle) {
|
|
@@ -152,6 +154,7 @@ export function normalizeAsset(record) {
|
|
|
152
154
|
if (!bytes || !bytes.byteLength) return null;
|
|
153
155
|
// An `hdr-` id is an HDR environment file (stage-hdr.js), stored verbatim.
|
|
154
156
|
if (isHdrAssetId(record.id)) return normalizeHdrAsset(record, bytes);
|
|
157
|
+
if (isSculptAssetId(record.id)) return normalizeSculptAsset(record, bytes);
|
|
155
158
|
// Prefix and MIME must agree: a mesh id with an image type (or the reverse)
|
|
156
159
|
// is a cross-wired record and cannot be drawn as either kind.
|
|
157
160
|
if (isMeshAssetId(record.id)) {
|
|
@@ -378,14 +381,23 @@ export async function deleteAssetWithGraphGuard({
|
|
|
378
381
|
return false;
|
|
379
382
|
}
|
|
380
383
|
|
|
381
|
-
|
|
384
|
+
// Stored ids that nothing points at any more, ready to be swept. A sculpt card
|
|
385
|
+
// is referenced by no scene on purpose: the shelf, not Manage storage, deletes it.
|
|
382
386
|
export function unreachableAssetIds(storedIds, scenes) {
|
|
383
387
|
const reachable = referencedAssetIds(scenes);
|
|
384
|
-
return (Array.isArray(storedIds) ? storedIds : []).filter((id) => isAssetId(id) && !reachable.has(id));
|
|
388
|
+
return (Array.isArray(storedIds) ? storedIds : []).filter((id) => isAssetId(id) && !isSculptAssetId(id) && !reachable.has(id));
|
|
385
389
|
}
|
|
386
390
|
|
|
387
391
|
/* ------------------------------------------------------------ storage ---- */
|
|
388
392
|
|
|
393
|
+
// A readwrite transaction settles on complete, error or abort.
|
|
394
|
+
const committed = (tx) =>
|
|
395
|
+
new Promise((resolve, reject) => {
|
|
396
|
+
tx.oncomplete = resolve;
|
|
397
|
+
tx.onerror = () => reject(tx.error);
|
|
398
|
+
tx.onabort = () => reject(tx.error);
|
|
399
|
+
});
|
|
400
|
+
|
|
389
401
|
const request = (req) =>
|
|
390
402
|
new Promise((resolve, reject) => {
|
|
391
403
|
req.onsuccess = () => resolve(req.result);
|
|
@@ -416,11 +428,7 @@ export async function putAsset(db, record) {
|
|
|
416
428
|
if (!asset) throw new TypeError("putAsset needs a normalizable asset record");
|
|
417
429
|
const tx = db.transaction(ASSET_STORE_NAME, "readwrite");
|
|
418
430
|
tx.objectStore(ASSET_STORE_NAME).put(asset);
|
|
419
|
-
await
|
|
420
|
-
tx.oncomplete = resolve;
|
|
421
|
-
tx.onerror = () => reject(tx.error);
|
|
422
|
-
tx.onabort = () => reject(tx.error);
|
|
423
|
-
});
|
|
431
|
+
await committed(tx);
|
|
424
432
|
return asset;
|
|
425
433
|
}
|
|
426
434
|
|
|
@@ -440,10 +448,6 @@ export async function deleteAsset(db, id) {
|
|
|
440
448
|
if (!isAssetId(id)) return false;
|
|
441
449
|
const tx = db.transaction(ASSET_STORE_NAME, "readwrite");
|
|
442
450
|
tx.objectStore(ASSET_STORE_NAME).delete(id);
|
|
443
|
-
await
|
|
444
|
-
tx.oncomplete = resolve;
|
|
445
|
-
tx.onerror = () => reject(tx.error);
|
|
446
|
-
tx.onabort = () => reject(tx.error);
|
|
447
|
-
});
|
|
451
|
+
await committed(tx);
|
|
448
452
|
return true;
|
|
449
453
|
}
|
package/src/scenes.js
CHANGED
|
@@ -52,10 +52,12 @@ export function isProxyFigure(character) {
|
|
|
52
52
|
export const DEFAULT_SUBJECT_ONE = "a young woman in a tan coat";
|
|
53
53
|
export const DEFAULT_SUBJECT_TWO = "a man in a dark coat";
|
|
54
54
|
|
|
55
|
+
// The one-character cast every scene used to open with. A new scene now
|
|
56
|
+
// starts on an empty stage; legacy documents that never stored a cast, and
|
|
57
|
+
// fixtures that want a body to work with, still seed this one.
|
|
58
|
+
const SEED_CHARACTER = Object.freeze({ id: "char-a", model: DEFAULT_CHARACTER_MODEL, x: 0, z: 0, rot: 0, hidden: false, pose: null, subject: DEFAULT_SUBJECT_ONE });
|
|
55
59
|
const DEFAULT_SCENE_STAGE = Object.freeze({
|
|
56
|
-
characters: Object.freeze([
|
|
57
|
-
Object.freeze({ id: "char-a", model: DEFAULT_CHARACTER_MODEL, x: 0, z: 0, rot: 0, hidden: false, pose: null, subject: DEFAULT_SUBJECT_ONE }),
|
|
58
|
-
]),
|
|
60
|
+
characters: Object.freeze([]),
|
|
59
61
|
hasCharSheet: false,
|
|
60
62
|
// The look reference for the location: a data URL so it survives save/load
|
|
61
63
|
environmentImage: null,
|
|
@@ -241,16 +243,17 @@ function migrateLegacyCast(source) {
|
|
|
241
243
|
return cast;
|
|
242
244
|
}
|
|
243
245
|
|
|
244
|
-
const STAGE_ENVELOPE_KEYS = new Set([
|
|
246
|
+
const LEGACY_CAST_KEYS = Object.freeze(["charA", "charB", "showB", "poseA", "poseB", "subject", "subject2"]), STAGE_ENVELOPE_KEYS = new Set([
|
|
245
247
|
"characters", "hasCharSheet", "environmentImage", "environment", "style", "hasEnvSheet", "shotAspect", "cameraPresetId", "sensorId", "keyLight", "lighting",
|
|
246
|
-
|
|
248
|
+
...LEGACY_CAST_KEYS,
|
|
247
249
|
]);
|
|
248
250
|
|
|
249
251
|
export function createSceneStage(stage = null) {
|
|
250
252
|
const source = plainObject(stage) ? stage : {};
|
|
251
|
-
|
|
253
|
+
// With no cast list, a pre-v3 envelope folds its fixed cast in; anything else is an empty stage.
|
|
254
|
+
const characters = Array.isArray(source.characters)
|
|
252
255
|
? source.characters.map((entry, index) => createCharacterEntry(entry, index))
|
|
253
|
-
: migrateLegacyCast(source);
|
|
256
|
+
: LEGACY_CAST_KEYS.some((key) => Object.hasOwn(source, key)) ? migrateLegacyCast(source) : [];
|
|
254
257
|
// Unknown extra keys (shotAspect was one, once) ride along so the envelope
|
|
255
258
|
// stays sealed-but-lossless; legacy cast keys are deliberately dropped.
|
|
256
259
|
const extras = {};
|
|
@@ -359,8 +362,13 @@ export function activeScene(scenes, activeSceneId) {
|
|
|
359
362
|
return index < 0 ? null : scenes[index];
|
|
360
363
|
}
|
|
361
364
|
|
|
362
|
-
|
|
363
|
-
|
|
365
|
+
// The stage a scene opened with before empty stages: one standing Y Bot.
|
|
366
|
+
export const createSeededSceneStage = () => createSceneStage({ characters: [SEED_CHARACTER] });
|
|
367
|
+
|
|
368
|
+
// A one-scene document on an empty stage. `{ seedCharacter: true }` gives
|
|
369
|
+
// the scene the old one-character cast instead.
|
|
370
|
+
export function createSceneDocument({ seedCharacter = false } = {}) {
|
|
371
|
+
const scene = { ...createScene(), ...(seedCharacter === true ? { stage: createSeededSceneStage() } : {}) };
|
|
364
372
|
return { version: SCENES_VERSION, activeSceneId: scene.id, scenes: [scene] };
|
|
365
373
|
}
|
|
366
374
|
|
|
@@ -410,7 +418,8 @@ export function readSceneDocument(raw, legacyRaw = null) {
|
|
|
410
418
|
if (!plainObject(legacy) || legacy.version !== 1 || !Array.isArray(legacy.objects)) {
|
|
411
419
|
return { status: "corrupt", document: createSceneDocument(), dropped: 0, quarantineRaw: legacyRaw };
|
|
412
420
|
}
|
|
413
|
-
|
|
421
|
+
// A v1 scene had no stored cast; it always opened with the seed character.
|
|
422
|
+
const document = createSceneDocument({ seedCharacter: true });
|
|
414
423
|
document.scenes[0].objects = legacy.objects.filter(plainObject).map((object) => retireObjectKind(cloneValue(object)));
|
|
415
424
|
return { status: "migrated", document, dropped: legacy.objects.length - document.scenes[0].objects.length };
|
|
416
425
|
}
|
|
@@ -432,7 +441,9 @@ export function readSceneDocument(raw, legacyRaw = null) {
|
|
|
432
441
|
// v4 → v5 needs no step of its own: every cast member passes through
|
|
433
442
|
// createCharacterEntry, which fills the missing posture with "stand".
|
|
434
443
|
const legacyClock = payload.version < 4;
|
|
435
|
-
|
|
444
|
+
// Before v3 the cast lived on one global stage, and a document without one
|
|
445
|
+
// opened with the seed character.
|
|
446
|
+
const repaired = repairDocument(payload, payload.version < 3 && !plainObject(payload.stage) ? createSeededSceneStage() : createSceneStage(payload.stage), legacyClock);
|
|
436
447
|
return { status: migrating ? "migrated" : "valid", ...repaired };
|
|
437
448
|
}
|
|
438
449
|
|
package/src/sculpt-blueprint.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// reference picture into an orthographic model sheet, the drawing a sculpt is
|
|
3
3
|
// built from. The template names no subject: everything subject-specific comes
|
|
4
4
|
// from the attached reference and the operator's optional notes.
|
|
5
|
+
import { referenceMask } from "./sculpt-compare.js";
|
|
5
6
|
|
|
6
7
|
/** The sheet's fixed layout, as fractions of the image: a 2 x 2 grid. FRONT and
|
|
7
8
|
* SIDE share the top row so their head and foot lines are the same pixels;
|
|
@@ -63,3 +64,58 @@ export function blueprintErrorMessage(error, fallback) {
|
|
|
63
64
|
}
|
|
64
65
|
return fallback;
|
|
65
66
|
}
|
|
67
|
+
|
|
68
|
+
/** The camera yaw each compared view was drawn from (sculpt-compare.js: 0 in
|
|
69
|
+
* front, positive toward the subject's own left). TOP looks straight down and is
|
|
70
|
+
* cropped but not compared: sculpt.compare's elevation stops at 60 degrees. */
|
|
71
|
+
export const BLUEPRINT_VIEW_YAWS = Object.freeze({ front: 0, side: 90, back: 180 });
|
|
72
|
+
|
|
73
|
+
/** One panel of an RGBA sheet, cut on whole pixels from the layout fractions. */
|
|
74
|
+
export function cropBlueprintPanel(image, { x, y, width, height }) {
|
|
75
|
+
const left = Math.round(x * image.width), top = Math.round(y * image.height);
|
|
76
|
+
const w = Math.round((x + width) * image.width) - left, h = Math.round((y + height) * image.height) - top;
|
|
77
|
+
const data = new Uint8Array(w * h * 4);
|
|
78
|
+
for (let row = 0; row < h; row += 1) {
|
|
79
|
+
const start = ((top + row) * image.width + left) * 4;
|
|
80
|
+
data.set(image.data.subarray(start, start + w * 4), row * w * 4);
|
|
81
|
+
}
|
|
82
|
+
return { width: w, height: h, data };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Cut a generated sheet (RGBA) into its views by BLUEPRINT_LAYOUT and mask each
|
|
87
|
+
* one. A view whose mask fails (no subject, no background, or a subject that
|
|
88
|
+
* touches the panel edge and so was cut or spills into its neighbour) is dropped
|
|
89
|
+
* with a notice. Returns { views: [{ view, label, yaw, image, mask }], notices }.
|
|
90
|
+
*/
|
|
91
|
+
export function cropBlueprintViews(sheet, layout = BLUEPRINT_LAYOUT) {
|
|
92
|
+
const views = [], notices = [];
|
|
93
|
+
for (const panel of layout) {
|
|
94
|
+
const image = cropBlueprintPanel(sheet, panel);
|
|
95
|
+
try {
|
|
96
|
+
const { width, height, mask, source } = referenceMask(image);
|
|
97
|
+
let minX = width, maxX = -1, minY = height, maxY = -1;
|
|
98
|
+
for (let i = 0; i < mask.length; i += 1) if (mask[i]) {
|
|
99
|
+
const px = i % width, py = (i - px) / width;
|
|
100
|
+
minX = Math.min(minX, px); maxX = Math.max(maxX, px); minY = Math.min(minY, py); maxY = Math.max(maxY, py);
|
|
101
|
+
}
|
|
102
|
+
if (minX === 0 || minY === 0 || maxX === width - 1 || maxY === height - 1) throw new Error("the subject touches the panel edge");
|
|
103
|
+
const box = { x: minX / width, y: minY / height, width: (maxX - minX + 1) / width, height: (maxY - minY + 1) / height };
|
|
104
|
+
views.push({ view: panel.view, label: panel.label, yaw: BLUEPRINT_VIEW_YAWS[panel.view] ?? null, image, mask: { source, box } });
|
|
105
|
+
} catch (error) {
|
|
106
|
+
notices.push(`${panel.label} view dropped: ${String(error?.message || error).replace(/\.$/, "")}.`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return { views, notices };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The cropped views of a generated sheet as PNG data URLs, through an image
|
|
113
|
+
* port ({ decode, encodePng }: commands/sculpt-images.js in the browser). */
|
|
114
|
+
export async function blueprintViewImages(sheetDataUrl, images) {
|
|
115
|
+
const { views, notices } = cropBlueprintViews(await images.decode({ dataUrl: sheetDataUrl }));
|
|
116
|
+
return { notices, views: await Promise.all(views.map(async ({ view, label, yaw, image, mask }) => ({ view, label, yaw, mask, width: image.width, height: image.height, dataUrl: await images.encodePng(image) }))) };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** What a sculpt run takes from a sheet: its side and back views (the front is the reference itself). */
|
|
120
|
+
export const blueprintRunViews = (sheet) => (sheet?.views ?? []).filter((view) => view.view === "side" || view.view === "back")
|
|
121
|
+
.map(({ view, yaw, dataUrl }) => ({ view, yaw, dataUrl, name: `blueprint-${view}.png` }));
|
package/src/sculpt-mode.js
CHANGED
|
@@ -115,6 +115,11 @@ export function sculptAgentClient(transport, readContext) {
|
|
|
115
115
|
* signed in, or a provider key held by the sidecar). */
|
|
116
116
|
export const agentSessionReady = (status) => Boolean(status?.signedIn || status?.providersConfigured > 0);
|
|
117
117
|
|
|
118
|
+
/** Whether the sidecar holds a ChatGPT token: /oauth/status `signedIn` is the
|
|
119
|
+
* ChatGPT login alone (a provider key only counts in providersConfigured). The
|
|
120
|
+
* blueprint's image model is ChatGPT-only, so its button needs this (#970). */
|
|
121
|
+
export const chatgptSignedIn = (status) => status?.signedIn === true;
|
|
122
|
+
|
|
118
123
|
/** A stored image asset as a data URL (the bytes the store holds, so the run
|
|
119
124
|
* scores against exactly the stored, possibly downscaled, picture). */
|
|
120
125
|
export function assetDataUrl(asset) {
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// The part gizmo's arithmetic (#740 S8). Pure: no React, no DOM. A part's
|
|
2
|
+
// `position` and `rotation` are written in its PARENT's frame (the object
|
|
3
|
+
// frame for a root part), so a gizmo that moves the part in the world is
|
|
4
|
+
// read back through the parent: world = objectWorld × parent × local. A
|
|
5
|
+
// mirrored copy is edited through the part the recipe holds: the copy's own
|
|
6
|
+
// local values reflected across x = 0 of the same frame, which is how
|
|
7
|
+
// expandSculptParts derived the copy in the first place.
|
|
8
|
+
import * as THREE from "three";
|
|
9
|
+
import { SCULPT_MIRROR_SUFFIX, expandSculptParts } from "./sculpt-recipe.js";
|
|
10
|
+
import { tidy } from "./sculpt-recipe-read.js";
|
|
11
|
+
|
|
12
|
+
const DEG = Math.PI / 180;
|
|
13
|
+
// The recipe keeps 1e-4 (tidy); a field the drag left within that is unchanged.
|
|
14
|
+
const SAME = 1e-4;
|
|
15
|
+
|
|
16
|
+
const reflect = (local) => ({ position: [-local.position[0], local.position[1], local.position[2]], rotation: [local.rotation[0], -local.rotation[1], -local.rotation[2]] });
|
|
17
|
+
const wrap = (deg) => { const out = ((((deg + 180) % 360) + 360) % 360) - 180; return out === -180 ? 180 : out; };
|
|
18
|
+
const angleGap = (a, b) => Math.abs(wrap(a - b));
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Where the selected part is drawn, as the gizmo needs it: the drawn mesh id,
|
|
22
|
+
* the object-frame matrix of the frame its values are written in (`parent`,
|
|
23
|
+
* column-major), and its local position, rotation (deg, XYZ) and size. A
|
|
24
|
+
* mirrored selection gives the copy's frame and values. Null when the recipe
|
|
25
|
+
* has no such part.
|
|
26
|
+
*/
|
|
27
|
+
export function sculptPartFrame(recipe, selected) {
|
|
28
|
+
if (!recipe?.parts || !selected?.partId) return null;
|
|
29
|
+
// A cutter (op) is never drawn, so it has no mesh for the gizmo to sit on (the tree does not offer it).
|
|
30
|
+
if (recipe.parts.find((part) => part.id === selected.partId)?.op) return null;
|
|
31
|
+
const drawn = expandSculptParts(recipe);
|
|
32
|
+
const meshId = selected.mirrored ? `${selected.partId}${SCULPT_MIRROR_SUFFIX}` : selected.partId;
|
|
33
|
+
const row = drawn.find((entry) => entry.id === meshId);
|
|
34
|
+
if (!row) return null;
|
|
35
|
+
const parent = row.parent === undefined ? new THREE.Matrix4().toArray() : drawn.find((entry) => entry.id === row.parent).matrix;
|
|
36
|
+
return { meshId, parent, position: [...row.position], rotation: [...row.rotation], size: [...row.size] };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A local transform (position, rotation in degrees XYZ, scale) as a matrix. */
|
|
40
|
+
function localMatrix({ position, rotation, scale = [1, 1, 1] }) {
|
|
41
|
+
const quaternion = new THREE.Quaternion().setFromEuler(new THREE.Euler(rotation[0] * DEG, rotation[1] * DEG, rotation[2] * DEG, "XYZ"));
|
|
42
|
+
return new THREE.Matrix4().compose(new THREE.Vector3(...position), quaternion, new THREE.Vector3(...scale));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The world matrix of the frame a part's values are written in: objectWorld × parent. */
|
|
46
|
+
export function partParentWorld(objectWorld, frame) {
|
|
47
|
+
return objectWorld.clone().multiply(new THREE.Matrix4().fromArray(frame.parent));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Parent space → world: the matrix a part with `local` values draws at. */
|
|
51
|
+
export function partWorldMatrix(objectWorld, frame, local = frame) {
|
|
52
|
+
return partParentWorld(objectWorld, frame).multiply(localMatrix(local));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** World → parent space: the position, rotation (deg) and scale `world` has in the part's frame. */
|
|
56
|
+
export function partLocalFromWorld(objectWorld, frame, world) {
|
|
57
|
+
const local = partParentWorld(objectWorld, frame).invert().multiply(world);
|
|
58
|
+
const position = new THREE.Vector3(), quaternion = new THREE.Quaternion(), scale = new THREE.Vector3();
|
|
59
|
+
local.decompose(position, quaternion, scale);
|
|
60
|
+
return partLocalOf({ position, quaternion, scale });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** An Object3D-like transform (position, quaternion, scale) as recipe-style local values. */
|
|
64
|
+
export function partLocalOf({ position, quaternion, scale }) {
|
|
65
|
+
const euler = new THREE.Euler().setFromQuaternion(quaternion, "XYZ");
|
|
66
|
+
return { position: position.toArray(), rotation: [euler.x / DEG, euler.y / DEG, euler.z / DEG], scale: scale.toArray() };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Shift's uniform scale: every axis takes the factor of the axis that moved furthest from 1. */
|
|
70
|
+
export function uniformScale(scale) {
|
|
71
|
+
const lead = scale.reduce((best, value) => (Math.abs(value - 1) > Math.abs(best - 1) ? value : best), 1);
|
|
72
|
+
return [lead, lead, lead];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The one `object.sculpt` edit a finished drag writes: `{ set: partId,
|
|
77
|
+
* position?, rotation?, size? }` with only the fields the drag changed, or
|
|
78
|
+
* null when it changed nothing. `local` is the gizmo's result in the frame
|
|
79
|
+
* sculptPartFrame gave (scale multiplies the size). A mirrored selection
|
|
80
|
+
* writes the reflected values onto the part the recipe holds, so the pair
|
|
81
|
+
* moves together. Nothing is clamped: an undrawable size goes to the command,
|
|
82
|
+
* which refuses it with the reason.
|
|
83
|
+
*/
|
|
84
|
+
export function sculptPartEdit(recipe, selected, local) {
|
|
85
|
+
const frame = sculptPartFrame(recipe, selected);
|
|
86
|
+
if (!frame) return null;
|
|
87
|
+
const moved = selected.mirrored ? reflect(local) : local;
|
|
88
|
+
const authored = selected.mirrored ? reflect(frame) : frame;
|
|
89
|
+
const edit = { set: selected.partId };
|
|
90
|
+
const position = moved.position.map(tidy);
|
|
91
|
+
if (position.some((value, axis) => Math.abs(value - authored.position[axis]) >= SAME)) edit.position = position;
|
|
92
|
+
const rotation = moved.rotation.map((deg) => tidy(wrap(deg)));
|
|
93
|
+
if (rotation.some((value, axis) => angleGap(value, authored.rotation[axis]) >= SAME)) edit.rotation = rotation;
|
|
94
|
+
const size = frame.size.map((value, axis) => tidy(value * (local.scale?.[axis] ?? 1)));
|
|
95
|
+
if (size.some((value, axis) => Math.abs(value - frame.size[axis]) >= SAME)) edit.size = size;
|
|
96
|
+
return Object.keys(edit).length > 1 ? edit : null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The drag's live preview without rebuilding geometry: every drawn part's
|
|
101
|
+
* object-frame matrix after `edit`, and how much the edited part's size
|
|
102
|
+
* grew per axis (its meshes scale by that until the rebuilt sculpt lands).
|
|
103
|
+
* `Map(meshId → { matrix, ratio })`.
|
|
104
|
+
*/
|
|
105
|
+
export function previewSculptParts(recipe, edit) {
|
|
106
|
+
const parts = recipe.parts.map((part) => (part.id === edit?.set ? { ...part, ...edit } : part));
|
|
107
|
+
const sizes = new Map(recipe.parts.map((part) => [part.id, part.size]));
|
|
108
|
+
const out = new Map();
|
|
109
|
+
for (const row of expandSculptParts({ ...recipe, parts })) {
|
|
110
|
+
const source = row.id.endsWith(SCULPT_MIRROR_SUFFIX) ? row.id.slice(0, -SCULPT_MIRROR_SUFFIX.length) : row.id;
|
|
111
|
+
const before = sizes.get(source) ?? row.size;
|
|
112
|
+
out.set(row.id, { matrix: row.matrix, ratio: row.size.map((value, axis) => (before[axis] ? value / before[axis] : 1)) });
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|