cozyclay 1.0.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.
Files changed (85) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +109 -0
  3. package/THIRD_PARTY_NOTICES.md +29 -0
  4. package/bin/cozyclay.mjs +194 -0
  5. package/dist/ardy/cskel27-rest.json +557 -0
  6. package/dist/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
  7. package/dist/assets/basis_transcoder-o4Hde_L7.js +19 -0
  8. package/dist/assets/draco_decoder-C32yEggz.wasm +0 -0
  9. package/dist/assets/draco_decoder-Z1_iN-Ht.wasm +0 -0
  10. package/dist/assets/draco_decoder-fzg4nYZr.js +34 -0
  11. package/dist/assets/draco_wasm_wrapper-DxJM36Ib.js +117 -0
  12. package/dist/assets/draco_wasm_wrapper-fZCQGLGb.js +116 -0
  13. package/dist/assets/index-BW_S1YRy.js +4395 -0
  14. package/dist/assets/index-nRmmJgap.css +1 -0
  15. package/dist/demo/walk-then-stop.npz +0 -0
  16. package/dist/fonts/instrument-serif-italic-latin.woff2 +0 -0
  17. package/dist/fonts/instrument-serif-latin.woff2 +0 -0
  18. package/dist/fonts/inter-latin.woff2 +0 -0
  19. package/dist/index.html +13 -0
  20. package/dist/models/x-bot-tpose.fbx +0 -0
  21. package/dist/models/y-bot-tpose.fbx +0 -0
  22. package/package.json +80 -0
  23. package/src/App.jsx +3840 -0
  24. package/src/ardy/client.js +140 -0
  25. package/src/ardy/convert.js +313 -0
  26. package/src/ardy/cskel27-neutral.js +39 -0
  27. package/src/ardy/cskel27.js +68 -0
  28. package/src/ardy/export.js +157 -0
  29. package/src/ardy/ik.js +610 -0
  30. package/src/ardy/npz.js +520 -0
  31. package/src/ardy/playback.js +414 -0
  32. package/src/ardy/prompt-clips.js +16 -0
  33. package/src/ardy/timeline-coordinates.js +17 -0
  34. package/src/ardy/timeline-resize.js +11 -0
  35. package/src/ardy/timeline.jsx +723 -0
  36. package/src/ardy/to-cskel27.js +205 -0
  37. package/src/ardy/waypoints.js +364 -0
  38. package/src/camera-follow.js +366 -0
  39. package/src/camera-move.js +286 -0
  40. package/src/controls.jsx +245 -0
  41. package/src/dualview.jsx +305 -0
  42. package/src/hierarchy-model.js +77 -0
  43. package/src/hierarchy-panel.jsx +365 -0
  44. package/src/history.js +90 -0
  45. package/src/main.jsx +10 -0
  46. package/src/object-catalog.jsx +90 -0
  47. package/src/object-gizmo.jsx +755 -0
  48. package/src/planview.jsx +653 -0
  49. package/src/poses.js +424 -0
  50. package/src/posestudio.jsx +811 -0
  51. package/src/props.jsx +302 -0
  52. package/src/room.jsx +64 -0
  53. package/src/scene-history.js +125 -0
  54. package/src/scene-objects.js +420 -0
  55. package/src/shot-authoring.js +113 -0
  56. package/src/shot.js +239 -0
  57. package/src/styles.css +5639 -0
  58. package/src/ui.jsx +391 -0
  59. package/src/use-render-activity.js +96 -0
  60. package/tools/ardy/BRIDGE.md +255 -0
  61. package/tools/ardy/README.md +136 -0
  62. package/tools/ardy/__pycache__/cclay_sequence_generate.cpython-313.pyc +0 -0
  63. package/tools/ardy/bridge.mjs +1427 -0
  64. package/tools/ardy/cclay_motion_edit.py +445 -0
  65. package/tools/ardy/cclay_sequence_generate.py +595 -0
  66. package/tools/ardy/dump-npz.py +205 -0
  67. package/tools/ardy/extract-rest.mjs +299 -0
  68. package/tools/ardy/npz.mjs +335 -0
  69. package/tools/ardy/out/gen-1786443326924-c6019e-generated.npz +0 -0
  70. package/tools/ardy/out/gen-1786443609325-0053db-generated.npz +0 -0
  71. package/tools/ardy/out/gen-1786443835628-749ed1-generated.npz +0 -0
  72. package/tools/ardy/out/gen-1786462605247-03cb19-generated.npz +0 -0
  73. package/tools/ardy/pose-to-npz.mjs +106 -0
  74. package/tools/ardy/run-edit-on-box.sh +73 -0
  75. package/tools/ardy/run-on-box.sh +568 -0
  76. package/tools/ardy/run-sequence-on-box.sh +162 -0
  77. package/tools/ardy/visual-qa.mjs +188 -0
  78. package/tools/ardy/vq-car.mjs +52 -0
  79. package/tools/dev-full.mjs +29 -0
  80. package/tools/process-supervisor.mjs +63 -0
  81. package/tools/qa-browser.mjs +84 -0
  82. package/tools/qa-crop3.mjs +22 -0
  83. package/tools/qa-playview.mjs +49 -0
  84. package/tools/qa-screenshot.mjs +24 -0
  85. package/tools/qa-visual.mjs +51 -0
