cozyclay 2.0.2 → 2.1.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.
Files changed (262) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +17 -2
  3. package/THIRD_PARTY_NOTICES.md +8 -0
  4. package/bin/agent/agent-routes.mjs +211 -36
  5. package/bin/agent/agent-runner.mjs +98 -4
  6. package/bin/agent/codex-client.mjs +2 -1
  7. package/bin/agent/fal-contract.mjs +0 -17
  8. package/bin/agent/h3-preservation.mjs +6 -8
  9. package/bin/agent/provider-keys.mjs +1 -1
  10. package/bin/agent/providers.mjs +124 -3
  11. package/bin/agent/script-tool.mjs +74 -0
  12. package/bin/agent/session-store.mjs +5 -5
  13. package/bin/agent/studio-prompt.mjs +2 -1
  14. package/bin/agent/studio-rebase.mjs +314 -0
  15. package/bin/agent/studio-tools.mjs +21 -14
  16. package/bin/agent/supervisor.mjs +311 -0
  17. package/bin/agent/video-adapters.mjs +0 -7
  18. package/bin/codex-auth.mjs +4 -4
  19. package/bin/live/cli.mjs +23 -8
  20. package/bin/live/client.mjs +1 -1
  21. package/bin/live/studio.mjs +5 -2
  22. package/bin/mcp-runtime.mjs +2 -2
  23. package/bin/telemetry-state.mjs +3 -3
  24. package/dist/app/index.html +6 -6
  25. package/dist/assets/analytics-C9pMpW7s.js +1 -0
  26. package/dist/assets/app-B_2XB9rw.css +1 -0
  27. package/dist/assets/app-DAuvKPP8.js +4920 -0
  28. package/dist/assets/{demo-ByeYp-dY.js → demo-CvKuBgy5.js} +1 -1
  29. package/dist/assets/{first-shot-handoff-DgT28ZiV.js → first-shot-handoff-msLI71g1.js} +1 -1
  30. package/dist/assets/{landing-BaXkpFip.js → landing-ChUvB_zL.js} +1 -1
  31. package/dist/assets/scene-asset-sync-DKkpjKBV.js +19 -0
  32. package/dist/assets/{shot-prompt-CbqWcWYi.css → scene-asset-sync-DjJ_cJ2R.css} +1 -1
  33. package/dist/assets/{ticket-BcmsnHn5.js → ticket-BPk2Ni95.js} +1 -1
  34. package/dist/assets/workflow-jJRBR94Z.js +179 -0
  35. package/dist/cozyclay-package.json +1 -1
  36. package/dist/index.html +328 -222
  37. package/dist/llms.txt +14 -0
  38. package/dist/sitemap.xml +1 -1
  39. package/dist/workflow/index.html +4 -4
  40. package/mcp/LIVE-PROTOCOL.md +23 -2
  41. package/mcp/README.md +37 -2
  42. package/mcp/ardy-prompts.mjs +1 -1
  43. package/mcp/image-file.mjs +132 -0
  44. package/mcp/live-hub.mjs +2 -2
  45. package/mcp/package.json +1 -0
  46. package/mcp/runtime/package-lock.json +7 -0
  47. package/mcp/runtime/package.json +1 -0
  48. package/mcp/server.mjs +0 -6
  49. package/mcp/tool-handlers.mjs +289 -87
  50. package/package.json +11 -2
  51. package/src/App.jsx +813 -278
  52. package/src/agent-image-client.js +94 -0
  53. package/src/analytics.js +47 -3
  54. package/src/app-stage.jsx +129 -2745
  55. package/src/ardy/auto-physics.js +6 -16
  56. package/src/ardy/collision-blockers.js +3 -7
  57. package/src/ardy/fix-collisions.js +5 -35
  58. package/src/ardy/ik-drag.js +86 -5
  59. package/src/ardy/ik-key-json.js +3 -1
  60. package/src/ardy/ik.js +15 -13
  61. package/src/ardy/motion-calibration.js +1 -1
  62. package/src/ardy/npz.js +0 -8
  63. package/src/ardy/physics-panel.jsx +3 -2
  64. package/src/ardy/physics-review.js +3 -2
  65. package/src/ardy/physics-support.js +1 -1
  66. package/src/ardy/physics-surface.js +1 -1
  67. package/src/ardy/platform-fit-panel.jsx +2 -1
  68. package/src/ardy/playback.js +1 -5
  69. package/src/ardy/range-pin.js +2 -2
  70. package/src/ardy/root-drop.js +0 -1
  71. package/src/ardy/timeline.css +184 -6
  72. package/src/ardy/timeline.jsx +121 -36
  73. package/src/ardy/to-cskel27.js +0 -2
  74. package/src/ardy/waypoints.js +0 -3
  75. package/src/ardy-bridge-contract.js +281 -0
  76. package/src/asset-pane.jsx +19 -9
  77. package/src/auto-color.js +1 -1
  78. package/src/burn-in.js +1 -1
  79. package/src/camera-block.js +1 -1
  80. package/src/camera-follow.js +75 -32
  81. package/src/camera-move.js +0 -6
  82. package/src/camera-tutorial.jsx +6 -7
  83. package/src/character-kind.js +79 -0
  84. package/src/command-bus.js +76 -17
  85. package/src/commands/cast.js +31 -7
  86. package/src/commands/elements/object.js +1 -1
  87. package/src/commands/elements/shot.js +1 -1
  88. package/src/commands/elements.js +1 -1
  89. package/src/commands/export.js +3 -3
  90. package/src/commands/motion.js +2 -2
  91. package/src/commands/objects.js +105 -10
  92. package/src/commands/project.js +15 -2
  93. package/src/commands/sculpt-images.js +53 -0
  94. package/src/commands/shot.js +88 -10
  95. package/src/commands/view.js +10 -4
  96. package/src/coplanar-depth.js +216 -0
  97. package/src/cuts.js +7 -6
  98. package/src/domains/cast.js +60 -43
  99. package/src/domains/motion/motion-intent-codec.js +50 -0
  100. package/src/domains/motion/motion-range-pin-rules.js +23 -0
  101. package/src/domains/motion/motion-video-capture.js +664 -0
  102. package/src/domains/motion.js +253 -742
  103. package/src/domains/objects.js +106 -5
  104. package/src/domains/scenes.js +79 -28
  105. package/src/domains/shots.js +33 -7
  106. package/src/error-boundary.jsx +1 -1
  107. package/src/execution-telemetry.js +5 -5
  108. package/src/facing-marks.js +14 -1
  109. package/src/first-success-guide.jsx +35 -4
  110. package/src/gizmo-claim.js +3 -1
  111. package/src/grid-floor.jsx +1 -4
  112. package/src/hierarchy-model.js +48 -35
  113. package/src/hierarchy-panel.css +1 -0
  114. package/src/hierarchy-panel.jsx +54 -102
  115. package/src/history.js +0 -2
  116. package/src/i2v-motion-client.js +1 -4
  117. package/src/ime.js +6 -0
  118. package/src/keyframe-pack.js +15 -6
  119. package/src/line-edit.js +4 -4
  120. package/src/live-control.js +6 -6
  121. package/src/matte-editor.js +2 -8
  122. package/src/matte.js +0 -19
  123. package/src/mesh-graph-clone.js +16 -2
  124. package/src/mesh-sniff.js +0 -1
  125. package/src/monotone-cubic.js +40 -0
  126. package/src/motion-store.js +4 -4
  127. package/src/motion-trail.js +5 -11
  128. package/src/multimodel-ingest.js +1 -3
  129. package/src/object-catalog.jsx +7 -5
  130. package/src/object-gizmo.jsx +45 -31
  131. package/src/object-path.js +310 -67
  132. package/src/object-travel.js +224 -0
  133. package/src/otio.js +8 -3
  134. package/src/panels/CharacterTransformPanel.jsx +24 -6
  135. package/src/panels/ObjectTransformPanel.jsx +4 -3
  136. package/src/panels/PoseImagePanel.jsx +205 -0
  137. package/src/panels/PromptBlocksPanel.jsx +3 -1
  138. package/src/panels/ProxyFigurePanel.jsx +56 -0
  139. package/src/panels/ReferenceImageField.jsx +1 -1
  140. package/src/panels/RigControlPanel.jsx +24 -3
  141. package/src/panels/RigPanel.jsx +2 -1
  142. package/src/panels/SculptPanel.jsx +405 -0
  143. package/src/panels/SubjectBox.jsx +1 -1
  144. package/src/panels/SubjectsPanel.jsx +0 -2
  145. package/src/panels/VideoCapturePanel.jsx +2 -1
  146. package/src/panels/pose-image.css +42 -0
  147. package/src/panels/pose.css +3 -0
  148. package/src/panels/sculpt.css +162 -0
  149. package/src/plan-diagram.js +184 -0
  150. package/src/planview.jsx +127 -80
  151. package/src/playground.js +3 -5
  152. package/src/pose-fade.js +77 -0
  153. package/src/pose-gizmo.css +23 -0
  154. package/src/pose-gizmo.js +96 -0
  155. package/src/posestudio.jsx +134 -83
  156. package/src/project-browser.css +53 -0
  157. package/src/project-browser.jsx +49 -4
  158. package/src/project.js +55 -24
  159. package/src/props.jsx +177 -36
  160. package/src/proxy-figure.jsx +69 -0
  161. package/src/range-pin-panel.jsx +0 -1
  162. package/src/receipt-findings.js +20 -0
  163. package/src/render-passes.js +0 -1
  164. package/src/resource-status.jsx +1 -1
  165. package/src/root-path.js +48 -0
  166. package/src/route-owner.js +52 -0
  167. package/src/scene-asset-cache.js +1 -2
  168. package/src/scene-assets.js +12 -4
  169. package/src/scene-fbx.js +1 -1
  170. package/src/scene-mesh-cache.js +0 -1
  171. package/src/scene-objects.js +319 -19
  172. package/src/scenes.js +58 -9
  173. package/src/sculpt-blueprint.js +65 -0
  174. package/src/sculpt-compare.js +384 -0
  175. package/src/sculpt-geometry.js +141 -0
  176. package/src/sculpt-mode.js +168 -0
  177. package/src/sculpt-recipe.js +385 -0
  178. package/src/sculpt-run.js +351 -0
  179. package/src/sculpt-stage.jsx +221 -0
  180. package/src/settings-menu.jsx +1 -1
  181. package/src/shell/BoardSlot.jsx +341 -0
  182. package/src/shell/BottomDock.jsx +60 -16
  183. package/src/shell/CompositionGuideButton.jsx +20 -0
  184. package/src/shell/DetailsSlot.jsx +26 -6
  185. package/src/shell/MenuBar.jsx +78 -6
  186. package/src/shell/OutlinerSlot.jsx +3 -1
  187. package/src/shell/ShotPreviewHeader.jsx +17 -0
  188. package/src/shell/TopBar.jsx +18 -3
  189. package/src/shell/ViewportToolbar.jsx +36 -31
  190. package/src/shell/dock.css +239 -0
  191. package/src/shell/glass-regions.css +3 -2
  192. package/src/shell/log-store.js +4 -4
  193. package/src/shell/mode.css +20 -0
  194. package/src/shell/studio-shell-context.js +4 -1
  195. package/src/shell/viewport.css +12 -0
  196. package/src/shot-authoring.js +69 -6
  197. package/src/stable-items.js +1 -1
  198. package/src/stage-attach-frames.js +169 -0
  199. package/src/stage-canvas-rigs.jsx +402 -0
  200. package/src/stage-capture.jsx +216 -0
  201. package/src/stage-characters.jsx +302 -0
  202. package/src/stage-editor-gizmos.jsx +1229 -0
  203. package/src/stage-labels-ko.js +204 -0
  204. package/src/stage-startup-defaults.js +182 -0
  205. package/src/starter-scenes.js +1 -1
  206. package/src/storyboard.js +24 -5
  207. package/src/studio-actions.js +42 -9
  208. package/src/studio-agent-commands.js +213 -23
  209. package/src/studio-agent-context.js +11 -9
  210. package/src/studio-agent-motion.js +2 -1
  211. package/src/studio-agent-protocol.js +60 -24
  212. package/src/studio-app-binding.js +56 -23
  213. package/src/studio-contact-sheet.js +3 -3
  214. package/src/studio-elements.js +11 -2
  215. package/src/studio-geometry-facts.js +114 -0
  216. package/src/styles.css +92 -9
  217. package/src/ui-scrub.js +4 -4
  218. package/src/ui.jsx +6 -4
  219. package/src/use-asset-shelf.js +60 -0
  220. package/src/use-camera-tutorial.js +50 -0
  221. package/src/use-embed-bridge.js +12 -0
  222. package/src/use-first-success-prompt.js +7 -0
  223. package/src/use-i2v-capture-modal.js +15 -0
  224. package/src/use-sculpt-mode.js +95 -0
  225. package/src/workflow/AgentPanel.jsx +106 -18
  226. package/src/workflow/WorkflowBuilder.jsx +1 -1
  227. package/src/workflow/agent-client.js +113 -45
  228. package/src/workflow/agent-panel.css +35 -0
  229. package/src/workflow/attachment-image.js +1 -1
  230. package/src/workflow/canvas-commands.js +3 -3
  231. package/src/workflow/cozy-scene-node.js +3 -3
  232. package/src/workflow/local-workflow.js +2 -2
  233. package/src/workflow/motion-input.js +3 -3
  234. package/src/workflow/node-schema.js +1 -1
  235. package/src/workflow/scene-asset-sync.js +2 -9
  236. package/src/workflow/shot-prompt-node.js +1 -1
  237. package/src/workflow/video-contract.js +1 -1
  238. package/src/workflow/workflow-resources.js +0 -8
  239. package/src/workflow-send.js +87 -0
  240. package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
  241. package/tools/check-eslint.mjs +57 -0
  242. package/tools/check-knip.mjs +60 -0
  243. package/tools/check-loc.mjs +54 -0
  244. package/tools/demo-worker/api-client.mjs +7 -13
  245. package/tools/demo-worker/generate.mjs +2 -4
  246. package/tools/eslint-baseline.json +23 -0
  247. package/tools/gt-render/browser.mjs +23 -0
  248. package/tools/headless-studio.mjs +85 -0
  249. package/tools/kimodo/effector-constraints.mjs +4 -4
  250. package/tools/kimodo/generate.mjs +1 -1
  251. package/tools/knip-baseline.json +331 -0
  252. package/tools/loc-baseline.json +126 -0
  253. package/tools/process-supervisor.mjs +1 -1
  254. package/tools/projflow/replay.mjs +4 -4
  255. package/tools/projflow/runner.mjs +3 -3
  256. package/tools/qa/studio-control-count.mjs +2 -0
  257. package/tools/run-tests.mjs +95 -7
  258. package/dist/assets/analytics-MVZOEW8O.js +0 -1
  259. package/dist/assets/app-BxBLC6GT.js +0 -4876
  260. package/dist/assets/app-CJdxvnrl.css +0 -1
  261. package/dist/assets/shot-prompt-hN0j_O-p.js +0 -17
  262. package/dist/assets/workflow-B5XZwVfe.js +0 -179
