cozyclay 2.0.2 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (256) hide show
  1. package/CHANGELOG.md +56 -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 +2 -3
  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-B41FmPYI.js +1 -0
  26. package/dist/assets/app-Do-Og5Pu.css +1 -0
  27. package/dist/assets/app-DwP-tl2x.js +4920 -0
  28. package/dist/assets/{demo-ByeYp-dY.js → demo-Dt0qZfS7.js} +1 -1
  29. package/dist/assets/{first-shot-handoff-DgT28ZiV.js → first-shot-handoff-BlT33CGf.js} +1 -1
  30. package/dist/assets/{landing-BaXkpFip.js → landing-BSesrCpL.js} +1 -1
  31. package/dist/assets/scene-asset-sync-BCWDJB1d.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-jqMhqL88.js} +1 -1
  34. package/dist/assets/workflow-Br-K7Srs.js +179 -0
  35. package/dist/cozyclay-package.json +1 -1
  36. package/dist/index.html +328 -222
  37. package/dist/sitemap.xml +1 -1
  38. package/dist/workflow/index.html +4 -4
  39. package/mcp/LIVE-PROTOCOL.md +23 -2
  40. package/mcp/README.md +37 -2
  41. package/mcp/ardy-prompts.mjs +1 -1
  42. package/mcp/image-file.mjs +132 -0
  43. package/mcp/live-hub.mjs +2 -2
  44. package/mcp/package.json +1 -0
  45. package/mcp/runtime/package-lock.json +7 -0
  46. package/mcp/runtime/package.json +1 -0
  47. package/mcp/server.mjs +0 -6
  48. package/mcp/tool-handlers.mjs +289 -87
  49. package/package.json +11 -2
  50. package/src/App.jsx +789 -263
  51. package/src/agent-image-client.js +94 -0
  52. package/src/analytics.js +47 -3
  53. package/src/app-stage.jsx +129 -2745
  54. package/src/ardy/auto-physics.js +6 -16
  55. package/src/ardy/collision-blockers.js +3 -7
  56. package/src/ardy/fix-collisions.js +5 -35
  57. package/src/ardy/ik-drag.js +86 -5
  58. package/src/ardy/ik-key-json.js +3 -1
  59. package/src/ardy/ik.js +15 -13
  60. package/src/ardy/motion-calibration.js +1 -1
  61. package/src/ardy/npz.js +0 -8
  62. package/src/ardy/physics-panel.jsx +3 -2
  63. package/src/ardy/physics-review.js +3 -2
  64. package/src/ardy/physics-support.js +1 -1
  65. package/src/ardy/physics-surface.js +1 -1
  66. package/src/ardy/platform-fit-panel.jsx +2 -1
  67. package/src/ardy/playback.js +1 -5
  68. package/src/ardy/range-pin.js +2 -2
  69. package/src/ardy/root-drop.js +0 -1
  70. package/src/ardy/timeline.css +184 -6
  71. package/src/ardy/timeline.jsx +113 -28
  72. package/src/ardy/to-cskel27.js +0 -2
  73. package/src/ardy/waypoints.js +0 -3
  74. package/src/ardy-bridge-contract.js +281 -0
  75. package/src/asset-pane.jsx +19 -9
  76. package/src/auto-color.js +1 -1
  77. package/src/burn-in.js +1 -1
  78. package/src/camera-block.js +1 -1
  79. package/src/camera-follow.js +75 -32
  80. package/src/camera-move.js +0 -6
  81. package/src/camera-tutorial.jsx +6 -7
  82. package/src/character-kind.js +79 -0
  83. package/src/command-bus.js +76 -17
  84. package/src/commands/cast.js +31 -7
  85. package/src/commands/elements/object.js +1 -1
  86. package/src/commands/elements/shot.js +1 -1
  87. package/src/commands/elements.js +1 -1
  88. package/src/commands/export.js +3 -3
  89. package/src/commands/motion.js +2 -2
  90. package/src/commands/objects.js +105 -10
  91. package/src/commands/project.js +15 -2
  92. package/src/commands/sculpt-images.js +53 -0
  93. package/src/commands/shot.js +88 -10
  94. package/src/commands/view.js +10 -4
  95. package/src/coplanar-depth.js +216 -0
  96. package/src/cuts.js +7 -6
  97. package/src/domains/cast.js +60 -43
  98. package/src/domains/motion/motion-intent-codec.js +50 -0
  99. package/src/domains/motion/motion-range-pin-rules.js +23 -0
  100. package/src/domains/motion/motion-video-capture.js +664 -0
  101. package/src/domains/motion.js +253 -742
  102. package/src/domains/objects.js +106 -5
  103. package/src/domains/scenes.js +79 -28
  104. package/src/domains/shots.js +33 -7
  105. package/src/error-boundary.jsx +1 -1
  106. package/src/execution-telemetry.js +5 -5
  107. package/src/facing-marks.js +14 -1
  108. package/src/first-success-guide.jsx +35 -4
  109. package/src/gizmo-claim.js +3 -1
  110. package/src/grid-floor.jsx +1 -4
  111. package/src/hierarchy-model.js +48 -35
  112. package/src/hierarchy-panel.css +1 -0
  113. package/src/hierarchy-panel.jsx +54 -102
  114. package/src/history.js +0 -2
  115. package/src/i2v-motion-client.js +1 -4
  116. package/src/ime.js +6 -0
  117. package/src/keyframe-pack.js +15 -6
  118. package/src/line-edit.js +4 -4
  119. package/src/live-control.js +6 -6
  120. package/src/matte-editor.js +2 -8
  121. package/src/matte.js +0 -19
  122. package/src/mesh-graph-clone.js +16 -2
  123. package/src/mesh-sniff.js +0 -1
  124. package/src/monotone-cubic.js +40 -0
  125. package/src/motion-store.js +4 -4
  126. package/src/motion-trail.js +5 -11
  127. package/src/multimodel-ingest.js +1 -3
  128. package/src/object-catalog.jsx +7 -5
  129. package/src/object-gizmo.jsx +45 -31
  130. package/src/object-path.js +310 -67
  131. package/src/object-travel.js +224 -0
  132. package/src/otio.js +8 -3
  133. package/src/panels/CharacterTransformPanel.jsx +24 -6
  134. package/src/panels/ObjectTransformPanel.jsx +4 -3
  135. package/src/panels/PoseImagePanel.jsx +205 -0
  136. package/src/panels/PromptBlocksPanel.jsx +3 -1
  137. package/src/panels/ProxyFigurePanel.jsx +56 -0
  138. package/src/panels/ReferenceImageField.jsx +1 -1
  139. package/src/panels/RigControlPanel.jsx +24 -3
  140. package/src/panels/RigPanel.jsx +2 -1
  141. package/src/panels/SculptPanel.jsx +405 -0
  142. package/src/panels/SubjectBox.jsx +1 -1
  143. package/src/panels/SubjectsPanel.jsx +0 -2
  144. package/src/panels/VideoCapturePanel.jsx +2 -1
  145. package/src/panels/pose-image.css +42 -0
  146. package/src/panels/pose.css +3 -0
  147. package/src/panels/sculpt.css +162 -0
  148. package/src/plan-diagram.js +184 -0
  149. package/src/planview.jsx +127 -80
  150. package/src/playground.js +3 -5
  151. package/src/pose-fade.js +77 -0
  152. package/src/pose-gizmo.css +23 -0
  153. package/src/pose-gizmo.js +96 -0
  154. package/src/posestudio.jsx +134 -83
  155. package/src/project-browser.css +53 -0
  156. package/src/project-browser.jsx +49 -4
  157. package/src/project.js +55 -24
  158. package/src/props.jsx +177 -36
  159. package/src/proxy-figure.jsx +69 -0
  160. package/src/range-pin-panel.jsx +0 -1
  161. package/src/receipt-findings.js +20 -0
  162. package/src/render-passes.js +0 -1
  163. package/src/resource-status.jsx +1 -1
  164. package/src/root-path.js +48 -0
  165. package/src/route-owner.js +52 -0
  166. package/src/scene-asset-cache.js +1 -2
  167. package/src/scene-assets.js +12 -4
  168. package/src/scene-fbx.js +1 -1
  169. package/src/scene-mesh-cache.js +0 -1
  170. package/src/scene-objects.js +319 -19
  171. package/src/scenes.js +58 -9
  172. package/src/sculpt-blueprint.js +65 -0
  173. package/src/sculpt-compare.js +384 -0
  174. package/src/sculpt-geometry.js +141 -0
  175. package/src/sculpt-mode.js +168 -0
  176. package/src/sculpt-recipe.js +385 -0
  177. package/src/sculpt-run.js +351 -0
  178. package/src/sculpt-stage.jsx +221 -0
  179. package/src/settings-menu.jsx +1 -1
  180. package/src/shell/BoardSlot.jsx +341 -0
  181. package/src/shell/BottomDock.jsx +58 -14
  182. package/src/shell/DetailsSlot.jsx +26 -6
  183. package/src/shell/MenuBar.jsx +78 -6
  184. package/src/shell/OutlinerSlot.jsx +3 -1
  185. package/src/shell/TopBar.jsx +17 -3
  186. package/src/shell/ViewportToolbar.jsx +18 -3
  187. package/src/shell/dock.css +239 -0
  188. package/src/shell/glass-regions.css +3 -2
  189. package/src/shell/log-store.js +4 -4
  190. package/src/shell/mode.css +20 -0
  191. package/src/shell/studio-shell-context.js +4 -1
  192. package/src/shot-authoring.js +69 -6
  193. package/src/stable-items.js +1 -1
  194. package/src/stage-attach-frames.js +169 -0
  195. package/src/stage-canvas-rigs.jsx +402 -0
  196. package/src/stage-capture.jsx +216 -0
  197. package/src/stage-characters.jsx +302 -0
  198. package/src/stage-editor-gizmos.jsx +1229 -0
  199. package/src/stage-labels-ko.js +204 -0
  200. package/src/stage-startup-defaults.js +182 -0
  201. package/src/starter-scenes.js +1 -1
  202. package/src/storyboard.js +24 -5
  203. package/src/studio-actions.js +42 -9
  204. package/src/studio-agent-commands.js +186 -23
  205. package/src/studio-agent-context.js +11 -9
  206. package/src/studio-agent-motion.js +2 -1
  207. package/src/studio-agent-protocol.js +56 -23
  208. package/src/studio-app-binding.js +52 -23
  209. package/src/studio-contact-sheet.js +3 -3
  210. package/src/studio-elements.js +11 -2
  211. package/src/studio-geometry-facts.js +114 -0
  212. package/src/styles.css +92 -9
  213. package/src/ui-scrub.js +4 -4
  214. package/src/ui.jsx +6 -4
  215. package/src/use-asset-shelf.js +60 -0
  216. package/src/use-camera-tutorial.js +50 -0
  217. package/src/use-embed-bridge.js +12 -0
  218. package/src/use-first-success-prompt.js +7 -0
  219. package/src/use-i2v-capture-modal.js +15 -0
  220. package/src/use-sculpt-mode.js +95 -0
  221. package/src/workflow/AgentPanel.jsx +106 -18
  222. package/src/workflow/WorkflowBuilder.jsx +1 -1
  223. package/src/workflow/agent-client.js +113 -45
  224. package/src/workflow/agent-panel.css +35 -0
  225. package/src/workflow/attachment-image.js +1 -1
  226. package/src/workflow/canvas-commands.js +3 -3
  227. package/src/workflow/cozy-scene-node.js +3 -3
  228. package/src/workflow/local-workflow.js +2 -2
  229. package/src/workflow/motion-input.js +3 -3
  230. package/src/workflow/node-schema.js +1 -1
  231. package/src/workflow/scene-asset-sync.js +2 -9
  232. package/src/workflow/shot-prompt-node.js +1 -1
  233. package/src/workflow/video-contract.js +1 -1
  234. package/src/workflow/workflow-resources.js +0 -8
  235. package/src/workflow-send.js +87 -0
  236. package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
  237. package/tools/check-eslint.mjs +57 -0
  238. package/tools/check-knip.mjs +60 -0
  239. package/tools/check-loc.mjs +54 -0
  240. package/tools/demo-worker/api-client.mjs +7 -13
  241. package/tools/demo-worker/generate.mjs +2 -4
  242. package/tools/eslint-baseline.json +23 -0
  243. package/tools/kimodo/effector-constraints.mjs +4 -4
  244. package/tools/kimodo/generate.mjs +1 -1
  245. package/tools/knip-baseline.json +331 -0
  246. package/tools/loc-baseline.json +126 -0
  247. package/tools/process-supervisor.mjs +1 -1
  248. package/tools/projflow/replay.mjs +4 -4
  249. package/tools/projflow/runner.mjs +3 -3
  250. package/tools/qa/studio-control-count.mjs +2 -0
  251. package/tools/run-tests.mjs +94 -7
  252. package/dist/assets/analytics-MVZOEW8O.js +0 -1
  253. package/dist/assets/app-BxBLC6GT.js +0 -4876
  254. package/dist/assets/app-CJdxvnrl.css +0 -1
  255. package/dist/assets/shot-prompt-hN0j_O-p.js +0 -17
  256. package/dist/assets/workflow-B5XZwVfe.js +0 -179