package/src/props.jsx ADDED
@@ -0,0 +1,302 @@
1
+ /**
2
+ * Set props: low-poly clay maquettes to block against.
3
+ *
4
+ * The set is a bare room and a figure; a shot that needs "something in the
5
+ * world" (a car to lean on, to walk past, to frame behind glass) has nothing
6
+ * to work with. These props are built from primitives in the same clay style
7
+ * as the Room and the character tint, so the whole frame stays one
8
+ * consistent maquette. Dimensions are metres against real vehicle sizes so
9
+ * the 1.8 m figure keeps honest scale.
10
+ */
11
+
12
+ import { useEffect, useMemo } from "react";
13
+ import * as THREE from "three";
14
+ import { GIZMO_LAYER } from "./dualview.jsx";
15
+
16
+ const CLAY_CAR = "#d98770";
17
+ const CLAY_CAR_TOP = "#e49a84";
18
+ const CLAY_TIRE = "#41484c";
19
+ const CLAY_RIM = "#7c8588";
20
+ const CLAY_GLASS = "#55697a";
21
+ const CLAY_PLANE = "#7896a4";
22
+ const CLAY_PLANE_TRIM = "#e1a849";
23
+ const CLAY_CHAIR = "#b9855d";
24
+ const CLAY_CHAIR_LIGHT = "#cf9d72";
25
+
26
+ function Wheel({ position }) {
27
+ return (
28
+ <group position={position}>
29
+ <mesh rotation={[0, 0, Math.PI / 2]}>
30
+ <cylinderGeometry args={[0.33, 0.33, 0.24, 20]} />
31
+ <meshStandardMaterial color={CLAY_TIRE} roughness={0.95} />
32
+ </mesh>
33
+ <mesh rotation={[0, 0, Math.PI / 2]}>
34
+ <cylinderGeometry args={[0.17, 0.17, 0.26, 16]} />
35
+ <meshStandardMaterial color={CLAY_RIM} roughness={0.5} metalness={0.35} />
36
+ </mesh>
37
+ </group>
38
+ );
39
+ }
40
+ /**
41
+ * A generic 5-door-ish sedan silhouette, ~4.5 m long. Origin at the centre
42
+ * of the footprint, +Z forward.
43
+ */
44
+ export function Car({ position = [0, 0, 0], rotY = 0, color = CLAY_CAR, topColor = CLAY_CAR_TOP }) {
45
+ return (
46
+ <group position={position} rotation={[0, rotY, 0]}>
47
+ {/* lower body */}
48
+ <mesh position={[0, 0.62, 0]}>
49
+ <boxGeometry args={[1.78, 0.62, 4.45]} />
50
+ <meshStandardMaterial color={color} roughness={0.55} metalness={0.25} />
51
+ </mesh>
52
+ {/* cabin: centred over the wheelbase, not stacked on the tail */}
53
+ <mesh position={[0, 1.12, -0.15]}>
54
+ <boxGeometry args={[1.58, 0.5, 2.2]} />
55
+ <meshStandardMaterial color={topColor} roughness={0.5} metalness={0.2} />
56
+ </mesh>
57
+ {/* greenhouse glass band */}
58
+ <mesh position={[0, 1.14, -0.15]}>
59
+ <boxGeometry args={[1.62, 0.3, 1.9]} />
60
+ <meshStandardMaterial color={CLAY_GLASS} roughness={0.15} metalness={0.6} />
61
+ </mesh>
62
+ {/* windshield slope */}
63
+ <mesh position={[0, 1.05, 0.95]} rotation={[0.5, 0, 0]}>
64
+ <boxGeometry args={[1.56, 0.42, 0.08]} />
65
+ <meshStandardMaterial color={CLAY_GLASS} roughness={0.15} metalness={0.6} />
66
+ </mesh>
67
+ {/* rear glass slope */}
68
+ <mesh position={[0, 1.05, -1.25]} rotation={[-0.55, 0, 0]}>
69
+ <boxGeometry args={[1.56, 0.42, 0.08]} />
70
+ <meshStandardMaterial color={CLAY_GLASS} roughness={0.15} metalness={0.6} />
71
+ </mesh>
72
+ <Wheel position={[0.82, 0.33, 1.45]} />
73
+ <Wheel position={[-0.82, 0.33, 1.45]} />
74
+ <Wheel position={[0.82, 0.33, -1.45]} />
75
+ <Wheel position={[-0.82, 0.33, -1.45]} />
76
+ </group>
77
+ );
78
+ }
79
+
80
+ /** Compact single-engine propeller plane, ~3.4 m wingspan and +Z forward. */
81
+ export function SmallPlane({ position = [0, 0, 0], rotY = 0 }) {
82
+ return (
83
+ <group position={position} rotation={[0, rotY, 0]}>
84
+ {/* fuselage and tapered nose */}
85
+ <mesh position={[0, 0.76, 0]} rotation={[Math.PI / 2, 0, 0]}>
86
+ <cylinderGeometry args={[0.23, 0.31, 2.75, 16]} />
87
+ <meshStandardMaterial color={CLAY_PLANE} roughness={0.58} metalness={0.18} />
88
+ </mesh>
89
+ <mesh position={[0, 0.76, 1.55]} rotation={[Math.PI / 2, 0, 0]}>
90
+ <coneGeometry args={[0.23, 0.55, 16]} />
91
+ <meshStandardMaterial color={CLAY_PLANE_TRIM} roughness={0.52} metalness={0.2} />
92
+ </mesh>
93
+
94
+ {/* main wing and tail plane */}
95
+ <mesh position={[0, 0.73, 0.15]}>
96
+ <boxGeometry args={[3.4, 0.09, 0.58]} />
97
+ <meshStandardMaterial color={CLAY_PLANE} roughness={0.62} metalness={0.14} />
98
+ </mesh>
99
+ <mesh position={[0, 0.84, -1.18]}>
100
+ <boxGeometry args={[1.45, 0.07, 0.38]} />
101
+ <meshStandardMaterial color={CLAY_PLANE_TRIM} roughness={0.62} metalness={0.12} />
102
+ </mesh>
103
+ <mesh position={[0, 1.08, -1.2]} rotation={[0.22, 0, 0]}>
104
+ <boxGeometry args={[0.08, 0.62, 0.48]} />
105
+ <meshStandardMaterial color={CLAY_PLANE} roughness={0.62} metalness={0.12} />
106
+ </mesh>
107
+
108
+ {/* cockpit canopy */}
109
+ <mesh position={[0, 1.02, 0.42]} scale={[0.62, 0.46, 0.9]}>
110
+ <sphereGeometry args={[0.42, 16, 10, 0, Math.PI * 2, 0, Math.PI / 2]} />
111
+ <meshStandardMaterial color={CLAY_GLASS} roughness={0.16} metalness={0.55} />
112
+ </mesh>
113
+
114
+ {/* propeller hub and blades */}
115
+ <group position={[0, 0.76, 1.86]}>
116
+ <mesh rotation={[Math.PI / 2, 0, 0]}>
117
+ <cylinderGeometry args={[0.11, 0.11, 0.2, 12]} />
118
+ <meshStandardMaterial color={CLAY_RIM} roughness={0.45} metalness={0.4} />
119
+ </mesh>
120
+ <mesh position={[0, 0, 0.12]} rotation={[0, 0, 0.28]}>
121
+ <boxGeometry args={[1.25, 0.08, 0.06]} />
122
+ <meshStandardMaterial color={CLAY_TIRE} roughness={0.8} />
123
+ </mesh>
124
+ </group>
125
+
126
+ {/* simple landing gear */}
127
+ {[-0.55, 0.55].map((x) => (
128
+ <group key={x} position={[x, 0.2, 0.15]}>
129
+ <mesh position={[0, 0.24, 0]} rotation={[0, 0, x < 0 ? -0.35 : 0.35]}>
130
+ <boxGeometry args={[0.045, 0.55, 0.045]} />
131
+ <meshStandardMaterial color={CLAY_RIM} roughness={0.65} metalness={0.3} />
132
+ </mesh>
133
+ <mesh rotation={[0, 0, Math.PI / 2]}>
134
+ <cylinderGeometry args={[0.16, 0.16, 0.09, 14]} />
135
+ <meshStandardMaterial color={CLAY_TIRE} roughness={0.95} />
136
+ </mesh>
137
+ </group>
138
+ ))}
139
+ </group>
140
+ );
141
+ }
142
+
143
+ export function Chair({ position = [0, 0, 0], rotY = 0 }) {
144
+ const legPositions = [
145
+ [-0.24, 0.23, -0.22],
146
+ [0.24, 0.23, -0.22],
147
+ [-0.24, 0.23, 0.22],
148
+ [0.24, 0.23, 0.22],
149
+ ];
150
+ return (
151
+ <group position={position} rotation={[0, rotY, 0]} scale={0.9}>
152
+ <mesh position={[0, 0.49, 0]} castShadow receiveShadow>
153
+ <boxGeometry args={[0.6, 0.12, 0.58]} />
154
+ <meshStandardMaterial color={CLAY_CHAIR_LIGHT} roughness={0.86} />
155
+ </mesh>
156
+ {legPositions.map((leg, index) => (
157
+ <mesh key={index} position={leg} castShadow receiveShadow>
158
+ <boxGeometry args={[0.09, 0.46, 0.09]} />
159
+ <meshStandardMaterial color={CLAY_CHAIR} roughness={0.9} />
160
+ </mesh>
161
+ ))}
162
+ <mesh position={[-0.24, 0.92, -0.245]} castShadow receiveShadow>
163
+ <boxGeometry args={[0.09, 0.86, 0.09]} />
164
+ <meshStandardMaterial color={CLAY_CHAIR} roughness={0.9} />
165
+ </mesh>
166
+ <mesh position={[0.24, 0.92, -0.245]} castShadow receiveShadow>
167
+ <boxGeometry args={[0.09, 0.86, 0.09]} />
168
+ <meshStandardMaterial color={CLAY_CHAIR} roughness={0.9} />
169
+ </mesh>
170
+ <mesh position={[0, 1.08, -0.245]} castShadow receiveShadow>
171
+ <boxGeometry args={[0.52, 0.38, 0.1]} />
172
+ <meshStandardMaterial color={CLAY_CHAIR_LIGHT} roughness={0.86} />
173
+ </mesh>
174
+ </group>
175
+ );
176
+ }
177
+
178
+ /**
179
+ * The creatable primitives, Unity's 3D Object menu in clay. Each one is built
180
+ * with its base on the local floor (y = 0), so an object's `y` reads as height
181
+ * above the deck rather than "half of me is underground".
182
+ */
183
+ function Primitive({ kind, color }) {
184
+ const material = <meshStandardMaterial color={color} roughness={0.82} side={kind === "plane" ? THREE.DoubleSide : THREE.FrontSide} />;
185
+ if (kind === "sphere") {
186
+ return (
187
+ <mesh position={[0, 0.5, 0]} castShadow receiveShadow>
188
+ <sphereGeometry args={[0.5, 28, 18]} />
189
+ {material}
190
+ </mesh>
191
+ );
192
+ }
193
+ if (kind === "capsule") {
194
+ return (
195
+ <mesh position={[0, 0.7, 0]} castShadow receiveShadow>
196
+ <capsuleGeometry args={[0.35, 0.7, 6, 18]} />
197
+ {material}
198
+ </mesh>
199
+ );
200
+ }
201
+ if (kind === "cylinder") {
202
+ return (
203
+ <mesh position={[0, 0.5, 0]} castShadow receiveShadow>
204
+ <cylinderGeometry args={[0.5, 0.5, 1, 26]} />
205
+ {material}
206
+ </mesh>
207
+ );
208
+ }
209
+ if (kind === "cone") {
210
+ return (
211
+ <mesh position={[0, 0.5, 0]} castShadow receiveShadow>
212
+ <coneGeometry args={[0.5, 1, 26]} />
213
+ {material}
214
+ </mesh>
215
+ );
216
+ }
217
+ if (kind === "plane") {
218
+ return (
219
+ <mesh position={[0, 0.004, 0]} rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
220
+ <planeGeometry args={[2, 2]} />
221
+ {material}
222
+ </mesh>
223
+ );
224
+ }
225
+ return (
226
+ <mesh position={[0, 0.5, 0]} castShadow receiveShadow>
227
+ <boxGeometry args={[1, 1, 1]} />
228
+ {material}
229
+ </mesh>
230
+ );
231
+ }
232
+
233
+ const PRIMITIVE_KINDS = new Set(["cube", "sphere", "capsule", "cylinder", "cone", "plane"]);
234
+
235
+ function SceneObjectContent({ renderer, color }) {
236
+ if (renderer === "car") return <Car color={color} />;
237
+ if (renderer === "small-plane") return <SmallPlane />;
238
+ if (renderer === "chair") return <Chair />;
239
+ if (PRIMITIVE_KINDS.has(renderer)) return <Primitive kind={renderer} color={color} />;
240
+ return null;
241
+ }
242
+
243
+ /**
244
+ * Selection cage: the object's bounding box drawn as EDGES only. A wireframe
245
+ * box draws every triangle diagonal too, which reads as a scribble over the
246
+ * object instead of a selection.
247
+ */
248
+ function SelectionBox({ object }) {
249
+ const height = Math.max(object.height ?? 1, 0.08);
250
+ const width = object.footprint?.width ?? 1;
251
+ const depth = object.footprint?.depth ?? 1;
252
+ const edges = useMemo(
253
+ () => new THREE.EdgesGeometry(new THREE.BoxGeometry(width * 1.04, height * 1.04, depth * 1.04)),
254
+ [width, height, depth],
255
+ );
256
+ useEffect(() => () => edges.dispose(), [edges]);
257
+ return (
258
+ <lineSegments
259
+ // The cage is editor furniture, exactly like the transform gizmo, so
260
+ // it lives on GIZMO_LAYER — the layer PlayView, the ink prepass and
261
+ // CaptureRig already strip. Set on the mesh itself (via the ref):
262
+ // three.js layer membership is per object, and the camera mask
263
+ // checks the object that actually renders.
264
+ ref={(mesh) => {
265
+ if (mesh) mesh.layers.set(GIZMO_LAYER);
266
+ }}
267
+ geometry={edges}
268
+ position={[0, height / 2, 0]}
269
+ renderOrder={998}
270
+ >
271
+ <lineBasicMaterial color="#e7b557" transparent opacity={0.95} depthTest={false} depthWrite={false} />
272
+ </lineSegments>
273
+ );
274
+ }
275
+
276
+ const DEG = Math.PI / 180;
277
+
278
+ function SceneObject({ object, selected }) {
279
+ return (
280
+ <group
281
+ position={[object.x, object.y ?? 0, object.z]}
282
+ rotation={[(object.rotX ?? 0) * DEG, object.rot * DEG, (object.rotZ ?? 0) * DEG]}
283
+ scale={[object.scaleX ?? 1, object.scaleY ?? 1, object.scaleZ ?? 1]}
284
+ // the viewport picker walks up from a hit mesh to find this id
285
+ userData={{ sceneObjectId: object.id }}
286
+ >
287
+ <SceneObjectContent renderer={object.renderer} color={object.color} />
288
+ {selected && <SelectionBox object={object} />}
289
+ </group>
290
+ );
291
+ }
292
+
293
+ /** User-added scene objects, all driven by the shared object registry. */
294
+ export function SetProps({ objects = [], selectedId = null }) {
295
+ return (
296
+ <group>
297
+ {objects.map((object) => (
298
+ <SceneObject key={object.id} object={object} selected={object.id === selectedId} />
299
+ ))}
300
+ </group>
301
+ );
302
+ }
package/src/room.jsx ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The blocking set: an open room corner.
3
+ *
4
+ * A blocking frame needs walls far more than it needs a pretty floor. An
5
+ * infinite grid gives an AI model no sense of enclosure, so depth, headroom and
6
+ * the horizon line all read as ambiguous. Two perpendicular walls and a floor
7
+ * preserve that context without enclosing the camera in a box.
8
+ *
9
+ * Dimensions are metres and stage-sized (24 x 24) so a 1.8 m figure has room
10
+ * to walk real paths while the corner still reads as an enclosure.
11
+ */
12
+
13
+ const FLOOR = "#e7e1d7";
14
+ const BACK_WALL = "#eef1ed";
15
+ const SIDE_WALL = "#e4ecec";
16
+ const SKIRTING = "#bdcccc";
17
+
18
+ const SIZE = 24;
19
+ const HEIGHT = 6.2;
20
+ const BACK_Z = -10;
21
+ function Skirting({ position, rotation }) {
22
+ return (
23
+ <mesh position={position} rotation={rotation}>
24
+ <boxGeometry args={[SIZE, 0.16, 0.06]} />
25
+ <meshBasicMaterial color={SKIRTING} />
26
+ </mesh>
27
+ );
28
+ }
29
+
30
+ export function Room() {
31
+ return (
32
+ <group>
33
+ <mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0, 0]}>
34
+ <planeGeometry args={[SIZE, SIZE]} />
35
+ <meshBasicMaterial color={FLOOR} />
36
+ </mesh>
37
+
38
+ <mesh position={[0, HEIGHT / 2, BACK_Z]}>
39
+ <planeGeometry args={[SIZE, HEIGHT]} />
40
+ <meshBasicMaterial color={BACK_WALL} />
41
+ </mesh>
42
+ <mesh position={[-SIZE / 2, HEIGHT / 2, 0]} rotation={[0, Math.PI / 2, 0]}>
43
+ <planeGeometry args={[SIZE, HEIGHT]} />
44
+ <meshBasicMaterial color={SIDE_WALL} />
45
+ </mesh>
46
+
47
+ <Skirting position={[0, 0.08, BACK_Z + 0.03]} rotation={[0, 0, 0]} />
48
+ <Skirting position={[-SIZE / 2 + 0.03, 0.08, 0]} rotation={[0, Math.PI / 2, 0]} />
49
+ </group>
50
+ );
51
+ }
52
+
53
+ /** Key/fill/rim rig tuned so a clay figure keeps readable form from any angle. */
54
+ export function StageLights() {
55
+ return (
56
+ <>
57
+ <hemisphereLight args={["#fffdf6", "#d8d0c3", 0.9]} />
58
+ <ambientLight intensity={0.18} />
59
+ <directionalLight color="#fff8e8" position={[6, 9, 4]} intensity={1.12} />
60
+ <directionalLight color="#dff6f7" position={[-6, 4, -4]} intensity={0.36} />
61
+ <directionalLight color="#ffffff" position={[2, 3, 9]} intensity={0.22} />
62
+ </>
63
+ );
64
+ }
@@ -0,0 +1,125 @@
1
+ // The single mutation owner for scene objects. All four producer classes —
2
+ // gizmo, plan board, inspector number scrubs, one-shot hierarchy/inspector
3
+ // atomics — mutate through this store so a drag becomes exactly ONE undo
4
+ // entry and can be cancelled. No React, node-importable (the test seam).
5
+ import {
6
+ createHistory,
7
+ pushHistory,
8
+ undoHistory,
9
+ redoHistory,
10
+ canUndo as historyCanUndo,
11
+ canRedo as historyCanRedo,
12
+ createTransactions,
13
+ beginTransaction,
14
+ isCurrentTransaction,
15
+ endTransaction,
16
+ settleTransaction,
17
+ } from "./history.js";
18
+
19
+ // Invariant, stated once: whenever no transaction is open,
20
+ // `store.present()` is reference-equal to `store.objects`. `history` is
21
+ // assigned in exactly four places — applyAtomic, end(commit:true), settle,
22
+ // and undo/redo — and every one pushes or steps to the POST-change present.
23
+ export function createSceneHistoryStore(initialObjects, { onObjects }) {
24
+ let objects = initialObjects;
25
+ let history = createHistory(initialObjects);
26
+ const tx = createTransactions();
27
+ let before = null;
28
+
29
+ function emit(next) {
30
+ objects = next;
31
+ onObjects?.(next);
32
+ }
33
+ // A settle is triggered by the user starting something else; the
34
+ // travel already applied is real and becomes its own entry, so the
35
+ // very next Ctrl+Z discards it deliberately.
36
+ function settle() {
37
+ if (settleTransaction(tx) === null) return;
38
+ history = pushHistory(history, objects);
39
+ before = null;
40
+ }
41
+ return {
42
+ get objects() {
43
+ return objects;
44
+ },
45
+
46
+ // Public so the present === objects invariant is assertable from
47
+ // Node and from QA without touching private state.
48
+ present() {
49
+ return history.present;
50
+ },
51
+
52
+ // Settle any open drag first, then apply one atomic mutation. A fn
53
+ // that returns the same array creates no entry (coalescing).
54
+ applyAtomic(fn) {
55
+ settle();
56
+ const next = fn(objects);
57
+ if (next === objects) return;
58
+ emit(next);
59
+ history = pushHistory(history, next);
60
+ },
61
+
62
+ // Settle first so a nested begin commits the previous drag as ONE
63
+ // entry; history is not touched at begin.
64
+ begin(owner, cancel) {
65
+ settle();
66
+ before = objects;
67
+ return beginTransaction(tx, { owner, cancel });
68
+ },
69
+
70
+ // Stream applies land on the live array only while the token is the
71
+ // current transaction; history is not touched here.
72
+ applyIn(token, fn) {
73
+ if (!isCurrentTransaction(tx, token)) return;
74
+ const next = fn(objects);
75
+ if (next === objects) return;
76
+ emit(next);
77
+ },
78
+
79
+ end(token, { commit }) {
80
+ if (!endTransaction(tx, token)) return false;
81
+ if (commit) {
82
+ // pushHistory coalesces: nothing applied pushes nothing.
83
+ history = pushHistory(history, objects);
84
+ } else {
85
+ // Rollback restores the whole pre-drag array by reference —
86
+ // strictly more correct than replaying one record.
87
+ emit(before);
88
+ }
89
+ before = null;
90
+ return true;
91
+ },
92
+
93
+ settle,
94
+
95
+ undo() {
96
+ settle();
97
+ const stepped = undoHistory(history);
98
+ if (stepped === null) return null;
99
+ history = stepped;
100
+ emit(stepped.present);
101
+ return stepped.present;
102
+ },
103
+
104
+ redo() {
105
+ settle();
106
+ const stepped = redoHistory(history);
107
+ if (stepped === null) return null;
108
+ history = stepped;
109
+ emit(stepped.present);
110
+ return stepped.present;
111
+ },
112
+
113
+ canUndo() {
114
+ return historyCanUndo(history);
115
+ },
116
+
117
+ canRedo() {
118
+ return historyCanRedo(history);
119
+ },
120
+
121
+ depths() {
122
+ return { past: history.past.length, future: history.future.length };
123
+ },
124
+ };
125
+ }