@@ -0,0 +1,168 @@
1
+ // Sculpt mode (#739): the pure parts of the fifth viewport mode. The stage
2
+ // component (sculpt-stage.jsx) and App use these; verify-sculpt-mode pins them.
3
+ import { placementInFront, SCULPT_KIND } from "./scene-objects.js";
4
+ import { effortOptions, modelIsSelectable, readStoredModel } from "./workflow/agent-client.js";
5
+
6
+ /** The sculpt turntable's render layer. dualview.jsx owns 2-5; nothing else
7
+ * draws on 6, so only the sculpt camera sees the turntable floor and lights. */
8
+ export const SCULPT_LAYER = 6;
9
+
10
+ /** Front / side / back, as azimuths from the sculpt's own front (+Z turned by
11
+ * its yaw). Side is the subject's own left, sculpt.compare's positive yaw. */
12
+ export const SCULPT_VIEWS = Object.freeze({ front: 0, side: 90, back: 180 });
13
+
14
+ /** The sculpt the mode opens on: the selected object when it is a sculpt,
15
+ * otherwise none (the first run creates one). */
16
+ export function sculptTargetFromSelection(objects, selectedId) {
17
+ const object = (objects ?? []).find((row) => row.id === selectedId);
18
+ return object?.renderer === SCULPT_KIND ? object.id : null;
19
+ }
20
+
21
+ /** The object ids the isolated turntable draws: the target and nothing else.
22
+ * Every other object, character and the set stay in their records untouched;
23
+ * they are simply not on the sculpt camera's layer. */
24
+ export function isolatedObjectIds(objects, targetId) {
25
+ return (objects ?? []).some((row) => row.id === targetId) ? [targetId] : [];
26
+ }
27
+
28
+ /** Orbit target and camera position that frame `object` (size from
29
+ * objectSize) from one of SCULPT_VIEWS through a `fovDeg` vertical lens. */
30
+ export function sculptFraming(object, size, view = "front", fovDeg = 35) {
31
+ const height = Math.max(0.05, size?.height ?? 1);
32
+ const radius = 0.5 * Math.hypot(size?.width ?? 1, height, size?.depth ?? 1);
33
+ const base = object?.y ?? 0;
34
+ const target = [object?.x ?? 0, base + height / 2, object?.z ?? 0];
35
+ const distance = (radius / Math.sin(((fovDeg / 2) * Math.PI) / 180)) * 1.15;
36
+ const azimuth = (((object?.rot ?? 0) + (SCULPT_VIEWS[view] ?? 0)) * Math.PI) / 180;
37
+ // A slight look-down reads the volume; level would flatten it to the mask.
38
+ const lift = distance * 0.18;
39
+ return {
40
+ target,
41
+ position: [target[0] + Math.sin(azimuth) * distance, target[1] + lift, target[2] + Math.cos(azimuth) * distance],
42
+ radius,
43
+ floorY: base,
44
+ };
45
+ }
46
+
47
+ /** Where Place in scene stands the sculpt: on the ground, in front of the
48
+ * scene camera, with the same helper + Add uses. */
49
+ export function sculptPlacement(cameraPosition, yaw) {
50
+ return { ...placementInFront({ x: cameraPosition.x, z: cameraPosition.z }, yaw), y: 0 };
51
+ }
52
+
53
+ const PENDING = new Set(["started", "queued", "running", "pending"]);
54
+
55
+ /**
56
+ * The command feed a sculpt run listens to. The editor bus only announces
57
+ * finished jobs, so the agent's live command handlers are wrapped to report
58
+ * every receipt too; a receipt seen twice (a job reported by both) is fed once.
59
+ * Events are `{ action, args, result, turnId }`, with a job's output merged into
60
+ * result. turnId is the agent turn that ran the command (null for UI and MCP
61
+ * commands), so a run hears only its own turns; a job that went to the
62
+ * background keeps the turnId of the request that started it.
63
+ */
64
+ export function createCommandTap() {
65
+ const listeners = new Set();
66
+ const seen = new Set();
67
+ const jobTurns = new Map();
68
+ const report = (request, receipt) => {
69
+ if (receipt?.ok && receipt.jobId && request?.turnId && PENDING.has(receipt.status)) {
70
+ jobTurns.set(receipt.jobId, request.turnId);
71
+ if (jobTurns.size > 200) jobTurns.delete(jobTurns.keys().next().value);
72
+ }
73
+ if (!receipt?.ok || !receipt.action || PENDING.has(receipt.status)) return;
74
+ if (receipt.receiptId) {
75
+ if (seen.has(receipt.receiptId)) return;
76
+ seen.add(receipt.receiptId);
77
+ // Duplicates arrive back to back; the recent ids are enough to drop them.
78
+ if (seen.size > 200) seen.delete(seen.values().next().value);
79
+ }
80
+ const args = request?.args?.action === receipt.action ? request.args.args ?? {} : request?.args ?? {};
81
+ const turnId = request?.turnId ?? jobTurns.get(receipt.jobId) ?? null;
82
+ const event = { action: receipt.action, args, result: { ...receipt, ...(receipt.output ?? {}) }, turnId };
83
+ // An observer must never turn an applied command into a failed one.
84
+ for (const listener of [...listeners]) {
85
+ try { listener(event); } catch (error) { console.error("sculpt command tap listener failed", error); }
86
+ }
87
+ };
88
+ return {
89
+ report,
90
+ /** Wrap live handlers so each receipt they answer is reported. */
91
+ wrap(handlers) {
92
+ return Object.fromEntries(Object.entries(handlers).map(([name, handler]) => [name, (request, ...rest) => {
93
+ const out = handler(request, ...rest);
94
+ if (out?.then) return out.then((value) => { report(request, value); return value; });
95
+ report(request, out);
96
+ return out;
97
+ }]));
98
+ },
99
+ /** createSculptRun's `bus`: tap events plus the editor bus's finished jobs. */
100
+ subscribe(listener, bus = null) {
101
+ listeners.add(listener);
102
+ const off = bus?.subscribe?.((event) => { if (event?.type === "job.completed") report(null, event.receipt); });
103
+ return () => { listeners.delete(listener); off?.(); };
104
+ },
105
+ };
106
+ }
107
+
108
+ /** The studio agent client a sculpt run turns through: the Agent panel's
109
+ * transport, with the live editor context every Studio turn must carry. */
110
+ export function sculptAgentClient(transport, readContext) {
111
+ return { turn: (request, onEvent, signal) => transport.turn({ ...request, context: readContext() }, onEvent, signal) };
112
+ }
113
+
114
+ /** Readiness for Start, from the transport's status (the Agent panel's rule:
115
+ * signed in, or a provider key held by the sidecar). */
116
+ export const agentSessionReady = (status) => Boolean(status?.signedIn || status?.providersConfigured > 0);
117
+
118
+ /** A stored image asset as a data URL (the bytes the store holds, so the run
119
+ * scores against exactly the stored, possibly downscaled, picture). */
120
+ export function assetDataUrl(asset) {
121
+ const bytes = new Uint8Array(asset.bytes);
122
+ let binary = "";
123
+ for (let index = 0; index < bytes.length; index += 0x8000) binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
124
+ return `data:${asset.type};base64,${globalThis.btoa(binary)}`;
125
+ }
126
+
127
+ /** Sculpt remembers its own model and effort, apart from the Agent panel's chat model. */
128
+ const SCULPT_MODEL_KEY = "cozyclay.sculpt.model";
129
+
130
+ export function readSculptModel(storage = globalThis.localStorage) {
131
+ try {
132
+ const value = JSON.parse(storage?.getItem(SCULPT_MODEL_KEY) ?? "null");
133
+ return value && typeof value.model === "string" ? { model: value.model, effort: typeof value.effort === "string" ? value.effort : null } : null;
134
+ } catch {
135
+ return null;
136
+ }
137
+ }
138
+
139
+ export function storeSculptModel(choice, storage = globalThis.localStorage) {
140
+ try {
141
+ if (choice?.model) storage?.setItem(SCULPT_MODEL_KEY, JSON.stringify({ model: choice.model, effort: choice.effort ?? null }));
142
+ } catch {
143
+ // Storage denial must never break picking a model.
144
+ }
145
+ }
146
+
147
+ /**
148
+ * The model and effort Sculpt runs on, from the sidecar's `/agent/models`
149
+ * answer: the current pick, else Sculpt's remembered one, else the Agent
150
+ * panel's model, else the first model a signed-in provider can run. Only a
151
+ * model the list still carries and its provider can run is ever chosen, and
152
+ * an effort the model does not offer falls back to its default (null).
153
+ */
154
+ export function sculptModelChoice(advertised, current = null, stored = readSculptModel(), agentModel = readStoredModel()) {
155
+ const models = Array.isArray(advertised?.models) ? advertised.models : [];
156
+ const providers = Array.isArray(advertised?.providers) ? advertised.providers : [];
157
+ const runnable = (key) => Boolean(key) && models.some((entry) => entry.id === key) && modelIsSelectable(providers, key);
158
+ const model = [current?.model, stored?.model, agentModel].find(runnable)
159
+ ?? models.find((entry) => modelIsSelectable(providers, entry.id))?.id ?? "";
160
+ const offered = effortOptions(models.find((entry) => entry.id === model));
161
+ const wanted = model === current?.model ? current?.effort : model === stored?.model ? stored?.effort : null;
162
+ return { model, effort: wanted && offered.includes(wanted) ? wanted : null };
163
+ }
164
+
165
+ /** How a model key is named to the operator: its catalogue label, else the key. */
166
+ export function sculptModelLabel(advertised, key) {
167
+ return (Array.isArray(advertised?.models) ? advertised.models : []).find((entry) => entry.id === key)?.label ?? key ?? "";
168
+ }
@@ -0,0 +1,385 @@
1
+ /**
2
+ * Sculpt recipes: a clay prop described as data.
3
+ *
4
+ * A `sculpt` object carries a recipe of soft parts instead of a mesh file, so an
5
+ * agent can build a prop from a reference image without anything it writes
6
+ * being executed. This module is the pure half: it owns the schema, refuses
7
+ * what it cannot draw, expands mirrors and parent chains into object-frame
8
+ * transforms, and measures the standing box. It is importable without three.js
9
+ * so the scene record, the MCP server and the tests all read one truth.
10
+ *
11
+ * A recipe that breaks a rule is REFUSED with the reason, never clamped into a
12
+ * different shape: an agent that asked for a 40 m head should hear why, not
13
+ * see a 20 m one and believe it.
14
+ *
15
+ * Contract: docs/sculpt-from-image.md.
16
+ */
17
+
18
+ const SCULPT_RECIPE_VERSION = 1;
19
+ const SCULPT_SHAPES = Object.freeze(["blob", "box", "cylinder", "torus", "frame"]);
20
+ export const SCULPT_LIMITS = Object.freeze({
21
+ /** after mirrors are expanded */
22
+ maxParts: 48,
23
+ minExtent: 0.005,
24
+ maxExtent: 20,
25
+ maxOffset: 20,
26
+ maxDepth: 8,
27
+ });
28
+ const SCULPT_DEFAULT_COLOR = "#c2c6c8";
29
+ /** The suffix a mirrored copy's id gets. `.` is outside the id alphabet, so a
30
+ * copy can never collide with an authored part. */
31
+ export const SCULPT_MIRROR_SUFFIX = ".mirror";
32
+ const MIN_HEIGHT = 0.05;
33
+
34
+ const ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,39}$/;
35
+ const HEX_SHORT = /^#?([0-9a-f]{3})$/i;
36
+ const HEX_LONG = /^#?([0-9a-f]{6})$/i;
37
+ const PART_KEYS = new Set(["id", "shape", "size", "position", "rotation", "color", "parent", "mirror", "roundness", "taper", "border"]);
38
+ const SHAPE_KEYS = {
39
+ blob: new Set(["roundness", "taper"]),
40
+ box: new Set(["roundness"]),
41
+ cylinder: new Set(["taper"]),
42
+ torus: new Set(),
43
+ frame: new Set(["border"]),
44
+ };
45
+ const DEFAULT_ROUNDNESS = { blob: 0.5, box: 0.1 };
46
+
47
+ const tidy = (value) => {
48
+ const rounded = Math.round(value * 1e4) / 1e4;
49
+ return rounded === 0 ? 0 : rounded;
50
+ };
51
+ const isFiniteNumber = (value) => typeof value === "number" && Number.isFinite(value);
52
+ const wrapDegrees = (deg) => {
53
+ const wrapped = ((((deg + 180) % 360) + 360) % 360) - 180;
54
+ return wrapped === -180 ? 180 : wrapped;
55
+ };
56
+
57
+ class RecipeError extends Error {
58
+ constructor(path, message) {
59
+ super(`${path}: ${message}`);
60
+ this.path = path;
61
+ }
62
+ }
63
+
64
+ function readColor(value, path) {
65
+ if (value === undefined) return SCULPT_DEFAULT_COLOR;
66
+ const text = typeof value === "string" ? value.trim() : "";
67
+ const short = HEX_SHORT.exec(text);
68
+ if (short) return `#${short[1].toLowerCase().replace(/./g, (digit) => digit + digit)}`;
69
+ const long = HEX_LONG.exec(text);
70
+ if (long) return `#${long[1].toLowerCase()}`;
71
+ throw new RecipeError(path, `colour must be #rgb or #rrggbb, got ${JSON.stringify(value)}`);
72
+ }
73
+
74
+ function readVector(value, path, fallback, check) {
75
+ if (value === undefined && fallback) return [...fallback];
76
+ if (!Array.isArray(value) || value.length !== 3 || !value.every(isFiniteNumber)) {
77
+ throw new RecipeError(path, `must be three numbers [x, y, z], got ${JSON.stringify(value)}`);
78
+ }
79
+ value.forEach((component, axis) => check(component, `${path}[${axis}]`));
80
+ return value.map(tidy);
81
+ }
82
+
83
+ function readUnit(value, path, fallback, min, max) {
84
+ if (value === undefined) return fallback;
85
+ if (!isFiniteNumber(value) || value < min || value > max) {
86
+ throw new RecipeError(path, `must be a number from ${min} to ${max}, got ${JSON.stringify(value)}`);
87
+ }
88
+ return tidy(value);
89
+ }
90
+
91
+ function normalizePart(raw, index) {
92
+ const at = `parts[${index}]`;
93
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new RecipeError(at, "must be an object");
94
+ for (const key of Object.keys(raw)) {
95
+ if (!PART_KEYS.has(key)) throw new RecipeError(`${at}.${key}`, `unknown key (allowed: ${[...PART_KEYS].join(", ")})`);
96
+ }
97
+ if (typeof raw.id !== "string" || !ID_PATTERN.test(raw.id)) {
98
+ throw new RecipeError(`${at}.id`, `must be 1-40 characters of a-z, 0-9 and -, starting with a letter or digit; got ${JSON.stringify(raw.id)}`);
99
+ }
100
+ const where = `parts[${raw.id}]`;
101
+ if (!SCULPT_SHAPES.includes(raw.shape)) {
102
+ throw new RecipeError(`${where}.shape`, `must be one of ${SCULPT_SHAPES.join(", ")}; got ${JSON.stringify(raw.shape)}`);
103
+ }
104
+ for (const key of ["roundness", "taper", "border"]) {
105
+ if (raw[key] !== undefined && !SHAPE_KEYS[raw.shape].has(key)) {
106
+ throw new RecipeError(`${where}.${key}`, `does not apply to a ${raw.shape}`);
107
+ }
108
+ }
109
+ const size = readVector(raw.size, `${where}.size`, null, (value, path) => {
110
+ if (value < SCULPT_LIMITS.minExtent || value > SCULPT_LIMITS.maxExtent) {
111
+ throw new RecipeError(path, `extent must be ${SCULPT_LIMITS.minExtent}-${SCULPT_LIMITS.maxExtent} m, got ${value}`);
112
+ }
113
+ });
114
+ const position = readVector(raw.position, `${where}.position`, [0, 0, 0], (value, path) => {
115
+ if (Math.abs(value) > SCULPT_LIMITS.maxOffset) throw new RecipeError(path, `must be within ±${SCULPT_LIMITS.maxOffset} m, got ${value}`);
116
+ });
117
+ const rotation = readVector(raw.rotation, `${where}.rotation`, [0, 0, 0], () => {}).map((deg) => tidy(wrapDegrees(deg)));
118
+ if (raw.parent !== undefined && (typeof raw.parent !== "string" || !raw.parent)) {
119
+ throw new RecipeError(`${where}.parent`, "must be another part's id");
120
+ }
121
+ if (raw.mirror !== undefined && typeof raw.mirror !== "boolean") throw new RecipeError(`${where}.mirror`, "must be true or false");
122
+ const part = {
123
+ id: raw.id,
124
+ shape: raw.shape,
125
+ size,
126
+ position,
127
+ rotation,
128
+ color: readColor(raw.color, `${where}.color`),
129
+ };
130
+ if (raw.parent !== undefined) part.parent = raw.parent;
131
+ if (raw.mirror === true) part.mirror = true;
132
+ if (SHAPE_KEYS[raw.shape].has("roundness")) part.roundness = readUnit(raw.roundness, `${where}.roundness`, DEFAULT_ROUNDNESS[raw.shape], 0, 1);
133
+ if (SHAPE_KEYS[raw.shape].has("taper")) part.taper = readUnit(raw.taper, `${where}.taper`, 0, -1, 1);
134
+ if (raw.shape === "frame") {
135
+ const limit = Math.min(size[0], size[1]) / 2;
136
+ const fallback = tidy(Math.min(size[0], size[1]) * 0.15);
137
+ const border = raw.border === undefined ? fallback : raw.border;
138
+ if (!isFiniteNumber(border) || border <= 0 || border >= limit) {
139
+ throw new RecipeError(`${where}.border`, `must be above 0 and below half the frame's smaller side (${tidy(limit)} m), got ${JSON.stringify(raw.border)}`);
140
+ }
141
+ part.border = tidy(border);
142
+ }
143
+ if (raw.shape === "torus" && size[2] >= Math.min(size[0], size[1])) {
144
+ throw new RecipeError(`${where}.size`, "a torus's tube (size[2]) must be thinner than its outer diameter");
145
+ }
146
+ return part;
147
+ }
148
+
149
+ /** Depth of every part in the parent forest, refusing unknown parents and cycles. */
150
+ function partDepths(parts) {
151
+ const byId = new Map(parts.map((part) => [part.id, part]));
152
+ const depths = new Map();
153
+ const depthOf = (part, trail) => {
154
+ if (depths.has(part.id)) return depths.get(part.id);
155
+ if (!part.parent) {
156
+ depths.set(part.id, 0);
157
+ return 0;
158
+ }
159
+ const parent = byId.get(part.parent);
160
+ if (!parent) throw new RecipeError(`parts[${part.id}].parent`, `no part named "${part.parent}"`);
161
+ if (trail.has(parent.id)) throw new RecipeError(`parts[${part.id}].parent`, "parents form a cycle");
162
+ const depth = depthOf(parent, new Set([...trail, part.id])) + 1;
163
+ if (depth > SCULPT_LIMITS.maxDepth) throw new RecipeError(`parts[${part.id}].parent`, `parent chain is deeper than ${SCULPT_LIMITS.maxDepth}`);
164
+ depths.set(part.id, depth);
165
+ return depth;
166
+ };
167
+ for (const part of parts) depthOf(part, new Set([part.id]));
168
+ // A mirror inside a mirrored subtree would mint the same `.mirror` id twice.
169
+ for (const part of parts) {
170
+ if (!part.mirror) continue;
171
+ for (let up = byId.get(part.parent); up; up = byId.get(up.parent)) {
172
+ if (up.mirror) throw new RecipeError(`parts[${part.id}].mirror`, `"${up.id}" above it is already mirrored, which copies this part too`);
173
+ }
174
+ }
175
+ return depths;
176
+ }
177
+
178
+ /**
179
+ * The recipe as the record stores it, or the reason it cannot be drawn.
180
+ * Returns `{ ok: true, recipe }` or `{ ok: false, error, path }`; never throws.
181
+ */
182
+ export function normalizeSculptRecipe(input) {
183
+ try {
184
+ if (!input || typeof input !== "object" || Array.isArray(input)) throw new RecipeError("recipe", "must be an object with a parts array");
185
+ for (const key of Object.keys(input)) {
186
+ if (key !== "version" && key !== "parts") throw new RecipeError(`recipe.${key}`, "unknown key (allowed: version, parts)");
187
+ }
188
+ if (input.version !== undefined && input.version !== SCULPT_RECIPE_VERSION) {
189
+ throw new RecipeError("recipe.version", `only version ${SCULPT_RECIPE_VERSION} is understood, got ${JSON.stringify(input.version)}`);
190
+ }
191
+ if (!Array.isArray(input.parts) || input.parts.length === 0) throw new RecipeError("recipe.parts", "must be a non-empty array");
192
+ const parts = input.parts.map(normalizePart);
193
+ const seen = new Set();
194
+ for (const part of parts) {
195
+ if (seen.has(part.id)) throw new RecipeError(`parts[${part.id}].id`, "is used twice");
196
+ seen.add(part.id);
197
+ }
198
+ partDepths(parts);
199
+ const recipe = { version: SCULPT_RECIPE_VERSION, parts };
200
+ const count = expandSculptParts(recipe).length;
201
+ if (count > SCULPT_LIMITS.maxParts) {
202
+ throw new RecipeError("recipe.parts", `expands to ${count} parts with mirrors; the limit is ${SCULPT_LIMITS.maxParts}`);
203
+ }
204
+ return { ok: true, recipe };
205
+ } catch (error) {
206
+ if (error instanceof RecipeError) return { ok: false, error: error.message, path: error.path };
207
+ throw error;
208
+ }
209
+ }
210
+
211
+ /**
212
+ * A recipe edited part by part instead of rewritten. `edits` are applied in
213
+ * order: `{ set: id, ...fields }` merges fields into one part (`parent: null`
214
+ * and `mirror: false` clear), `{ add: part }` appends, `{ remove: id }` drops a
215
+ * part and every part below it. The result is normalized as a whole, so an edit
216
+ * that leaves the recipe undrawable is refused with the usual path.
217
+ * Returns `{ ok: true, recipe, changed: [ids] }` or `{ ok: false, error, path }`.
218
+ */
219
+ export function patchSculptRecipe(recipe, edits) {
220
+ const base = normalizeSculptRecipe(recipe);
221
+ if (!base.ok) return base;
222
+ if (!Array.isArray(edits) || edits.length === 0) return { ok: false, error: "edits: must be a non-empty array", path: "edits" };
223
+ const parts = base.recipe.parts.map((part) => ({ ...part }));
224
+ const changed = new Set();
225
+ for (const [index, edit] of edits.entries()) {
226
+ const at = `edits[${index}]`;
227
+ const ops = edit && typeof edit === "object" ? ["set", "add", "remove"].filter((key) => key in edit) : [];
228
+ if (ops.length !== 1) return { ok: false, error: `${at}: must have exactly one of set, add, remove`, path: at };
229
+ if (ops[0] === "add") {
230
+ parts.push(edit.add);
231
+ changed.add(edit.add?.id);
232
+ continue;
233
+ }
234
+ const id = edit[ops[0]];
235
+ const found = parts.findIndex((part) => part.id === id);
236
+ if (found < 0) return { ok: false, error: `${at}.${ops[0]}: no part named ${JSON.stringify(id)}`, path: `${at}.${ops[0]}` };
237
+ if (ops[0] === "remove") {
238
+ const gone = new Set([id]);
239
+ for (let grew = true; grew; ) {
240
+ grew = false;
241
+ for (const part of parts) if (part.parent && gone.has(part.parent) && !gone.has(part.id)) { gone.add(part.id); grew = true; }
242
+ }
243
+ for (let i = parts.length - 1; i >= 0; i -= 1) if (gone.has(parts[i].id)) parts.splice(i, 1);
244
+ gone.forEach((entry) => changed.add(entry));
245
+ continue;
246
+ }
247
+ const { set: _set, ...fields } = edit;
248
+ if ("id" in fields) return { ok: false, error: `${at}.id: rename by remove + add`, path: `${at}.id` };
249
+ const next = { ...parts[found], ...fields };
250
+ if (fields.parent === null) delete next.parent;
251
+ if (fields.mirror === false) delete next.mirror;
252
+ // Shape-specific defaults belong to the old shape, not the new one.
253
+ if ("shape" in fields) for (const key of ["roundness", "taper", "border"]) if (!(key in fields)) delete next[key];
254
+ parts[found] = next;
255
+ changed.add(id);
256
+ }
257
+ const normalized = normalizeSculptRecipe({ version: SCULPT_RECIPE_VERSION, parts });
258
+ return normalized.ok ? { ...normalized, changed: [...changed] } : normalized;
259
+ }
260
+
261
+ const DEG = Math.PI / 180;
262
+
263
+ /** Column-major 4x4, three.js layout, so the geometry side can hand it to
264
+ * Matrix4.fromArray without a transpose. Rotation order XYZ, three's default. */
265
+ function composeMatrix(position, rotationDeg) {
266
+ const [x, y, z] = rotationDeg.map((deg) => deg * DEG);
267
+ const a = Math.cos(x), b = Math.sin(x);
268
+ const c = Math.cos(y), d = Math.sin(y);
269
+ const e = Math.cos(z), f = Math.sin(z);
270
+ const ae = a * e, af = a * f, be = b * e, bf = b * f;
271
+ return [
272
+ c * e, af + be * d, bf - ae * d, 0,
273
+ -c * f, ae - bf * d, be + af * d, 0,
274
+ d, -b * c, a * c, 0,
275
+ position[0], position[1], position[2], 1,
276
+ ];
277
+ }
278
+
279
+ function multiply(m, n) {
280
+ const out = new Array(16);
281
+ for (let col = 0; col < 4; col += 1) {
282
+ for (let row = 0; row < 4; row += 1) {
283
+ let sum = 0;
284
+ for (let k = 0; k < 4; k += 1) sum += m[k * 4 + row] * n[col * 4 + k];
285
+ out[col * 4 + row] = sum;
286
+ }
287
+ }
288
+ return out;
289
+ }
290
+
291
+ function applyMatrix(m, [x, y, z]) {
292
+ return [
293
+ m[0] * x + m[4] * y + m[8] * z + m[12],
294
+ m[1] * x + m[5] * y + m[9] * z + m[13],
295
+ m[2] * x + m[6] * y + m[10] * z + m[14],
296
+ ];
297
+ }
298
+
299
+ /** A reflection across x = 0 of the parent frame keeps the part's shape and
300
+ * negates the lateral axis only: position.x flips, and the yaw and roll turn
301
+ * the other way. All five shapes are symmetric about their own x, so no
302
+ * negative scale (and no winding flip) is needed. */
303
+ function mirroredPart(part, parentId) {
304
+ const copy = {
305
+ ...part,
306
+ id: `${part.id}${SCULPT_MIRROR_SUFFIX}`,
307
+ position: [tidy(-part.position[0]), part.position[1], part.position[2]],
308
+ rotation: [part.rotation[0], tidy(-part.rotation[1]), tidy(-part.rotation[2])],
309
+ };
310
+ delete copy.mirror;
311
+ if (parentId !== undefined) copy.parent = parentId;
312
+ return copy;
313
+ }
314
+
315
+ /**
316
+ * Every part that will be drawn, in parent-before-child order, with its
317
+ * object-frame `matrix`. A mirrored part brings its whole subtree with it:
318
+ * a pupil on a mirrored eye shows up in both eyes.
319
+ */
320
+ export function expandSculptParts(recipe) {
321
+ const parts = recipe.parts;
322
+ const children = new Map();
323
+ for (const part of parts) {
324
+ const key = part.parent ?? "";
325
+ if (!children.has(key)) children.set(key, []);
326
+ children.get(key).push(part);
327
+ }
328
+ const out = [];
329
+ const visit = (part, parentMatrix, idPrefixParent, reflect) => {
330
+ const placed = reflect ? mirroredPart(part, idPrefixParent) : idPrefixParent !== undefined ? { ...part, parent: idPrefixParent } : { ...part };
331
+ const matrix = multiply(parentMatrix, composeMatrix(placed.position, placed.rotation));
332
+ out.push({ ...placed, matrix });
333
+ for (const child of children.get(part.id) ?? []) visit(child, matrix, placed.id, reflect);
334
+ if (part.mirror && !reflect) {
335
+ const mirror = mirroredPart(part, idPrefixParent);
336
+ const mirrorMatrix = multiply(parentMatrix, composeMatrix(mirror.position, mirror.rotation));
337
+ out.push({ ...mirror, matrix: mirrorMatrix });
338
+ for (const child of children.get(part.id) ?? []) visit(child, mirrorMatrix, mirror.id, true);
339
+ }
340
+ };
341
+ const identity = composeMatrix([0, 0, 0], [0, 0, 0]);
342
+ for (const root of children.get("") ?? []) visit(root, identity, undefined, false);
343
+ return out;
344
+ }
345
+
346
+ /** The part's own box, in its local frame, as eight corners. A tapered blob
347
+ * or cylinder never grows past its declared size, so the box stays honest. */
348
+ function localCorners(part) {
349
+ const [sx, sy, sz] = part.size.map((value) => value / 2);
350
+ const corners = [];
351
+ for (const x of [-sx, sx]) for (const y of [-sy, sy]) for (const z of [-sz, sz]) corners.push([x, y, z]);
352
+ return corners;
353
+ }
354
+
355
+ export function sculptBounds(recipe) {
356
+ const min = [Infinity, Infinity, Infinity];
357
+ const max = [-Infinity, -Infinity, -Infinity];
358
+ for (const part of expandSculptParts(recipe)) {
359
+ for (const corner of localCorners(part)) {
360
+ const point = applyMatrix(part.matrix, corner);
361
+ for (let axis = 0; axis < 3; axis += 1) {
362
+ min[axis] = Math.min(min[axis], point[axis]);
363
+ max[axis] = Math.max(max[axis], point[axis]);
364
+ }
365
+ }
366
+ }
367
+ return { min: min.map(tidy), max: max.map(tidy) };
368
+ }
369
+
370
+ /**
371
+ * The standing box the scene record stores: a footprint centred on the object
372
+ * origin (the plan board and the blockers assume a centred rectangle, so an
373
+ * off-centre recipe gets the conservative box that contains it) and the
374
+ * height of the highest point above the floor.
375
+ */
376
+ export function sculptStandingBox(recipe) {
377
+ const { min, max } = sculptBounds(recipe);
378
+ return {
379
+ footprint: {
380
+ width: tidy(Math.max(SCULPT_LIMITS.minExtent, 2 * Math.max(Math.abs(min[0]), Math.abs(max[0])))),
381
+ depth: tidy(Math.max(SCULPT_LIMITS.minExtent, 2 * Math.max(Math.abs(min[2]), Math.abs(max[2])))),
382
+ },
383
+ height: tidy(Math.max(MIN_HEIGHT, max[1])),
384
+ };
385
+ }