@@ -0,0 +1,162 @@
1
+ /* Unlayered and rooted like src/panels/details.css: the Foldout's v2 Details primitives
2
+ (.v2-details-section.v2-details-section .btn / .inspector-hint) are unlayered too, so the
3
+ overrides below repeat that root to win on specificity. */
4
+ /* One shrinkable column: an image's intrinsic width must not widen the Details column. */
5
+ .sculpt-panel { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--sp-1); min-width: 0; padding: 4px var(--sp-2) var(--sp-2); }
6
+ .sculpt-panel > *, .sculpt-sheet > * { min-width: 0; }
7
+ .v2-details-section.v2-details-section .sculpt-panel .btn.full { margin-top: 0; }
8
+ .v2-details-section.v2-details-section .sculpt-panel .btn:disabled { opacity: .45; cursor: not-allowed; }
9
+ .sculpt-label { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-1); color: var(--text-2); font-size: 11px; font-weight: 500; white-space: nowrap; }
10
+ .sculpt-label small { min-width: 0; overflow: hidden; text-overflow: ellipsis; font-size: 11px; font-variant-numeric: tabular-nums; color: var(--text-3); }
11
+
12
+ /* Reference well */
13
+ .sculpt-well { position: relative; border-radius: var(--r-2); border: 1px solid var(--row-divider); background: var(--field); overflow: hidden; }
14
+ .sculpt-well-empty { display: grid; justify-items: center; gap: 4px; padding: var(--sp-3) var(--sp-2); border-style: dashed; border-color: var(--divider-strong); color: var(--text-2); text-align: center; cursor: pointer; transition: border-color var(--fast), background var(--fast); }
15
+ .sculpt-well-empty:hover, .sculpt-well-empty[data-dragging="true"] { border-color: var(--select); background: var(--select-tint); color: var(--text-1); }
16
+ .sculpt-well-empty:focus-visible { outline: none; border-color: var(--select); box-shadow: 0 0 0 2px var(--select-tint); }
17
+ .sculpt-well-title { font-size: 12px; font-weight: 500; color: var(--text-1); }
18
+ .sculpt-well-sub { font-size: 11px; color: var(--text-3); }
19
+ .v2-details-section.v2-details-section .sculpt-well-empty .btn.ghost { width: auto; margin-top: 4px; pointer-events: none; }
20
+ .sculpt-well-filled { display: grid; justify-items: center; background: var(--surface-0); }
21
+ /* The frame shrink-wraps the picture, so the measured slices land on the subject. */
22
+ .sculpt-ref-frame { position: relative; max-width: 100%; }
23
+ .sculpt-ref-frame img { display: block; max-width: 100%; max-height: 220px; }
24
+ .v2-details-section.v2-details-section .sculpt-well-replace { position: absolute; right: 6px; bottom: 6px; width: auto; background: var(--overlay); color: var(--text-1); }
25
+
26
+ /* Primary action */
27
+ .v2-details-section.v2-details-section .sculpt-cancel { color: var(--danger); background: none; box-shadow: inset 0 0 0 1px var(--danger); }
28
+ .v2-details-section.v2-details-section .sculpt-cancel:hover { background: var(--surface-active); color: var(--danger); }
29
+ .v2-details-section.v2-details-section .sculpt-refusal { color: var(--text-2); }
30
+
31
+ /* Progress */
32
+ .sculpt-progress { display: grid; gap: var(--sp-1); padding: var(--sp-1); border-radius: var(--r-2); background: var(--surface-raised); }
33
+ .sculpt-stepper { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 0; padding: 0; list-style: none; }
34
+ .sculpt-step { position: relative; display: grid; justify-items: center; gap: 4px; color: var(--text-3); font-size: 10.5px; white-space: nowrap; letter-spacing: -.01em; }
35
+ /* Connector from the previous step's dot to this one. */
36
+ .sculpt-step + .sculpt-step::before { content: ""; position: absolute; top: 4px; right: 50%; width: 100%; height: 1px; background: var(--divider-strong); }
37
+ .sculpt-step[data-state="complete"]::before, .sculpt-step[data-state="current"]::before { background: var(--select); }
38
+ .sculpt-step-dot { position: relative; z-index: 1; width: 9px; height: 9px; border-radius: 50%; border: 1px solid var(--divider-strong); background: var(--surface-raised); }
39
+ .sculpt-step[data-state="complete"] { color: var(--text-2); }
40
+ .sculpt-step[data-state="complete"] .sculpt-step-dot { background: var(--select); border-color: var(--select); }
41
+ .sculpt-step[data-state="current"] { color: var(--text-1); font-weight: 600; }
42
+ .sculpt-step[data-state="current"] .sculpt-step-dot { border-color: var(--select); background: var(--select); box-shadow: 0 0 0 3px var(--select-tint); animation: sculpt-pulse 1.4s var(--ease) infinite; }
43
+ @keyframes sculpt-pulse { 50% { box-shadow: 0 0 0 5px var(--select-tint); } }
44
+ @media (prefers-reduced-motion: reduce) { .sculpt-step[data-state="current"] .sculpt-step-dot { animation: none; } }
45
+
46
+ .sculpt-stats { display: flex; justify-content: space-between; color: var(--text-2); font-size: 11px; font-variant-numeric: tabular-nums; }
47
+ .sculpt-iou { display: grid; gap: 4px; }
48
+ .sculpt-iou .sculpt-label small { color: var(--text-1); font-family: var(--mono); }
49
+ .sculpt-iou-target { color: var(--text-3); }
50
+ .sculpt-iou-bar { position: relative; height: 6px; border-radius: 3px; background: var(--field); }
51
+ .sculpt-iou-fill { position: absolute; inset: 0 auto 0 0; width: calc(var(--sculpt-iou) * 100%); border-radius: inherit; background: var(--primary); transition: width var(--med) var(--ease); }
52
+ .sculpt-iou-bar[data-reached="true"] .sculpt-iou-fill { background: var(--ok); }
53
+ .sculpt-iou-marker { position: absolute; top: -3px; bottom: -3px; left: calc(var(--sculpt-target) * 100%); width: 2px; margin-left: -1px; border-radius: 1px; background: var(--select); }
54
+ .v2-details-section.v2-details-section .sculpt-done { color: var(--ok); }
55
+ .v2-details-section.v2-details-section .sculpt-error { color: var(--danger); }
56
+
57
+ /* Overlay sheet */
58
+ .sculpt-sheet { display: grid; grid-template-columns: minmax(0, 1fr); gap: 6px; }
59
+ .v2-details-section.v2-details-section .sculpt-sheet .btn[aria-pressed="true"] { color: var(--text-1); box-shadow: inset 0 0 0 1px var(--select); }
60
+ .sculpt-sheet-image { display: block; width: 100%; border-radius: var(--r-1); border: 1px solid var(--row-divider); background: var(--surface-0); }
61
+
62
+
63
+ /* --------------------------------------------------- live narration (#739) ---
64
+ Everything below says "it is working": a lump of clay being kneaded while we
65
+ wait, a bar against the usual wait, the score trail, the candidate race and
66
+ the log. Motion stops under prefers-reduced-motion; the words stay. */
67
+
68
+ /* What we wait on now */
69
+ .sculpt-now { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: start; column-gap: 8px; row-gap: 4px; padding: 6px 8px; border-radius: var(--r-1); background: var(--field); font-size: 12px; color: var(--text-1); }
70
+ .sculpt-now-label { min-width: 0; overflow-wrap: anywhere; line-height: 1.35; font-weight: 500; }
71
+ .sculpt-now-time { font-family: var(--mono); font-variant-numeric: tabular-nums; color: var(--text-2); }
72
+ .sculpt-now-expect { grid-column: 2 / 4; font-size: 11px; color: var(--text-3); }
73
+ .sculpt-now[data-over="true"] .sculpt-now-expect { color: var(--warn, var(--text-2)); }
74
+ .sculpt-now-bar { grid-column: 1 / -1; position: relative; height: 3px; border-radius: 2px; background: var(--surface-active); overflow: hidden; }
75
+ .sculpt-now-bar > span { position: absolute; inset: 0 auto 0 0; width: calc(var(--sculpt-wait) * 100%); background: var(--select); border-radius: inherit; transition: width .5s linear; }
76
+ .sculpt-now[data-over="true"] .sculpt-now-bar > span { width: 100%; background: linear-gradient(90deg, var(--select-tint), var(--select), var(--select-tint)); background-size: 200% 100%; animation: sculpt-shimmer 1.2s linear infinite; }
77
+ @keyframes sculpt-shimmer { to { background-position: -200% 0; } }
78
+
79
+ /* A lump of clay being kneaded: squash, stretch, a thumb press. */
80
+ .sculpt-clay { width: 16px; height: 16px; margin-top: 1px; border-radius: 52% 48% 46% 54% / 55% 50% 50% 45%; background: radial-gradient(circle at 35% 30%, #e9c9a6, #c08a5c 70%); box-shadow: inset -2px -2px 0 rgba(0, 0, 0, .12); animation: sculpt-knead 1.1s ease-in-out infinite; }
81
+ @keyframes sculpt-knead {
82
+ 0%, 100% { transform: scale(1, 1) rotate(0deg); border-radius: 52% 48% 46% 54% / 55% 50% 50% 45%; }
83
+ 30% { transform: scale(1.18, .82) rotate(-6deg); border-radius: 60% 40% 55% 45% / 45% 55% 45% 55%; }
84
+ 60% { transform: scale(.86, 1.14) rotate(5deg); border-radius: 45% 55% 40% 60% / 60% 40% 60% 40%; }
85
+ }
86
+ .sculpt-now[data-what="score"] .sculpt-clay, .sculpt-now[data-what="measure"] .sculpt-clay { animation-duration: .6s; }
87
+
88
+ /* IoU trail */
89
+ .sculpt-trail { position: relative; height: 34px; }
90
+ .sculpt-trail svg { display: block; width: 100%; height: 100%; overflow: visible; }
91
+ .sculpt-trail-target { stroke: var(--select); stroke-width: 1; stroke-dasharray: 2 2; vector-effect: non-scaling-stroke; opacity: .7; }
92
+ .sculpt-trail-line { fill: none; stroke: var(--primary); stroke-width: 1.5; vector-effect: non-scaling-stroke; }
93
+ .sculpt-trail-dot { fill: var(--primary); }
94
+ .sculpt-trail-dot[data-last="true"] { fill: var(--ok); animation: sculpt-dot 1.6s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
95
+ @keyframes sculpt-dot { 50% { transform: scale(1.6); } }
96
+ .sculpt-delta { position: absolute; right: 0; top: -4px; padding: 1px 6px; border-radius: 9px; font: 600 11px/1.4 var(--mono); background: var(--surface-raised); box-shadow: 0 0 0 1px var(--row-divider); animation: sculpt-float 1.8s var(--ease) both; }
97
+ .sculpt-delta[data-sign="up"] { color: var(--ok); }
98
+ .sculpt-delta[data-sign="down"] { color: var(--danger); }
99
+ @keyframes sculpt-float { from { opacity: 0; transform: translateY(8px) scale(.9); } 25% { opacity: 1; transform: none; } }
100
+
101
+ /* Candidate race */
102
+ .sculpt-race { display: grid; gap: 3px; margin: 0; padding: 6px 8px; list-style: none; border-radius: var(--r-1); background: var(--field); }
103
+ .sculpt-race-row { display: grid; grid-template-columns: minmax(0, 7fr) minmax(0, 6fr) auto; align-items: center; gap: 6px; font-size: 11px; color: var(--text-2); }
104
+ .sculpt-race-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
105
+ .sculpt-race-bar { height: 5px; border-radius: 3px; background: var(--surface-active); overflow: hidden; }
106
+ .sculpt-race-bar > span { display: block; height: 100%; width: calc(var(--sculpt-race) * 100%); border-radius: inherit; background: var(--text-3); transform-origin: left; animation: sculpt-grow .7s var(--ease) both; animation-delay: var(--sculpt-race-delay); }
107
+ @keyframes sculpt-grow { from { transform: scaleX(0); } }
108
+ .sculpt-race-score { font-family: var(--mono); font-variant-numeric: tabular-nums; }
109
+ .sculpt-race-row[data-leader="true"] { color: var(--text-1); font-weight: 600; }
110
+ .sculpt-race-row[data-leader="true"] .sculpt-race-bar > span { background: var(--ok); }
111
+ .sculpt-race-row[data-leader="true"] .sculpt-race-label::before { content: "\2605 "; color: var(--ok); }
112
+ .sculpt-race-row[data-refused="true"] { opacity: .5; text-decoration: line-through; }
113
+
114
+ /* Activity log */
115
+ .sculpt-log { display: grid; gap: 2px; max-height: 168px; margin: 0; padding: 4px 2px; list-style: none; overflow-y: auto; overscroll-behavior: contain; scroll-behavior: smooth; }
116
+ .sculpt-log-row { display: grid; grid-template-columns: 30px 8px minmax(0, 1fr); align-items: baseline; gap: 6px; font-size: 11px; line-height: 1.4; color: var(--text-2); animation: sculpt-in .35s var(--ease) both; }
117
+ .sculpt-log-row:last-child { color: var(--text-1); }
118
+ @keyframes sculpt-in { from { opacity: 0; transform: translateY(4px); } }
119
+ .sculpt-log-time { font-family: var(--mono); font-variant-numeric: tabular-nums; color: var(--text-3); }
120
+ .sculpt-log-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--text-3); align-self: center; }
121
+ .sculpt-log-row[data-kind="agent"] .sculpt-log-dot { background: var(--select); }
122
+ .sculpt-log-row[data-kind="ok"] .sculpt-log-dot, .sculpt-log-row[data-kind="done"] .sculpt-log-dot { background: var(--ok); }
123
+ .sculpt-log-row[data-kind="score"] .sculpt-log-dot { background: var(--primary); }
124
+ .sculpt-log-row[data-kind="warn"] .sculpt-log-dot { background: var(--warn, #d9a441); }
125
+ .sculpt-log-row[data-kind="error"] .sculpt-log-dot { background: var(--danger); }
126
+ .sculpt-log-row[data-kind="done"] { color: var(--ok); font-weight: 600; }
127
+ .sculpt-log-text { min-width: 0; overflow-wrap: anywhere; }
128
+
129
+ /* Measuring the reference: a scan line, then the slices it found */
130
+ .sculpt-scanline { position: absolute; left: 0; right: 0; top: 0; height: 2px; background: var(--select); box-shadow: 0 0 8px 2px var(--select-tint); animation: sculpt-scan 1.1s ease-in-out infinite alternate; pointer-events: none; }
131
+ @keyframes sculpt-scan { to { top: calc(100% - 2px); } }
132
+ .sculpt-scan { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
133
+ .sculpt-scan-span { fill: var(--select); opacity: .8; animation: sculpt-slice .45s var(--ease) both; }
134
+ @keyframes sculpt-slice { from { opacity: 0; transform: scaleX(0); } }
135
+ .sculpt-scan-span { transform-box: fill-box; transform-origin: center; }
136
+
137
+ @media (prefers-reduced-motion: reduce) {
138
+ .sculpt-clay, .sculpt-trail-dot, .sculpt-delta, .sculpt-race-bar > span, .sculpt-log-row, .sculpt-scanline, .sculpt-scan-span, .sculpt-now[data-over="true"] .sculpt-now-bar > span { animation: none; }
139
+ }
140
+
141
+ /* Model and effort */
142
+ .sculpt-model { display: grid; gap: 4px; }
143
+ .sculpt-model-row { display: grid; grid-template-columns: minmax(0, 1fr); gap: 4px; }
144
+ .sculpt-select { min-width: 0; height: 26px; padding: 0 6px; border-radius: var(--r-1); border: 1px solid var(--row-divider); background: var(--field); color: var(--text-1); font: inherit; font-size: 12px; }
145
+ .sculpt-select:disabled { opacity: .6; }
146
+
147
+
148
+ /* Blueprint (model sheet) */
149
+ .sculpt-blueprint { display: grid; grid-template-columns: minmax(0, 1fr); gap: 6px; min-width: 0; padding: 8px; border-radius: var(--r-2); background: var(--surface-raised); }
150
+ .sculpt-blueprint-row { display: grid; grid-template-columns: minmax(0, 1fr); min-width: 0; }
151
+ .sculpt-blueprint > * { min-width: 0; }
152
+ .sculpt-blueprint .sculpt-label small { overflow: hidden; text-overflow: ellipsis; }
153
+ .sculpt-notes { box-sizing: border-box; width: 100%; min-width: 0; resize: vertical; padding: 6px; border-radius: var(--r-1); border: 1px solid var(--row-divider); background: var(--field); color: var(--text-1); font: inherit; font-size: 12px; }
154
+ .sculpt-blueprint .sculpt-now .btn { grid-column: 1 / -1; justify-self: end; width: auto; }
155
+ .sculpt-sheet-figure { display: grid; gap: 4px; margin: 0; }
156
+ .sculpt-sheet-figure > a { position: relative; display: block; border-radius: var(--r-1); overflow: hidden; border: 1px solid var(--row-divider); background: #fff; }
157
+ .sculpt-sheet-figure img { display: block; width: 100%; }
158
+ .sculpt-sheet-tag { position: absolute; margin: 3px; padding: 0 4px; border-radius: 3px; font-size: 9.5px; line-height: 1.5; color: #fff; background: rgba(20, 22, 28, .62); pointer-events: none; }
159
+ .sculpt-sheet-figure figcaption { display: flex; justify-content: space-between; font-size: 11px; color: var(--text-3); }
160
+ .sculpt-sheet-figure figcaption a { color: var(--select); }
161
+ .sculpt-prompt summary { cursor: pointer; font-size: 11px; color: var(--text-2); }
162
+ .sculpt-prompt pre { max-height: 180px; margin: 4px 0 0; padding: 6px; overflow: auto; white-space: pre-wrap; border-radius: var(--r-1); background: var(--field); font: 10.5px/1.45 var(--mono); color: var(--text-2); }
@@ -0,0 +1,184 @@
1
+ // The Top View the Studio agent reads (verify_result visual "plan",
2
+ // capture_plan_png): a 2D data diagram drawn from editor state, not a render.
3
+ // planDiagramLayout computes the world->pixel transform and a list of
4
+ // primitives; drawPlanDiagram paints them on any 2D canvas context.
5
+ // Pure: no renderer, no React, no DOM.
6
+ import { isEffectivelyHidden } from './scene-objects.js';
7
+ import { shotAtFrame } from './cuts.js';
8
+ import { occludersOf, primarySubjects, shotCamera, sideOfAxis } from './studio-geometry-facts.js';
9
+ import { entityWorldBounds } from './studio-agent-commands.js';
10
+
11
+ export const PLAN_DIAGRAM = Object.freeze({ marginM: 1.5, maxSpanM: 30, minSpanM: 6, characterRadiusM: 0.3, frustumM: 3, defaultFovDeg: 40 });
12
+ const SIDE_LABEL = { left: 'L', right: 'R', 'on-axis': 'on-axis' };
13
+ const GROUP_HUES = ['#c9a46a', '#a8c47a', '#d39a8c', '#8fb7c9', '#c0a0d0', '#d6c06a'];
14
+ const round = v => Math.round(v * 1000) / 1000 + 0;
15
+ const flat = p => ({ x: round(p.x), z: round(p.z) });
16
+
17
+ function groupRoot(object, objects) {
18
+ let node = object;
19
+ for (let guard = 0; node?.parent && guard < 64; guard += 1) {
20
+ const parent = objects.find(o => o.id === node.parent);
21
+ if (!parent) break;
22
+ node = parent;
23
+ }
24
+ return node;
25
+ }
26
+
27
+ /** Footprint corners in world x/z: width*scaleX by depth*scaleZ, turned by rot (yaw deg, 0 = +Z, positive toward +X). */
28
+ function footprintCorners(o) {
29
+ const w = (o.footprint?.width ?? 0) * (o.scaleX ?? 1) / 2, d = (o.footprint?.depth ?? 0) * (o.scaleZ ?? 1) / 2;
30
+ const yaw = (o.rot ?? 0) * Math.PI / 180, c = Math.cos(yaw), s = Math.sin(yaw);
31
+ return [[-w, -d], [w, -d], [w, d], [-w, d]].map(([lx, lz]) => flat({ x: o.x + lx * c + lz * s, z: o.z - lx * s + lz * c }));
32
+ }
33
+
34
+ /**
35
+ * The diagram's content and framing. `options`: width/height of the output in
36
+ * pixels, `ports` for world bounds (occluders), `currentShotId` (defaults to
37
+ * the shot under state.frame), `aspect` of the shot frame for the horizontal FOV.
38
+ */
39
+ export function planDiagramLayout(state, { width = 1280, height = 720, ports, currentShotId, aspect = 16 / 9 } = {}) {
40
+ const visible = e => !isEffectivelyHidden(e, state.objects, state.characters);
41
+ const characters = state.characters.filter(visible);
42
+ // A carried prop's numbers are local to the bone it rides, so it has no floor spot of its own.
43
+ const objects = state.objects.filter(o => visible(o) && !o.attach);
44
+ const shots = [...(state.shotDocument?.shots ?? [])].sort((a, b) => a.startFrame - b.startFrame);
45
+ const currentId = currentShotId ?? shotAtFrame(shots, state.frame ?? 0)?.id ?? null;
46
+ const primitives = [];
47
+
48
+ // Largest footprints first, so a ground plane never covers the props standing on it.
49
+ const area = o => (o.footprint?.width ?? 0) * (o.scaleX ?? 1) * (o.footprint?.depth ?? 0) * (o.scaleZ ?? 1);
50
+ for (const o of [...objects].sort((p, q) => area(q) - area(p))) {
51
+ const root = groupRoot(o, state.objects), grouped = root !== o || state.objects.some(child => child.parent === o.id);
52
+ const hue = grouped ? GROUP_HUES[[...root.id].reduce((sum, ch) => sum + ch.charCodeAt(0), 0) % GROUP_HUES.length] : '#d8c39a';
53
+ primitives.push({ kind: 'object', id: o.id, label: o.name || o.id, center: flat(o), corners: footprintCorners(o), groupId: grouped ? root.id : null, fill: hue });
54
+ }
55
+ for (const c of characters) {
56
+ const yaw = (c.rot ?? 0) * Math.PI / 180;
57
+ primitives.push({ kind: 'character', id: c.id, label: c.subject || c.id, x: round(c.x), z: round(c.z), radius: PLAN_DIAGRAM.characterRadiusM, facing: flat({ x: Math.sin(yaw), z: Math.cos(yaw) }) });
58
+ }
59
+ shots.forEach((shot, index) => {
60
+ const camera = shotCamera(shot, state);
61
+ if (!camera) return;
62
+ const fovDeg = shot.cameraKeys?.[0]?.framing?.fovDeg ?? PLAN_DIAGRAM.defaultFovDeg;
63
+ const halfFov = Math.atan(Math.tan(fovDeg * Math.PI / 360) * aspect), heading = Math.atan2(camera.lookAt.x - camera.position.x, camera.lookAt.z - camera.position.z);
64
+ const edge = angle => flat({ x: camera.position.x + Math.sin(angle) * PLAN_DIAGRAM.frustumM, z: camera.position.z + Math.cos(angle) * PLAN_DIAGRAM.frustumM });
65
+ const current = shot.id === currentId, entry = { kind: 'camera', shotId: shot.id, label: `S${index + 1}`, current, position: flat(camera.position), lookAt: flat(camera.lookAt),
66
+ halfFovRad: round(halfFov), frustum: [edge(heading - halfFov), edge(heading + halfFov)], cameraSide: null };
67
+ primitives.push(entry);
68
+ if (!current) return;
69
+ const pair = primarySubjects(shot.subjectIds ?? [], camera.position, state);
70
+ if (pair.length !== 2) return;
71
+ entry.cameraSide = SIDE_LABEL[sideOfAxis(pair[0], pair[1], camera.position)] ?? null;
72
+ primitives.push({ kind: 'actionLine', shotId: shot.id, subjectIds: pair.map(c => c.id), a: flat(pair[0]), b: flat(pair[1]), distanceM: round(Math.hypot(pair[1].x - pair[0].x, pair[1].z - pair[0].z)), cameraSide: entry.cameraSide });
73
+ if (!ports) return;
74
+ for (const subject of pair) {
75
+ const box = entityWorldBounds(subject, state, ports);
76
+ if (!box) continue;
77
+ for (const hit of occludersOf(subject, camera.position, state, ports, box)) {
78
+ const blocker = state.objects.find(o => o.id === hit.byObjectId);
79
+ primitives.push({ kind: 'occluder', shotId: shot.id, subjectId: hit.subjectId, byObjectId: hit.byObjectId, corners: blocker && !blocker.attach ? footprintCorners(blocker) : [],
80
+ segment: { from: flat(camera.position), to: flat({ x: (box.min.x + box.max.x) / 2, z: (box.min.z + box.max.z) / 2 }) } });
81
+ }
82
+ }
83
+ });
84
+
85
+ // Fit everything visible plus a margin, keep the output aspect, cap the span.
86
+ // A capped view centres on the cast and cameras, so the action stays in frame.
87
+ const box = points => {
88
+ const xs = points.map(p => p.x), zs = points.map(p => p.z);
89
+ return xs.length ? { min: { x: Math.min(...xs), z: Math.min(...zs) }, max: { x: Math.max(...xs), z: Math.max(...zs) } } : null;
90
+ };
91
+ const action = box(primitives.flatMap(p => p.kind === 'character' ? [p] : p.kind === 'camera' ? [p.position] : []));
92
+ const { min, max } = box(primitives.flatMap(p => p.kind === 'object' ? [p.center, ...p.corners] : p.kind === 'character' ? [p] : p.kind === 'camera' ? [p.position] : [])) ?? { min: { x: 0, z: 0 }, max: { x: 0, z: 0 } };
93
+ const pixelAspect = width / height;
94
+ let spanX = max.x - min.x + 2 * PLAN_DIAGRAM.marginM, spanZ = max.z - min.z + 2 * PLAN_DIAGRAM.marginM;
95
+ spanZ = Math.max(spanZ, PLAN_DIAGRAM.minSpanM);
96
+ if (spanX / spanZ < pixelAspect) spanX = spanZ * pixelAspect; else spanZ = spanX / pixelAspect;
97
+ const cap = Math.min(1, PLAN_DIAGRAM.maxSpanM / Math.max(spanX, spanZ));
98
+ spanX *= cap; spanZ *= cap;
99
+ const focus = cap < 1 && action ? action : { min, max }, centre = { x: (focus.min.x + focus.max.x) / 2, z: (focus.min.z + focus.max.z) / 2 };
100
+ const scale = width / spanX, view = { minX: centre.x - spanX / 2, maxX: centre.x + spanX / 2, minZ: centre.z - spanZ / 2, maxZ: centre.z + spanZ / 2 };
101
+ primitives.unshift({ kind: 'grid', stepM: 1, labelStepM: 2, ...Object.fromEntries(Object.entries(view).map(([k, v]) => [k, round(v)])) });
102
+ // +X to the right, +Z down the image.
103
+ const toPixel = p => [(p.x - view.minX) * scale, (p.z - view.minZ) * scale];
104
+ return { width, height, scale: round(scale), view, toPixel, currentShotId: currentId, primitives };
105
+ }
106
+
107
+ /** Paint the plan diagram on a 2D context of the output size; returns the layout. */
108
+ export function drawPlanDiagram(ctx, state, options = {}) {
109
+ const layout = planDiagramLayout(state, options), { width, height, scale, view, toPixel } = layout;
110
+ const path = (corners, close = true) => {
111
+ ctx.beginPath();
112
+ corners.forEach((p, i) => { const [x, y] = toPixel(p); i ? ctx.lineTo(x, y) : ctx.moveTo(x, y); });
113
+ if (close) ctx.closePath();
114
+ };
115
+ const text = (value, x, y, colour = '#222', align = 'left') => {
116
+ ctx.textAlign = align; ctx.lineWidth = 3; ctx.strokeStyle = 'rgba(250, 248, 242, 0.9)'; ctx.fillStyle = colour;
117
+ ctx.strokeText(value, x, y); ctx.fillText(value, x, y);
118
+ };
119
+ ctx.fillStyle = '#f7f5ef'; ctx.fillRect(0, 0, width, height);
120
+ ctx.font = '500 13px system-ui, sans-serif'; ctx.textBaseline = 'middle';
121
+ for (const p of layout.primitives) {
122
+ if (p.kind === 'grid') {
123
+ for (let x = Math.ceil(view.minX); x <= view.maxX; x += 1) {
124
+ const [px] = toPixel({ x, z: 0 });
125
+ ctx.strokeStyle = x === 0 ? '#9a968c' : x % 2 ? '#e8e5dc' : '#d9d5ca'; ctx.lineWidth = x === 0 ? 1.5 : 1;
126
+ ctx.beginPath(); ctx.moveTo(px, 0); ctx.lineTo(px, height); ctx.stroke();
127
+ if (x % 2 === 0) text(`x ${x}`, px + 3, height - 10, '#6d695f');
128
+ }
129
+ for (let z = Math.ceil(view.minZ); z <= view.maxZ; z += 1) {
130
+ const [, py] = toPixel({ x: 0, z });
131
+ ctx.strokeStyle = z === 0 ? '#9a968c' : z % 2 ? '#e8e5dc' : '#d9d5ca'; ctx.lineWidth = z === 0 ? 1.5 : 1;
132
+ ctx.beginPath(); ctx.moveTo(0, py); ctx.lineTo(width, py); ctx.stroke();
133
+ if (z % 2 === 0) text(`z ${z}`, 4, py - 8, '#6d695f');
134
+ }
135
+ const [ox, oy] = toPixel({ x: 0, z: 0 });
136
+ ctx.strokeStyle = '#5c584f'; ctx.lineWidth = 2;
137
+ ctx.beginPath(); ctx.arc(ox, oy, 5, 0, Math.PI * 2); ctx.stroke();
138
+ text('0', ox + 8, oy + 10, '#5c584f');
139
+ } else if (p.kind === 'object') {
140
+ ctx.fillStyle = p.fill; ctx.strokeStyle = '#8a7350'; ctx.lineWidth = 1.5;
141
+ if (p.corners.some((c, i) => c.x !== p.corners[(i + 2) % 4].x || c.z !== p.corners[(i + 2) % 4].z)) { path(p.corners); ctx.fill(); ctx.stroke(); }
142
+ const [x, y] = toPixel(p.center);
143
+ text(p.label.length > 24 ? `${p.label.slice(0, 23)}\u2026` : p.label, x, y, '#4a3b22', 'center');
144
+ } else if (p.kind === 'character') {
145
+ const [x, y] = toPixel(p), r = p.radius * scale, [fx, fy] = toPixel({ x: p.x + p.facing.x * (p.radius + 0.35), z: p.z + p.facing.z * (p.radius + 0.35) });
146
+ ctx.fillStyle = '#6ea8e0'; ctx.strokeStyle = '#2f6aa8'; ctx.lineWidth = 2;
147
+ ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
148
+ ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(fx, fy); ctx.stroke();
149
+ const head = Math.atan2(fy - y, fx - x);
150
+ ctx.beginPath(); ctx.moveTo(fx, fy); ctx.lineTo(fx - 8 * Math.cos(head - 0.5), fy - 8 * Math.sin(head - 0.5)); ctx.lineTo(fx - 8 * Math.cos(head + 0.5), fy - 8 * Math.sin(head + 0.5)); ctx.closePath(); ctx.fillStyle = '#2f6aa8'; ctx.fill();
151
+ // The name goes behind the character, so two people facing each other keep their labels apart.
152
+ const behind = p.facing.x >= 0 ? -1 : 1, name = p.label.length > 24 ? `${p.label.slice(0, 23)}\u2026` : p.label;
153
+ text(name, x + behind * (r + 4), y - r - 6, '#1f4f80', behind < 0 ? 'right' : 'left');
154
+ } else if (p.kind === 'camera') {
155
+ const [cx, cy] = toPixel(p.position), [lx, ly] = toPixel(p.lookAt);
156
+ ctx.fillStyle = p.current ? 'rgba(214, 64, 52, 0.22)' : 'rgba(120, 120, 120, 0.14)'; ctx.strokeStyle = p.current ? '#d64034' : '#7d7a73'; ctx.lineWidth = p.current ? 2 : 1.25;
157
+ path([p.position, ...p.frustum]); ctx.fill(); ctx.stroke();
158
+ ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(lx, ly); ctx.stroke();
159
+ ctx.beginPath(); ctx.arc(cx, cy, 6, 0, Math.PI * 2);
160
+ if (p.current) { ctx.fillStyle = '#d64034'; ctx.fill(); } else { ctx.fillStyle = '#ffffff'; ctx.fill(); ctx.stroke(); }
161
+ text(p.cameraSide ? `${p.label} (${p.cameraSide})` : p.label, cx + 9, cy + 12, p.current ? '#b02d22' : '#55524b');
162
+ } else if (p.kind === 'actionLine') {
163
+ const [ax, ay] = toPixel(p.a), [bx, by] = toPixel(p.b);
164
+ ctx.strokeStyle = '#3c7d3a'; ctx.lineWidth = 2; ctx.setLineDash([8, 6]);
165
+ ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(bx, by); ctx.stroke(); ctx.setLineDash([]);
166
+ text(`${p.distanceM.toFixed(2)} m`, (ax + bx) / 2, (ay + by) / 2 - 12, '#2d5f2b', 'center');
167
+ } else if (p.kind === 'occluder') {
168
+ ctx.strokeStyle = '#e0241b'; ctx.lineWidth = 3;
169
+ if (p.corners.length) { path(p.corners); ctx.stroke(); }
170
+ const [fx, fy] = toPixel(p.segment.from), [tx, ty] = toPixel(p.segment.to);
171
+ ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(fx, fy); ctx.lineTo(tx, ty); ctx.stroke();
172
+ }
173
+ }
174
+ // Legend, top-right.
175
+ const rows = [['character', '#6ea8e0'], ['object', '#d8c39a'], ['camera (filled: current shot)', '#d64034'], ['action line', '#3c7d3a'], ['blocked sight line', '#e0241b']];
176
+ const lx = width - 236, ly = 12;
177
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.92)'; ctx.strokeStyle = '#c9c4b8'; ctx.lineWidth = 1;
178
+ ctx.fillRect(lx, ly, 224, rows.length * 20 + 12); ctx.strokeRect(lx, ly, 224, rows.length * 20 + 12);
179
+ rows.forEach(([name, colour], i) => {
180
+ ctx.fillStyle = colour; ctx.fillRect(lx + 10, ly + 12 + i * 20, 14, 10);
181
+ ctx.fillStyle = '#333'; ctx.textAlign = 'left'; ctx.fillText(name, lx + 32, ly + 17 + i * 20);
182
+ });
183
+ return layout;
184
+ }