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
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// The selected sculpt part's gizmo on the turntable (#740 S8). three.js
|
|
2
|
+
// TransformControls drives a proxy that sits in the part's PARENT frame, so
|
|
3
|
+
// its position, rotation and scale read straight back as the recipe's values
|
|
4
|
+
// (sculpt-part-edit.js). While a drag runs, the meshes follow a preview (no
|
|
5
|
+
// geometry rebuild); the drag's end writes one object.sculpt edit, one undo.
|
|
6
|
+
// A refused edit puts the meshes back and the reason shows under the tree.
|
|
7
|
+
// The gizmo lives on SCULPT_LAYER alone: the shot preview and exports never
|
|
8
|
+
// see it, and its picks use the main pane's rect, as part picking does.
|
|
9
|
+
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
10
|
+
import { useFrame, useThree } from "@react-three/fiber";
|
|
11
|
+
import * as THREE from "three";
|
|
12
|
+
import { TransformControls } from "three/examples/jsm/controls/TransformControls.js";
|
|
13
|
+
import { SCULPT_LAYER } from "./sculpt-mode.js";
|
|
14
|
+
import { partLocalOf, partParentWorld, previewSculptParts, sculptPartEdit, sculptPartFrame, uniformScale } from "./sculpt-part-edit.js";
|
|
15
|
+
|
|
16
|
+
const MODES = { move: "translate", rotate: "rotate", scale: "scale" };
|
|
17
|
+
const DEG = Math.PI / 180;
|
|
18
|
+
|
|
19
|
+
function partMeshes(nodes) {
|
|
20
|
+
const meshes = [];
|
|
21
|
+
for (const node of nodes) node.traverse((child) => { if (child.isMesh && child.userData?.sculptPart) meshes.push(child); });
|
|
22
|
+
return meshes;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function SculptPartGizmo({ camera, orbitRef, nodesRef, gizmoRef, recipe, parts }) {
|
|
26
|
+
const { gl, scene, invalidate } = useThree();
|
|
27
|
+
const selected = parts?.selected ?? null;
|
|
28
|
+
const frame = useMemo(() => sculptPartFrame(recipe, selected), [recipe, selected]);
|
|
29
|
+
const [rig] = useState(() => {
|
|
30
|
+
const base = new THREE.Object3D();
|
|
31
|
+
base.matrixAutoUpdate = false;
|
|
32
|
+
const proxy = new THREE.Object3D();
|
|
33
|
+
base.add(proxy);
|
|
34
|
+
return { base, proxy };
|
|
35
|
+
});
|
|
36
|
+
const live = useRef(null);
|
|
37
|
+
useLayoutEffect(() => { live.current = { recipe, selected, commit: parts?.commit }; });
|
|
38
|
+
// { meshes: [[mesh, position, quaternion, scale]] } while a drag runs; { held } after a write until its recipe lands.
|
|
39
|
+
const dragRef = useRef(null);
|
|
40
|
+
// The controls' restore, for the selection effect: a selection change mid-drag (Esc) puts the meshes back first.
|
|
41
|
+
const restoreRef = useRef(() => {});
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (!camera) return undefined;
|
|
45
|
+
const made = new TransformControls(camera, gl.domElement);
|
|
46
|
+
const canvas = gl.domElement, orbit = orbitRef;
|
|
47
|
+
// The sculpt camera spans the main pane, not the whole canvas.
|
|
48
|
+
made._getPointer = (event) => {
|
|
49
|
+
const pane = (document.querySelector(".vp-pane.vp-main") ?? canvas).getBoundingClientRect();
|
|
50
|
+
return { x: ((event.clientX - pane.left) / pane.width) * 2 - 1, y: -((event.clientY - pane.top) / pane.height) * 2 + 1, button: event.button };
|
|
51
|
+
};
|
|
52
|
+
const ray = made.getRaycaster();
|
|
53
|
+
const mask = ray.layers.mask;
|
|
54
|
+
ray.layers.set(SCULPT_LAYER);
|
|
55
|
+
const helper = made.getHelper();
|
|
56
|
+
helper.traverse((node) => node.layers.set(SCULPT_LAYER));
|
|
57
|
+
scene.add(rig.base, helper);
|
|
58
|
+
let shift = false;
|
|
59
|
+
const restore = () => {
|
|
60
|
+
for (const [mesh, position, quaternion, scale] of dragRef.current?.meshes ?? []) {
|
|
61
|
+
mesh.position.copy(position);
|
|
62
|
+
mesh.quaternion.copy(quaternion);
|
|
63
|
+
mesh.scale.copy(scale);
|
|
64
|
+
}
|
|
65
|
+
dragRef.current = null;
|
|
66
|
+
invalidate();
|
|
67
|
+
};
|
|
68
|
+
restoreRef.current = restore;
|
|
69
|
+
const editNow = () => {
|
|
70
|
+
const { recipe: current, selected: part } = live.current;
|
|
71
|
+
if (made.mode === "scale" && shift) rig.proxy.scale.fromArray(uniformScale(rig.proxy.scale.toArray()));
|
|
72
|
+
return sculptPartEdit(current, part, partLocalOf(rig.proxy));
|
|
73
|
+
};
|
|
74
|
+
const onDown = () => {
|
|
75
|
+
dragRef.current = { meshes: partMeshes(nodesRef.current).map((mesh) => [mesh, mesh.position.clone(), mesh.quaternion.clone(), mesh.scale.clone()]) };
|
|
76
|
+
};
|
|
77
|
+
const onChange = () => {
|
|
78
|
+
if (!dragRef.current?.meshes) return;
|
|
79
|
+
const preview = previewSculptParts(live.current.recipe, editNow());
|
|
80
|
+
const matrix = new THREE.Matrix4();
|
|
81
|
+
for (const [mesh] of dragRef.current.meshes) {
|
|
82
|
+
const entry = preview.get(mesh.userData.sculptPart);
|
|
83
|
+
if (!entry) continue;
|
|
84
|
+
matrix.fromArray(entry.matrix).decompose(mesh.position, mesh.quaternion, mesh.scale);
|
|
85
|
+
mesh.scale.fromArray(entry.ratio);
|
|
86
|
+
}
|
|
87
|
+
invalidate();
|
|
88
|
+
};
|
|
89
|
+
const onUp = () => {
|
|
90
|
+
if (!dragRef.current?.meshes) return;
|
|
91
|
+
const edit = editNow();
|
|
92
|
+
if (edit && live.current.commit?.(edit)) dragRef.current = { held: true };
|
|
93
|
+
else restore();
|
|
94
|
+
};
|
|
95
|
+
const onDragging = (event) => {
|
|
96
|
+
if (orbitRef.current) orbitRef.current.enabled = !event.value;
|
|
97
|
+
invalidate();
|
|
98
|
+
};
|
|
99
|
+
// A press on a handle must not also start an orbit: the orbit is off before its own listener runs.
|
|
100
|
+
const onPress = () => { if (made.axis !== null && orbitRef.current) orbitRef.current.enabled = false; };
|
|
101
|
+
const onRelease = () => { if (!made.dragging && orbitRef.current) orbitRef.current.enabled = true; };
|
|
102
|
+
const onShift = (event) => { shift = event.shiftKey; };
|
|
103
|
+
made.addEventListener("mouseDown", onDown);
|
|
104
|
+
made.addEventListener("objectChange", onChange);
|
|
105
|
+
made.addEventListener("mouseUp", onUp);
|
|
106
|
+
made.addEventListener("dragging-changed", onDragging);
|
|
107
|
+
made.addEventListener("change", invalidate);
|
|
108
|
+
canvas.addEventListener("pointerdown", onPress, true);
|
|
109
|
+
window.addEventListener("pointerup", onRelease);
|
|
110
|
+
window.addEventListener("pointermove", onShift, true);
|
|
111
|
+
window.addEventListener("keydown", onShift, true);
|
|
112
|
+
window.addEventListener("keyup", onShift, true);
|
|
113
|
+
gizmoRef.current = made;
|
|
114
|
+
return () => {
|
|
115
|
+
if (dragRef.current?.meshes) restore();
|
|
116
|
+
canvas.removeEventListener("pointerdown", onPress, true);
|
|
117
|
+
window.removeEventListener("pointerup", onRelease);
|
|
118
|
+
window.removeEventListener("pointermove", onShift, true);
|
|
119
|
+
window.removeEventListener("keydown", onShift, true);
|
|
120
|
+
window.removeEventListener("keyup", onShift, true);
|
|
121
|
+
if (orbit.current) orbit.current.enabled = true;
|
|
122
|
+
gizmoRef.current = null;
|
|
123
|
+
made.detach();
|
|
124
|
+
made.dispose();
|
|
125
|
+
scene.remove(rig.base, helper);
|
|
126
|
+
ray.layers.mask = mask;
|
|
127
|
+
};
|
|
128
|
+
}, [camera, gl, scene, rig, nodesRef, orbitRef, gizmoRef, invalidate]);
|
|
129
|
+
|
|
130
|
+
// Declared after the controls' own effect, so they exist when these run.
|
|
131
|
+
useEffect(() => { gizmoRef.current?.setMode(MODES[parts?.tool] ?? "translate"); invalidate(); }, [camera, gizmoRef, parts?.tool, invalidate]);
|
|
132
|
+
// A new recipe (the write landed, or any other edit) or selection ends the hold.
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
const controls = gizmoRef.current;
|
|
135
|
+
if (!controls) return;
|
|
136
|
+
if (dragRef.current?.held) dragRef.current = null;
|
|
137
|
+
// A drag the selection change interrupted (Esc before the release) writes nothing and puts the meshes back.
|
|
138
|
+
if (dragRef.current?.meshes) { restoreRef.current(); if (orbitRef.current) orbitRef.current.enabled = true; }
|
|
139
|
+
if (frame) controls.attach(rig.proxy);
|
|
140
|
+
else controls.detach();
|
|
141
|
+
invalidate();
|
|
142
|
+
}, [camera, gizmoRef, frame, rig, orbitRef, invalidate]);
|
|
143
|
+
|
|
144
|
+
// Between drags the proxy follows the part where it is drawn now.
|
|
145
|
+
useFrame(() => {
|
|
146
|
+
const controls = gizmoRef.current;
|
|
147
|
+
if (!controls || !frame || controls.dragging || dragRef.current) return;
|
|
148
|
+
const mesh = partMeshes(nodesRef.current).find((node) => node.userData.sculptPart === frame.meshId);
|
|
149
|
+
if (!mesh?.parent) return;
|
|
150
|
+
mesh.parent.updateWorldMatrix(true, false);
|
|
151
|
+
rig.base.matrix.copy(partParentWorld(mesh.parent.matrixWorld, frame));
|
|
152
|
+
rig.base.updateMatrixWorld(true);
|
|
153
|
+
rig.proxy.position.fromArray(frame.position);
|
|
154
|
+
rig.proxy.rotation.set(frame.rotation[0] * DEG, frame.rotation[1] * DEG, frame.rotation[2] * DEG, "XYZ");
|
|
155
|
+
rig.proxy.scale.set(1, 1, 1);
|
|
156
|
+
rig.proxy.updateMatrixWorld(true);
|
|
157
|
+
});
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// The selected sculpt part's highlight on the turntable (#740 S7): per part
|
|
2
|
+
// mesh, an inverted hull pushed out along the normals and drawn back-faces
|
|
3
|
+
// only (a rim of the selection colour), and a thin see-through tint over its
|
|
4
|
+
// visible surface, which still reads where the part sinks into another. The
|
|
5
|
+
// shells live on SCULPT_LAYER alone: the sculpt camera sees them, the shot
|
|
6
|
+
// preview and every export never do.
|
|
7
|
+
import { useEffect, useState } from "react";
|
|
8
|
+
import { useFrame, useThree } from "@react-three/fiber";
|
|
9
|
+
import * as THREE from "three";
|
|
10
|
+
import { SCULPT_LAYER } from "./sculpt-mode.js";
|
|
11
|
+
import { sculptPartMeshIds } from "./sculpt-pick.js";
|
|
12
|
+
|
|
13
|
+
// The panel's selection colour (--select), so the part and its tree row match;
|
|
14
|
+
// the dark theme's value stands in until the stylesheet is read.
|
|
15
|
+
const OUTLINE_COLOR = "#e8a33d";
|
|
16
|
+
const HEX_COLOR = /^#[0-9a-f]{6}$/i;
|
|
17
|
+
const RIM_WIDTH = 0.02;
|
|
18
|
+
const TINT_WIDTH = 0.002;
|
|
19
|
+
|
|
20
|
+
function inflated(width, options) {
|
|
21
|
+
const material = new THREE.MeshBasicMaterial({ color: OUTLINE_COLOR, ...options });
|
|
22
|
+
material.onBeforeCompile = (shader) => {
|
|
23
|
+
shader.vertexShader = shader.vertexShader.replace("#include <begin_vertex>", `#include <begin_vertex>\n\ttransformed += normal * ${width};`);
|
|
24
|
+
};
|
|
25
|
+
return material;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function highlightMaterials() {
|
|
29
|
+
return [
|
|
30
|
+
inflated(RIM_WIDTH, { side: THREE.BackSide }),
|
|
31
|
+
inflated(TINT_WIDTH, { transparent: true, opacity: 0.3, depthWrite: false }),
|
|
32
|
+
];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function shellFor(source, material) {
|
|
36
|
+
const shell = source.isInstancedMesh ? new THREE.InstancedMesh(source.geometry, material, source.count) : new THREE.Mesh(source.geometry, material);
|
|
37
|
+
if (source.isInstancedMesh) shell.instanceMatrix = source.instanceMatrix;
|
|
38
|
+
shell.userData = { source, noInk: true };
|
|
39
|
+
shell.matrixAutoUpdate = false;
|
|
40
|
+
shell.frustumCulled = false;
|
|
41
|
+
// Never a pick target: a click goes through the rim to the part.
|
|
42
|
+
shell.raycast = () => {};
|
|
43
|
+
shell.layers.set(SCULPT_LAYER);
|
|
44
|
+
return shell;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function SculptPartOutline({ nodesRef, selected }) {
|
|
48
|
+
const invalidate = useThree((state) => state.invalidate);
|
|
49
|
+
const [group] = useState(() => new THREE.Group());
|
|
50
|
+
const [materials] = useState(highlightMaterials);
|
|
51
|
+
useEffect(() => () => { for (const material of materials) material.dispose(); }, [materials]);
|
|
52
|
+
const ids = sculptPartMeshIds(selected);
|
|
53
|
+
const key = ids.join(" ");
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
const css = getComputedStyle(document.documentElement).getPropertyValue("--select").trim();
|
|
56
|
+
for (const material of materials) material.color.set(HEX_COLOR.test(css) ? css : OUTLINE_COLOR);
|
|
57
|
+
invalidate();
|
|
58
|
+
}, [key, materials, invalidate]);
|
|
59
|
+
// The sculpt rebuilds its meshes when the recipe changes, so the shells
|
|
60
|
+
// follow whichever meshes carry the selected ids this frame.
|
|
61
|
+
useFrame(() => {
|
|
62
|
+
const sources = [];
|
|
63
|
+
for (const node of nodesRef.current) node.traverse((child) => { if (child.isMesh && ids.includes(child.userData?.sculptPart)) sources.push(child); });
|
|
64
|
+
const shells = group.children;
|
|
65
|
+
if (shells.length !== sources.length * materials.length || shells.some((shell, index) => shell.userData.source !== sources[Math.floor(index / materials.length)])) {
|
|
66
|
+
group.clear();
|
|
67
|
+
for (const source of sources) for (const material of materials) group.add(shellFor(source, material));
|
|
68
|
+
}
|
|
69
|
+
for (const shell of group.children) shell.matrix.copy(shell.userData.source.matrixWorld);
|
|
70
|
+
});
|
|
71
|
+
return <primitive object={group} />;
|
|
72
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Part picking in Sculpt mode (#740 S7). Pure: no React, no DOM. A sculpt's
|
|
2
|
+
// meshes carry their part id as `userData.sculptPart` (sculpt-geometry.js); a
|
|
3
|
+
// mirrored copy's id ends in SCULPT_MIRROR_SUFFIX and resolves to the part the
|
|
4
|
+
// recipe actually holds, because that is the part an edit writes.
|
|
5
|
+
import { SCULPT_MIRROR_SUFFIX } from "./sculpt-recipe.js";
|
|
6
|
+
|
|
7
|
+
/** `{ partId, mirrored }` for a mesh's part id, or null when it has none. */
|
|
8
|
+
function resolveSculptPart(name) {
|
|
9
|
+
if (typeof name !== "string" || !name) return null;
|
|
10
|
+
const mirrored = name.endsWith(SCULPT_MIRROR_SUFFIX);
|
|
11
|
+
return { partId: mirrored ? name.slice(0, -SCULPT_MIRROR_SUFFIX.length) : name, mirrored };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The part nearest along the raycaster's ray among `roots` and their
|
|
16
|
+
* descendants, or null on a miss. Only meshes tagged as sculpt parts count:
|
|
17
|
+
* a hit on anything else (the turntable, an outline shell) is passed over.
|
|
18
|
+
*/
|
|
19
|
+
export function pickSculptPart(raycaster, roots) {
|
|
20
|
+
const hits = raycaster.intersectObjects(roots, true);
|
|
21
|
+
const hit = hits.find((entry) => entry.object.userData?.sculptPart);
|
|
22
|
+
return hit ? resolveSculptPart(hit.object.userData.sculptPart) : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The recipe's authored parts as an indented tree, parents before children
|
|
27
|
+
* in recipe order: `{ id, shape, depth, parent, mirror }`. `mirror` is true
|
|
28
|
+
* when the part is drawn twice, by its own flag or a mirrored ancestor's.
|
|
29
|
+
*/
|
|
30
|
+
export function sculptPartTree(recipe) {
|
|
31
|
+
const parts = Array.isArray(recipe?.parts) ? recipe.parts : [];
|
|
32
|
+
const rows = [];
|
|
33
|
+
const visit = (parent, depth, mirrored) => {
|
|
34
|
+
for (const part of parts) {
|
|
35
|
+
if ((part.parent ?? null) !== parent) continue;
|
|
36
|
+
const mirror = mirrored || part.mirror === true;
|
|
37
|
+
rows.push({ id: part.id, shape: part.shape, depth, parent, mirror, cutter: Boolean(part.op) });
|
|
38
|
+
visit(part.id, depth + 1, mirror);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
visit(null, 0, false);
|
|
42
|
+
return rows;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The mesh ids a selected part draws as: the part and its mirrored copy. */
|
|
46
|
+
export function sculptPartMeshIds(selected) {
|
|
47
|
+
return selected ? [selected.partId, `${selected.partId}${SCULPT_MIRROR_SUFFIX}`] : [];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** An open menu or modal dialog owns Escape: the part selection waits until it has closed. */
|
|
51
|
+
export function overlayOwnsEscape(root = globalThis.document) {
|
|
52
|
+
return Boolean(root?.querySelector?.('[role="menu"], [role="dialog"][aria-modal="true"], dialog[open]'));
|
|
53
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// The text of a sculpt run's turns (sculpt-run.js): the first turn's draft
|
|
2
|
+
// request, each refinement round's request, and the recipe rules both carry.
|
|
3
|
+
|
|
4
|
+
// The reference as sculpt.compare takes it: the stored asset when there is one.
|
|
5
|
+
export const referenceArg = reference => (reference?.assetId ? { assetId: reference.assetId } : { dataUrl: reference?.dataUrl });
|
|
6
|
+
|
|
7
|
+
// The recipe rules the agent needs to get object.sculpt right the first time (sculpt-recipe.js).
|
|
8
|
+
const RECIPE_RULES = "Recipe: { \"version\": 1, \"parts\": [{ \"id\", \"shape\", \"size\": [x,y,z] metres, \"position\": [x,y,z] centre, \"rotation\": [x,y,z] degrees, \"color\": \"#rrggbb\", \"parent\"?, \"mirror\"?, \"roundness\"? 0-1, \"taper\"? }] }. id: lowercase a-z, 0-9 and -, starting with a letter or digit, at most 40 characters. shape is exactly one of blob, box, cylinder, torus, frame, lathe, tube, extrude, loft, convex, scatter (no cone, sphere or other names): lathe { \"profile\": [[r 0-1, y 0-1], ...] } for turned forms (pot, bottle, bowl), tube { \"path\": [[x,y,z] each -0.5..0.5 of size], \"thickness\" m } for ropes, tails, handles and limbs that bend, extrude { \"outline\": [[x,y] -0.5..0.5] } for flat cut shapes (ears, fins, leaves, signs), loft { \"sections\": [{ \"y\", \"w\", \"d\" }, ...] } for bodies whose cross-section changes, convex { \"seed\" } for rocks. Any part may add \"detail\": { \"amount\": 0-1 } for a lumpy surface, \"gradient\": { \"color\" } for a two-tone blend, or \"op\": \"subtract\" to cut its parent (a mouth, a hollow). Up to 48 parts after mirrors. Floor is y=0 (feet touch it), front is +z, the subject's left is +x, mirror: true adds the reflection across x=0. roundness works backwards from its name: 0 is a smooth ellipsoid and 1 is a rounded box. roundness belongs only on blob, box and extrude, taper only on blob, cylinder and tube: leave both off the other shapes. Keep roundness at 0.2 or below on every organic part (body, belly, head, face, limbs); go above 0.3 only for hard-edged objects. Use box only for hard-edged things.";
|
|
9
|
+
|
|
10
|
+
// A compact score for the next turn: what is missing where, and which parts stick out.
|
|
11
|
+
function reportText(report) {
|
|
12
|
+
if (!report) return "";
|
|
13
|
+
const extra = Array.isArray(report.extraByPart) ? report.extraByPart.slice(0, 4) : report.extraByPart;
|
|
14
|
+
return ` Score detail: ${JSON.stringify({ widthToHeight: report.widthToHeight, missingByBand: report.missingByBand, extraByPart: extra })}.`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function outlineText(outline) {
|
|
18
|
+
if (!outline) return "";
|
|
19
|
+
return ` The harness measured the reference outline in pixels (front view; x is a fraction of the subject's height from its centre, + toward image right = the subject's own left; y is a fraction of height from the head top; spans are the filled runs across each slice): ${JSON.stringify(outline)}. Put parts on these measurements.`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The blueprint views attached after the reference (#970), named the way sculpt.compare reads yaw.
|
|
23
|
+
const VIEW_WORDS = { side: "camera on the subject's own left, so it faces image left", back: "camera behind the subject" };
|
|
24
|
+
function viewsText(views) {
|
|
25
|
+
if (!views?.length) return "";
|
|
26
|
+
return ` Also attached, after the reference: the blueprint's ${views.map(view => `${view.view} view (yaw ${view.yaw}, ${VIEW_WORDS[view.view] ?? "orthographic"})`).join(" and ")}. Take depth and what the front hides from them; the harness scores against the front and these views together, by weight.`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function makeTurnText(reference, sculptId, continuation, outline = null, views = []) {
|
|
30
|
+
const ref = JSON.stringify(referenceArg(reference));
|
|
31
|
+
if (continuation) {
|
|
32
|
+
return [
|
|
33
|
+
`Round ${continuation.round} of ${continuation.maxRounds}. The harness scored sculpt ${sculptId} at IoU ${continuation.iou ?? "unknown"} against the reference${views.length ? ` and the blueprint's ${views.map(view => view.view).join(" and ")} views (weighted)` : ""}.${reportText(continuation.report)}`,
|
|
34
|
+
`Write 3-6 candidate edit sets that fix the biggest mismatch (proportion first, then placement), rank them with ONE run_action sculpt.compare { "id": "${sculptId}", "reference": ${ref}, "candidates": [{ "label", "edits" }] },`,
|
|
35
|
+
`apply the best-ranked set with ONE run_action object.sculpt { "id": "${sculptId}", "edits": [...] }, then end the turn with one short line. Do not inspect first; the harness scores the result after your turn.`,
|
|
36
|
+
RECIPE_RULES,
|
|
37
|
+
].join(" ");
|
|
38
|
+
}
|
|
39
|
+
return [
|
|
40
|
+
`Sculpt the subject of the attached reference image as a clay recipe.${viewsText(views)}`,
|
|
41
|
+
outline
|
|
42
|
+
? `Use the measured outline below, scaled to a real-world height in metres.${outlineText(outline)}`
|
|
43
|
+
: "First measure the visible outline in the image: the subject's bounding box, then where the head top, each limb tip and the feet sit as fractions of its height, and convert them to metres for a real-world height.",
|
|
44
|
+
sculptId
|
|
45
|
+
? `Then rewrite sculpt ${sculptId} with ONE run_action object.sculpt { "id": "${sculptId}", "recipe": ... }, keeping parts on the measured landmarks.`
|
|
46
|
+
: "Then create it with ONE run_action object.sculpt { \"name\", \"recipe\" }, keeping parts on the measured landmarks.",
|
|
47
|
+
"No inspect calls are needed and do not call sculpt.compare: the harness scores the sculpt after your turn and tells you the result.",
|
|
48
|
+
"End the turn with one short line naming what the image hides.",
|
|
49
|
+
RECIPE_RULES,
|
|
50
|
+
].join(" ");
|
|
51
|
+
}
|
package/src/sculpt-run.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { makeTurnText, referenceArg } from "./sculpt-run-text.js";
|
|
2
|
+
|
|
1
3
|
const INITIAL_MESSAGE = "Measure the reference landmarks before placing parts.";
|
|
2
4
|
|
|
3
5
|
const initialState = ({ targetIou, maxRounds }) => ({
|
|
@@ -45,48 +47,6 @@ function dataUrlFromSheet(sheet) {
|
|
|
45
47
|
return null;
|
|
46
48
|
}
|
|
47
49
|
|
|
48
|
-
// The reference as sculpt.compare takes it: the stored asset when there is one.
|
|
49
|
-
const referenceArg = reference => (reference?.assetId ? { assetId: reference.assetId } : { dataUrl: reference?.dataUrl });
|
|
50
|
-
|
|
51
|
-
// The recipe rules the agent needs to get object.sculpt right the first time (sculpt-recipe.js).
|
|
52
|
-
const RECIPE_RULES = "Recipe: { \"version\": 1, \"parts\": [{ \"id\", \"shape\", \"size\": [x,y,z] metres, \"position\": [x,y,z] centre, \"rotation\": [x,y,z] degrees, \"color\": \"#rrggbb\", \"parent\"?, \"mirror\"?, \"roundness\"? 0-1, \"taper\"? }] }. id: lowercase a-z, 0-9 and -, starting with a letter or digit, at most 40 characters. shape is exactly one of blob, box, cylinder, torus, frame, lathe, tube, extrude, loft, convex, scatter (no cone, sphere or other names): lathe { \"profile\": [[r 0-1, y 0-1], ...] } for turned forms (pot, bottle, bowl), tube { \"path\": [[x,y,z] each -0.5..0.5 of size], \"thickness\" m } for ropes, tails, handles and limbs that bend, extrude { \"outline\": [[x,y] -0.5..0.5] } for flat cut shapes (ears, fins, leaves, signs), loft { \"sections\": [{ \"y\", \"w\", \"d\" }, ...] } for bodies whose cross-section changes, convex { \"seed\" } for rocks. Any part may add \"detail\": { \"amount\": 0-1 } for a lumpy surface, \"gradient\": { \"color\" } for a two-tone blend, or \"op\": \"subtract\" to cut its parent (a mouth, a hollow). Up to 48 parts after mirrors. Floor is y=0 (feet touch it), front is +z, the subject's left is +x, mirror: true adds the reflection across x=0. roundness works backwards from its name: 0 is a smooth ellipsoid and 1 is a rounded box. roundness belongs only on blob, box and extrude, taper only on blob, cylinder and tube: leave both off the other shapes. Keep roundness at 0.2 or below on every organic part (body, belly, head, face, limbs); go above 0.3 only for hard-edged objects. Use box only for hard-edged things.";
|
|
53
|
-
|
|
54
|
-
// A compact score for the next turn: what is missing where, and which parts stick out.
|
|
55
|
-
function reportText(report) {
|
|
56
|
-
if (!report) return "";
|
|
57
|
-
const extra = Array.isArray(report.extraByPart) ? report.extraByPart.slice(0, 4) : report.extraByPart;
|
|
58
|
-
return ` Score detail: ${JSON.stringify({ widthToHeight: report.widthToHeight, missingByBand: report.missingByBand, extraByPart: extra })}.`;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function outlineText(outline) {
|
|
62
|
-
if (!outline) return "";
|
|
63
|
-
return ` The harness measured the reference outline in pixels (front view; x is a fraction of the subject's height from its centre, + toward image right = the subject's own left; y is a fraction of height from the head top; spans are the filled runs across each slice): ${JSON.stringify(outline)}. Put parts on these measurements.`;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function makeTurnText(reference, sculptId, continuation, outline = null) {
|
|
67
|
-
const ref = JSON.stringify(referenceArg(reference));
|
|
68
|
-
if (continuation) {
|
|
69
|
-
return [
|
|
70
|
-
`Round ${continuation.round} of ${continuation.maxRounds}. The harness scored sculpt ${sculptId} at IoU ${continuation.iou ?? "unknown"} against the reference.${reportText(continuation.report)}`,
|
|
71
|
-
`Write 3-6 candidate edit sets that fix the biggest mismatch (proportion first, then placement), rank them with ONE run_action sculpt.compare { "id": "${sculptId}", "reference": ${ref}, "candidates": [{ "label", "edits" }] },`,
|
|
72
|
-
`apply the best-ranked set with ONE run_action object.sculpt { "id": "${sculptId}", "edits": [...] }, then end the turn with one short line. Do not inspect first; the harness scores the result after your turn.`,
|
|
73
|
-
RECIPE_RULES,
|
|
74
|
-
].join(" ");
|
|
75
|
-
}
|
|
76
|
-
return [
|
|
77
|
-
"Sculpt the subject of the attached reference image as a clay recipe.",
|
|
78
|
-
outline
|
|
79
|
-
? `Use the measured outline below, scaled to a real-world height in metres.${outlineText(outline)}`
|
|
80
|
-
: "First measure the visible outline in the image: the subject's bounding box, then where the head top, each limb tip and the feet sit as fractions of its height, and convert them to metres for a real-world height.",
|
|
81
|
-
sculptId
|
|
82
|
-
? `Then rewrite sculpt ${sculptId} with ONE run_action object.sculpt { "id": "${sculptId}", "recipe": ... }, keeping parts on the measured landmarks.`
|
|
83
|
-
: "Then create it with ONE run_action object.sculpt { \"name\", \"recipe\" }, keeping parts on the measured landmarks.",
|
|
84
|
-
"No inspect calls are needed and do not call sculpt.compare: the harness scores the sculpt after your turn and tells you the result.",
|
|
85
|
-
"End the turn with one short line naming what the image hides.",
|
|
86
|
-
RECIPE_RULES,
|
|
87
|
-
].join(" ");
|
|
88
|
-
}
|
|
89
|
-
|
|
90
50
|
/**
|
|
91
51
|
* Adapt the callback-based Studio client into the small port consumed by the
|
|
92
52
|
* sculpt controller. The controller deliberately owns the session/turn loop.
|
|
@@ -131,6 +91,8 @@ export function createSculptRun({
|
|
|
131
91
|
score = null,
|
|
132
92
|
// Optional (reference) -> measureReferenceOutline result, put into the first turn's text.
|
|
133
93
|
measure = null,
|
|
94
|
+
// The blueprint's cropped views ([{ view, yaw, dataUrl, name? }], #970): attached next to the reference and scored with it.
|
|
95
|
+
views = [],
|
|
134
96
|
// The model every turn runs on (a /agent/models key) and its effort; omitted = the sidecar's defaults.
|
|
135
97
|
// Measured on the Snorlax reference: effort "high" made the draft twice as slow for no better score.
|
|
136
98
|
model = undefined,
|
|
@@ -252,7 +214,8 @@ export function createSculptRun({
|
|
|
252
214
|
};
|
|
253
215
|
const turnId = crypto.randomUUID();
|
|
254
216
|
ownTurns.add(turnId);
|
|
255
|
-
const
|
|
217
|
+
const attachments = [...(reference?.dataUrl ? [{ dataUrl: reference.dataUrl, name: reference.name }] : []), ...views.map(view => ({ dataUrl: view.dataUrl, name: view.name ?? `blueprint-${view.view}.png` }))];
|
|
218
|
+
const returned = agent.sendTurn({ turnId, text, attachments, signal: controller.signal, ...(model ? { model } : {}), ...(effort ? { effort } : {}) }, callback);
|
|
256
219
|
if (isAsyncIterable(returned)) {
|
|
257
220
|
for await (const event of returned) callback(event);
|
|
258
221
|
} else await returned;
|
|
@@ -274,7 +237,8 @@ export function createSculptRun({
|
|
|
274
237
|
} catch (error) { note("warn", `Could not measure the reference: ${shortError(error?.message || error)}`, { message: `Could not measure the reference: ${String(error?.message || error)}` }); }
|
|
275
238
|
}
|
|
276
239
|
if (controller.signal.aborted) { note("warn", "Cancelled", { stage: "cancelled", message: "Sculpt run cancelled.", waiting: null }); settled = true; return; }
|
|
277
|
-
|
|
240
|
+
if (views.length) note("ok", `Using the blueprint's ${views.map(view => view.view).join(" and ")} view${views.length > 1 ? "s" : ""} next to the reference`);
|
|
241
|
+
let text = makeTurnText(reference, sculptId, false, outline, views);
|
|
278
242
|
while (!controller.signal.aborted) {
|
|
279
243
|
if (elapsed() >= timeBudgetMs) break;
|
|
280
244
|
const roundBefore = state.round;
|
|
@@ -304,7 +268,7 @@ export function createSculptRun({
|
|
|
304
268
|
}
|
|
305
269
|
retryCount = 0;
|
|
306
270
|
if (!turnScored && score && state.sculptId) {
|
|
307
|
-
const args = { id: state.sculptId, reference: referenceArg(reference) };
|
|
271
|
+
const args = views.length ? { id: state.sculptId, views: [{ yaw: 0, reference: referenceArg(reference) }, ...views.map(view => ({ yaw: view.yaw, reference: { dataUrl: view.dataUrl } }))] } : { id: state.sculptId, reference: referenceArg(reference) };
|
|
308
272
|
publish({ waiting: waitFor("score", "Scoring the silhouette against the reference") });
|
|
309
273
|
try {
|
|
310
274
|
const value = await score(args);
|
|
@@ -319,7 +283,7 @@ export function createSculptRun({
|
|
|
319
283
|
const nextRound = Math.max(1, state.round + 1);
|
|
320
284
|
publish({ stage: "refine", round: nextRound, message: `Preparing refinement round ${nextRound} of ${maxRounds}.` });
|
|
321
285
|
// The next turn's note says what was asked; nothing to log in between.
|
|
322
|
-
text = makeTurnText(reference, state.sculptId, { round: nextRound, maxRounds, iou: state.iou, report: lastReport });
|
|
286
|
+
text = makeTurnText(reference, state.sculptId, { round: nextRound, maxRounds, iou: state.iou, report: lastReport }, null, views);
|
|
323
287
|
}
|
|
324
288
|
if (controller.signal.aborted) {
|
|
325
289
|
note("warn", "Cancelled", { stage: "cancelled", message: "Sculpt run cancelled.", waiting: null });
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// A sculpt card on the asset shelf (#740 S9 + S13), and the `.cozysculpt.json`
|
|
2
|
+
// file that carries one between browsers. Pure: no IndexedDB, no three.js, so
|
|
3
|
+
// scene-assets.js, the node tests and the shelf all read one truth.
|
|
4
|
+
//
|
|
5
|
+
// A card is a record in the shared asset store (`cozyclay.assets`) under a
|
|
6
|
+
// `sculpt-` id. Its bytes ARE the file: the UTF-8 JSON
|
|
7
|
+
// { "format": "cozysculpt", "version": 1, "name", "recipe", "thumb"? }
|
|
8
|
+
// so export writes the stored bytes and import stores what it parsed. The id is
|
|
9
|
+
// the SHA-256 of the normalized recipe, so saving the same sculpt twice keeps
|
|
10
|
+
// one card (the later name and thumbnail win). The store is origin-wide, so a
|
|
11
|
+
// card shows in every project; it is never part of a project export.
|
|
12
|
+
import { normalizeSculptRecipe } from "./sculpt-recipe.js";
|
|
13
|
+
|
|
14
|
+
const SCULPT_CARD_FORMAT = "cozysculpt";
|
|
15
|
+
const SCULPT_CARD_VERSION = 1;
|
|
16
|
+
export const SCULPT_CARD_TYPE = "application/vnd.cozyclay.sculpt+json";
|
|
17
|
+
export const SCULPT_FILE_SUFFIX = ".cozysculpt.json";
|
|
18
|
+
const SCULPT_ID = /^sculpt-[0-9a-f]{32}$/;
|
|
19
|
+
// A card file is a recipe and a 256 px picture; anything far bigger is not one.
|
|
20
|
+
const MAX_FILE_CHARS = 4 * 1024 * 1024;
|
|
21
|
+
const MAX_THUMB_CHARS = 1024 * 1024;
|
|
22
|
+
const THUMB = /^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/]+=*$/;
|
|
23
|
+
const MAX_NAME = 120;
|
|
24
|
+
|
|
25
|
+
export const isSculptAssetId = (value) => typeof value === "string" && SCULPT_ID.test(value);
|
|
26
|
+
|
|
27
|
+
const cleanName = (name) => (typeof name === "string" ? name.trim().slice(0, MAX_NAME) : "");
|
|
28
|
+
const cleanThumb = (thumb) => (typeof thumb === "string" && thumb.length <= MAX_THUMB_CHARS && THUMB.test(thumb) ? thumb : null);
|
|
29
|
+
|
|
30
|
+
/** The `.cozysculpt.json` text of a card. Throws when the recipe is refused. */
|
|
31
|
+
export function serializeCard({ name, recipe, thumb = null }) {
|
|
32
|
+
const normalized = normalizeSculptRecipe(recipe);
|
|
33
|
+
if (!normalized.ok) throw new TypeError(`Recipe refused at ${normalized.error}`);
|
|
34
|
+
const picture = cleanThumb(thumb);
|
|
35
|
+
return JSON.stringify({ format: SCULPT_CARD_FORMAT, version: SCULPT_CARD_VERSION, name: cleanName(name) || "Sculpt", recipe: normalized.recipe, ...(picture ? { thumb: picture } : {}) });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Read a `.cozysculpt.json` text: { ok: true, card: { name, recipe, thumb } }
|
|
40
|
+
* or { ok: false, error } with a sentence for the user. The recipe is checked
|
|
41
|
+
* as a whole, never repaired; a bad thumbnail is dropped, not fatal.
|
|
42
|
+
*/
|
|
43
|
+
export function parseCard(text) {
|
|
44
|
+
if (typeof text !== "string" || !text.trim()) return { ok: false, error: "the file is empty" };
|
|
45
|
+
if (text.length > MAX_FILE_CHARS) return { ok: false, error: "the file is too large to be a sculpt card" };
|
|
46
|
+
let data;
|
|
47
|
+
try {
|
|
48
|
+
data = JSON.parse(text);
|
|
49
|
+
} catch {
|
|
50
|
+
return { ok: false, error: "the file is not JSON" };
|
|
51
|
+
}
|
|
52
|
+
if (!data || typeof data !== "object" || Array.isArray(data) || data.format !== SCULPT_CARD_FORMAT) return { ok: false, error: "the file is not a .cozysculpt.json sculpt card" };
|
|
53
|
+
if (data.version !== SCULPT_CARD_VERSION) return { ok: false, error: `card version ${JSON.stringify(data.version)} is not supported (expected ${SCULPT_CARD_VERSION})` };
|
|
54
|
+
const normalized = normalizeSculptRecipe(data.recipe);
|
|
55
|
+
if (!normalized.ok) return { ok: false, error: `the recipe is refused at ${normalized.error}` };
|
|
56
|
+
return { ok: true, card: { name: cleanName(data.name) || "Sculpt", recipe: normalized.recipe, thumb: cleanThumb(data.thumb) } };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function sha256Hex(text, subtle) {
|
|
60
|
+
if (!subtle?.digest) throw new Error("SubtleCrypto is unavailable — a secure context is required to save a sculpt card");
|
|
61
|
+
const digest = await subtle.digest("SHA-256", new TextEncoder().encode(text));
|
|
62
|
+
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The asset-store record of a card: the file text as bytes, under its recipe's id. */
|
|
66
|
+
export async function sculptCardRecord(card, subtle = globalThis.crypto?.subtle) {
|
|
67
|
+
const text = serializeCard(card);
|
|
68
|
+
const { name, recipe } = JSON.parse(text);
|
|
69
|
+
const id = `sculpt-${(await sha256Hex(JSON.stringify(recipe), subtle)).slice(0, 32)}`;
|
|
70
|
+
const bytes = new TextEncoder().encode(text);
|
|
71
|
+
return { id, type: SCULPT_CARD_TYPE, name, bytes: bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** normalizeAsset's branch for a `sculpt-` id: the bytes must parse as a card. */
|
|
75
|
+
export function normalizeSculptAsset(record, bytes) {
|
|
76
|
+
if (record.type !== SCULPT_CARD_TYPE) return null;
|
|
77
|
+
const parsed = parseCard(new TextDecoder().decode(bytes));
|
|
78
|
+
return parsed.ok ? { id: record.id, type: SCULPT_CARD_TYPE, bytes, name: parsed.card.name } : null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A stored card record as the shelf shows it, or null when it does not read. */
|
|
82
|
+
export function readSculptCard(record) {
|
|
83
|
+
if (!isSculptAssetId(record?.id) || !(record.bytes instanceof ArrayBuffer)) return null;
|
|
84
|
+
const parsed = parseCard(new TextDecoder().decode(record.bytes));
|
|
85
|
+
return parsed.ok ? { id: record.id, ...parsed.card } : null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A file name for the card's export: its name, kept to safe characters. */
|
|
89
|
+
export function sculptCardFileName(name) {
|
|
90
|
+
const stem = cleanName(name).replace(/[^\p{L}\p{N} _-]+/gu, "").trim().replace(/\s+/g, "-") || "sculpt";
|
|
91
|
+
return `${stem}${SCULPT_FILE_SUFFIX}`;
|
|
92
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// The Content pane's Sculpts folder (#740 S9 + S13): one tile per sculpt card
|
|
2
|
+
// on the shelf (sculpt-shelf.js). A tile drags into the scene like any other
|
|
3
|
+
// asset and carries its recipe, so a drop needs no store read. A selected card
|
|
4
|
+
// can be exported as `.cozysculpt.json` or deleted; Import (or dropping card
|
|
5
|
+
// files on the folder) brings cards in.
|
|
6
|
+
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
7
|
+
import { ko } from "./locale.js";
|
|
8
|
+
import { ContentTile, grabProps } from "./content-tile.jsx";
|
|
9
|
+
import { SearchEmpty } from "./search-friction.js";
|
|
10
|
+
import { deleteSculptCard, exportSculptCard, importSculptCardFiles, refreshSculptShelf, sculptShelfCards, subscribeSculptShelf } from "./sculpt-shelf.js";
|
|
11
|
+
import { SCULPT_FILE_SUFFIX } from "./sculpt-shelf-file.js";
|
|
12
|
+
|
|
13
|
+
const sculptKey = (id) => `sculpt:${id}`;
|
|
14
|
+
|
|
15
|
+
export default function SculptShelf({ query, selectedKey, onSelect, onAssetGrab, onAssetPlace }) {
|
|
16
|
+
const cards = useSyncExternalStore(subscribeSculptShelf, sculptShelfCards, sculptShelfCards);
|
|
17
|
+
const [note, setNote] = useState("");
|
|
18
|
+
const [confirmDelete, setConfirmDelete] = useState(null);
|
|
19
|
+
const fileRef = useRef(null);
|
|
20
|
+
useEffect(() => { void refreshSculptShelf(); }, []);
|
|
21
|
+
const selected = cards?.find((card) => sculptKey(card.id) === selectedKey) ?? null;
|
|
22
|
+
const visible = (cards ?? []).filter((card) => !query || card.name.toLowerCase().includes(query));
|
|
23
|
+
const importFiles = async (files) => setNote(await importSculptCardFiles(files));
|
|
24
|
+
const tiles = cards === null
|
|
25
|
+
? [0, 1, 2].map((n) => <span className="content-tile is-loading" key={`loading-${n}`} aria-hidden="true"><span className="content-tile-thumb"><span className="content-tile-skeleton" /></span></span>)
|
|
26
|
+
: visible.map((card) => {
|
|
27
|
+
const key = sculptKey(card.id);
|
|
28
|
+
const payload = { kind: "sculpt", id: card.id, label: card.name, name: card.name, recipe: card.recipe, thumb: card.thumb };
|
|
29
|
+
return (
|
|
30
|
+
<ContentTile key={key} assetKey={key} label={card.name} type={ko("Sculpt", "조형물", "雕塑")}
|
|
31
|
+
title={ko(`Drag ${card.name} into the scene, or double-click to place it at the origin`, `${card.name}을(를) 씬에 드래그하거나 더블클릭해 원점에 놓으세요`, `将 ${card.name} 拖入场景,或双击将其放在原点`)}
|
|
32
|
+
preview={card.thumb ? <img className="content-tile-img" src={card.thumb} alt="" draggable={false} /> : <span className="content-tile-skeleton" aria-hidden="true" />}
|
|
33
|
+
selected={selectedKey === key} onSelect={onSelect} grab={grabProps(onAssetGrab, payload)} onPlace={() => onAssetPlace?.(payload)} />
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
return (
|
|
37
|
+
<div className="sculpt-shelf" data-testid="sculpt-shelf"
|
|
38
|
+
onDragOver={(event) => { if (event.dataTransfer?.types?.includes("Files")) event.preventDefault(); }}
|
|
39
|
+
onDrop={(event) => {
|
|
40
|
+
const files = [...(event.dataTransfer?.files ?? [])].filter((file) => file.name.toLowerCase().endsWith(".json"));
|
|
41
|
+
if (!files.length) return;
|
|
42
|
+
event.preventDefault();
|
|
43
|
+
event.stopPropagation();
|
|
44
|
+
void importFiles(files);
|
|
45
|
+
}}>
|
|
46
|
+
<div className="sculpt-shelf-actions">
|
|
47
|
+
<button type="button" data-testid="sculpt-shelf-import" onClick={() => fileRef.current?.click()}>{ko("Import…", "가져오기…", "导入…")}</button>
|
|
48
|
+
<input ref={fileRef} type="file" hidden multiple accept={`${SCULPT_FILE_SUFFIX},.json,application/json`} data-testid="sculpt-shelf-file"
|
|
49
|
+
onChange={(event) => { const files = [...event.target.files]; event.target.value = ""; void importFiles(files); }} />
|
|
50
|
+
{selected && <>
|
|
51
|
+
<button type="button" data-testid="sculpt-shelf-export" onClick={async () => setNote(await exportSculptCard(selected))}>{ko("Export", "내보내기", "导出")}</button>
|
|
52
|
+
{confirmDelete === selected.id ? <>
|
|
53
|
+
<button type="button" className="sculpt-shelf-delete" data-testid="sculpt-shelf-delete-confirm"
|
|
54
|
+
onClick={async () => { setConfirmDelete(null); onSelect(null); await deleteSculptCard(selected.id); setNote(ko(`Deleted ${selected.name} from the shelf`, `${selected.name} 선반에서 삭제함`, `已从架子删除 ${selected.name}`)); }}>
|
|
55
|
+
{ko(`Delete ${selected.name}?`, `${selected.name} 삭제할까요?`, `删除 ${selected.name}?`)}
|
|
56
|
+
</button>
|
|
57
|
+
<button type="button" onClick={() => setConfirmDelete(null)}>{ko("Cancel", "취소")}</button>
|
|
58
|
+
</> : <button type="button" data-testid="sculpt-shelf-delete" onClick={() => setConfirmDelete(selected.id)}>{ko("Delete", "삭제")}</button>}
|
|
59
|
+
</>}
|
|
60
|
+
{note && <span className="sculpt-shelf-note" role="status" data-testid="sculpt-shelf-note">{note}</span>}
|
|
61
|
+
</div>
|
|
62
|
+
{cards !== null && !cards.length
|
|
63
|
+
? <p className="content-empty">{ko("No sculpts on the shelf yet. Press Save to shelf in Sculpt mode (5), or import a .cozysculpt.json.", "선반에 조형물이 아직 없어요. 조형 모드(5)에서 선반에 저장을 누르거나 .cozysculpt.json을 가져오세요.")}</p>
|
|
64
|
+
: query && !visible.length
|
|
65
|
+
? <SearchEmpty surface="assets" query={query}><p className="content-empty">{ko("Nothing matches this search.", "검색 결과가 없어요.")}</p></SearchEmpty>
|
|
66
|
+
: <div className="content-grid" role="list">{tiles}</div>}
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|