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
@@ -13,14 +13,61 @@
13
13
  */
14
14
 
15
15
  import { createTiming, timingIsFlat, timingProgress } from "./speed-envelope.js";
16
+ import { monotoneCubicAt } from "./monotone-cubic.js";
16
17
 
17
18
  const MAX_PATH_POINTS = 64;
19
+ // The camera rail's crane marks, mirrored: the two ends are always marks, up to
20
+ // MAX_PATH_MARKS in all, never closer than MARK_GAP of the route's length.
21
+ const MAX_PATH_MARKS = 8;
22
+ const MARK_GAP = 0.03;
18
23
  const ROOM_LIMIT = 240;
19
24
  const MAX_HEIGHT = 60;
25
+ // A mark's lean (bank / pitch) is a body tilt, not a flip: ±90° is a vehicle on
26
+ // its side, past that it is a different (and unreachable-by-slider) gesture.
27
+ const MAX_LEAN = 90;
20
28
 
21
29
  const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
22
30
  const finite = (value, fallback = 0) => (typeof value === "number" && Number.isFinite(value) ? value : fallback);
23
31
 
32
+ const tidyDegrees = (value) => {
33
+ const rounded = Math.round(value * 1e4) / 1e4;
34
+ return rounded === 0 ? 0 : rounded;
35
+ };
36
+
37
+ /** One stored mark: a bare number is a level dot (bank 0, pitch 0). */
38
+ function readMark(entry) {
39
+ if (entry && typeof entry === "object") {
40
+ return {
41
+ t: finite(entry.t, -1),
42
+ bank: tidyDegrees(clamp(finite(entry.bank), -MAX_LEAN, MAX_LEAN)),
43
+ pitch: tidyDegrees(clamp(finite(entry.pitch), -MAX_LEAN, MAX_LEAN)),
44
+ };
45
+ }
46
+ return { t: finite(entry, -1), bank: 0, pitch: 0 };
47
+ }
48
+
49
+ /**
50
+ * The interior marks a hand has dropped on the route: `{ t, bank, pitch }`,
51
+ * `t` the arc fraction, `bank` the roll about the travel direction (degrees,
52
+ * positive = right side down) and `pitch` the nose angle (positive = nose up)
53
+ * the body holds when it passes the dot. A plain number is accepted as a level
54
+ * dot. The ends are implicit marks 0 and 1, always level, and never stored.
55
+ */
56
+ function normalizeMarks(value) {
57
+ const marks = [];
58
+ const sorted = (Array.isArray(value) ? value : [])
59
+ .map(readMark)
60
+ .filter((mark) => mark.t > MARK_GAP && mark.t < 1 - MARK_GAP)
61
+ .sort((a, b) => a.t - b.t);
62
+ for (const mark of sorted) {
63
+ const previous = marks.length ? marks[marks.length - 1].t : 0;
64
+ if (mark.t - previous < MARK_GAP || 1 - mark.t < MARK_GAP) continue;
65
+ if (marks.length >= MAX_PATH_MARKS - 2) break;
66
+ marks.push(mark);
67
+ }
68
+ return marks;
69
+ }
70
+
24
71
  /** One authored path point: a floor position plus the height it travels at. */
25
72
  function createPathPoint(value) {
26
73
  const source = value && typeof value === "object" ? value : {};
@@ -55,6 +102,9 @@ export function createObjectPath(value) {
55
102
  const timing = createTiming(value.timing);
56
103
  return {
57
104
  points,
105
+ // The grabbable dots on the route (see pathMarkFractions); the points
106
+ // are the route's shape, the marks are where a hand takes hold of it.
107
+ marks: normalizeMarks(value.marks),
58
108
  timing: timingIsFlat(timing) ? null : timing,
59
109
  // 0 means "fill the timeline": the length/duration answer is computed at
60
110
  // sample time, where the take's duration is known.
@@ -86,52 +136,223 @@ export function translateObjectPath(path, delta) {
86
136
  });
87
137
  }
88
138
 
89
- /** Cumulative arc length per point, plus the total. */
90
- export function pathMetrics(path) {
91
- const points = path?.points ?? [];
92
- const cumulative = [0];
93
- for (let i = 1; i < points.length; i += 1) {
94
- const a = points[i - 1];
95
- const b = points[i];
96
- cumulative.push(cumulative[i - 1] + Math.hypot(b.x - a.x, b.y - a.y, b.z - a.z));
139
+ /*
140
+ * The route as travelled is a curve, not the polyline of its points: a
141
+ * centripetal Catmull-Rom through every authored point (the camera rail's
142
+ * curve, in 3D so lifted points stay lifted), sampled every few centimetres.
143
+ * A car rounds a corner instead of snapping to the next leg, and its heading
144
+ * turns with the curve instead of jumping at each point. The ends are
145
+ * extended by mirroring their neighbour, so a two-point route is still a
146
+ * straight line and the curve still starts and ends on the authored points.
147
+ */
148
+ const CURVE_SPACING = 0.05;
149
+ const CURVE_MAX_STEPS = 400;
150
+ const CURVE_ALPHA = 0.5; // centripetal: no loops or overshoot at tight corners
151
+ const curveCache = new Map();
152
+ const CURVE_CACHE_SIZE = 64;
153
+
154
+ const mirror = (point, neighbour) => ({ x: 2 * point.x - neighbour.x, y: 2 * point.y - neighbour.y, z: 2 * point.z - neighbour.z });
155
+ const span = (a, b) => Math.hypot(b.x - a.x, b.y - a.y, b.z - a.z);
156
+
157
+ function catmullRom(p0, p1, p2, p3, t) {
158
+ const t0 = 0;
159
+ const t1 = t0 + Math.max(span(p0, p1) ** CURVE_ALPHA, 1e-6);
160
+ const t2 = t1 + Math.max(span(p1, p2) ** CURVE_ALPHA, 1e-6);
161
+ const t3 = t2 + Math.max(span(p2, p3) ** CURVE_ALPHA, 1e-6);
162
+ const at = t1 + (t2 - t1) * t;
163
+ const lerp = (a, b, ta, tb) => {
164
+ const w = tb - ta < 1e-12 ? 0 : (at - ta) / (tb - ta);
165
+ return { x: a.x + (b.x - a.x) * w, y: a.y + (b.y - a.y) * w, z: a.z + (b.z - a.z) * w };
166
+ };
167
+ const a1 = lerp(p0, p1, t0, t1);
168
+ const a2 = lerp(p1, p2, t1, t2);
169
+ const a3 = lerp(p2, p3, t2, t3);
170
+ return lerp(lerp(a1, a2, t0, t2), lerp(a2, a3, t1, t3), t1, t2);
171
+ }
172
+
173
+ function segmentControls(points, index) {
174
+ const p1 = points[index];
175
+ const p2 = points[index + 1];
176
+ const p0 = index > 0 ? points[index - 1] : mirror(p1, p2);
177
+ const p3 = index + 2 < points.length ? points[index + 2] : mirror(p2, p1);
178
+ return [p0, p1, p2, p3];
179
+ }
180
+
181
+ const asPoint = (point) => ({ x: finite(point?.x), y: finite(point?.y), z: finite(point?.z) });
182
+
183
+ /** The point a fraction `t` along the curve between authored points
184
+ * `index` and `index + 1` — where a point inserted on the drawn line goes. */
185
+ export function pathCurvePointBetween(points, index, t) {
186
+ const list = (points ?? []).map(asPoint);
187
+ if (index < 0 || index + 1 >= list.length) return null;
188
+ const at = catmullRom(...segmentControls(list, index), clamp(finite(t), 0, 1));
189
+ return { x: at.x, y: Math.max(0, at.y), z: at.z };
190
+ }
191
+
192
+ /** The interior marks, normalized: `{ t, bank, pitch }` ascending by `t`. */
193
+ export function pathMarks(path) {
194
+ return normalizeMarks(path?.marks);
195
+ }
196
+
197
+ /** Every mark as an arc fraction, ends included: the dots the editors draw. */
198
+ export function pathMarkFractions(path) {
199
+ return [0, ...normalizeMarks(path?.marks).map((mark) => mark.t), 1];
200
+ }
201
+
202
+ /**
203
+ * The marks with one more dot at arc fraction `t`, and the editor index it
204
+ * takes (ends count: the first interior dot is 1). The new dot is born with the
205
+ * lean the route already has there, so dropping it changes nothing visible; its
206
+ * neighbours keep theirs. Null when the route is full or `t` is too close to a
207
+ * dot or an end.
208
+ */
209
+ export function insertPathMark(path, t) {
210
+ const marks = normalizeMarks(path?.marks);
211
+ const at = finite(t, -1);
212
+ if (marks.length >= MAX_PATH_MARKS - 2) return null;
213
+ if (!(at > MARK_GAP && at < 1 - MARK_GAP)) return null;
214
+ if (marks.some((mark) => Math.abs(mark.t - at) < MARK_GAP)) return null;
215
+ const lean = pathLeanAt(path, at);
216
+ const next = [...marks, { t: at, bank: tidyDegrees(lean.bank), pitch: tidyDegrees(lean.pitch) }].sort((a, b) => a.t - b.t);
217
+ return { marks: next, index: next.findIndex((mark) => mark.t === at) + 1 };
218
+ }
219
+
220
+ /**
221
+ * The body's tilt at arc fraction `t`: a monotone piecewise cubic through the
222
+ * marks' bank and pitch (ends level), the camera crane's own interpolation —
223
+ * it hits every dot exactly, never overshoots between two, and is level again
224
+ * at both ends and wherever the neighbouring dots are.
225
+ */
226
+ export function pathLeanAt(path, t) {
227
+ const marks = normalizeMarks(path?.marks);
228
+ if (!marks.some((mark) => mark.bank || mark.pitch)) return { bank: 0, pitch: 0 };
229
+ const knots = [{ t: 0, bank: 0, pitch: 0 }, ...marks, { t: 1, bank: 0, pitch: 0 }];
230
+ const at = clamp(finite(t), 0, 1);
231
+ const tidy = (value) => (Math.abs(value) < 1e-12 ? 0 : value);
232
+ return {
233
+ bank: tidy(monotoneCubicAt(knots, at, (knot) => knot.bank)),
234
+ pitch: tidy(monotoneCubicAt(knots, at, (knot) => knot.pitch)),
235
+ };
236
+ }
237
+
238
+ /** The point at arc fraction `t` of the travelled curve. */
239
+ export function pathPointAtFraction(path, t) {
240
+ const curve = pathCurve(path);
241
+ if (!curve.points.length) return null;
242
+ const at = pointAtDistance({ extend: false }, curve, clamp(finite(t), 0, 1) * curve.length);
243
+ return { x: at.x, y: at.y, z: at.z };
244
+ }
245
+
246
+ /**
247
+ * Where on the travelled curve a screen/plan position is nearest: `project`
248
+ * maps a world point to the editor's 2D space (pixels in the scene, metres on
249
+ * the board). Returns `{ d, t, point }` — distance in that space, arc fraction,
250
+ * the curve point — or null for a route with no length.
251
+ */
252
+ export function nearestPathFraction(path, project, x, y) {
253
+ const curve = pathCurve(path);
254
+ if (curve.length <= 1e-9) return null;
255
+ let best = null;
256
+ let prev = null;
257
+ for (let i = 0; i < curve.points.length; i += 1) {
258
+ const screen = project(curve.points[i]);
259
+ if (screen && prev) {
260
+ const dx = screen.x - prev.screen.x;
261
+ const dy = screen.y - prev.screen.y;
262
+ const lenSq = dx * dx + dy * dy;
263
+ const w = lenSq < 1e-12 ? 0 : clamp(((x - prev.screen.x) * dx + (y - prev.screen.y) * dy) / lenSq, 0, 1);
264
+ const d = Math.hypot(prev.screen.x + dx * w - x, prev.screen.y + dy * w - y);
265
+ if (!best || d < best.d) {
266
+ const distance = curve.cumulative[prev.i] + (curve.cumulative[i] - curve.cumulative[prev.i]) * w;
267
+ best = { d, t: distance / curve.length };
268
+ }
269
+ }
270
+ prev = screen ? { screen, i } : null;
97
271
  }
98
- return { cumulative, length: cumulative[cumulative.length - 1] ?? 0 };
272
+ if (best) best.point = pathPointAtFraction(path, best.t);
273
+ return best;
99
274
  }
100
275
 
101
- /** The point at `distance` along the path, extrapolating past the end. */
102
- function pointAtDistance(path, metrics, distance) {
103
- const { points } = path;
104
- const { cumulative, length } = metrics;
105
- if (distance <= 0) {
106
- const heading = headingBetween(points[0], points[1]);
107
- return { ...points[0], heading };
276
+ /**
277
+ * The travelled curve: dense points, cumulative arc length, the total, and
278
+ * a heading (yaw degrees, or null when the curve does not move on the floor)
279
+ * at every dense point.
280
+ */
281
+ export function pathCurve(path) {
282
+ const points = (path?.points ?? []).map(asPoint);
283
+ const key = points.map((point) => `${point.x},${point.y},${point.z}`).join(";");
284
+ const cached = curveCache.get(key);
285
+ if (cached) return cached;
286
+ const dense = points.length ? [{ ...points[0] }] : [];
287
+ for (let index = 0; index + 1 < points.length; index += 1) {
288
+ const controls = segmentControls(points, index);
289
+ const steps = clamp(Math.ceil(span(points[index], points[index + 1]) / CURVE_SPACING), 2, CURVE_MAX_STEPS);
290
+ for (let step = 1; step <= steps; step += 1) {
291
+ const at = step === steps ? { ...points[index + 1] } : catmullRom(...controls, step / steps);
292
+ dense.push({ x: at.x, y: Math.max(0, at.y), z: at.z });
293
+ }
108
294
  }
295
+ const cumulative = dense.length ? [0] : [];
296
+ for (let i = 1; i < dense.length; i += 1) cumulative.push(cumulative[i - 1] + span(dense[i - 1], dense[i]));
297
+ // The tangent at each sample, from its neighbours: headings blend between
298
+ // samples instead of stepping with every 5 cm segment.
299
+ const headings = dense.map((_, i) => headingBetween(dense[Math.max(0, i - 1)], dense[Math.min(dense.length - 1, i + 1)]));
300
+ const curve = { points: dense, cumulative, length: cumulative[cumulative.length - 1] ?? 0, headings };
301
+ if (curveCache.size >= CURVE_CACHE_SIZE) curveCache.delete(curveCache.keys().next().value);
302
+ curveCache.set(key, curve);
303
+ return curve;
304
+ }
305
+
306
+ /** Arc length along the travelled curve, plus the total. */
307
+ export function pathMetrics(path) {
308
+ const { cumulative, length } = pathCurve(path);
309
+ return { cumulative, length };
310
+ }
311
+
312
+ /** Shortest-way blend between two yaws in degrees; null-safe. */
313
+ function blendHeading(a, b, weight) {
314
+ if (a === null) return b;
315
+ if (b === null) return a;
316
+ const delta = ((((b - a) % 360) + 540) % 360) - 180;
317
+ return a + delta * weight;
318
+ }
319
+
320
+ /** The point at `distance` along the curve, extrapolating past the end. */
321
+ function pointAtDistance(path, curve, distance) {
322
+ const { points, cumulative, length, headings } = curve;
323
+ if (distance <= 0) return { ...points[0], heading: headings[0] };
324
+ const lastIndex = points.length - 1;
109
325
  if (distance >= length) {
110
- const last = points[points.length - 1];
111
- const heading = headingBetween(points[points.length - 2], last);
326
+ const last = points[lastIndex];
327
+ const heading = headings[lastIndex];
112
328
  if (!path.extend) return { ...last, heading };
113
329
  // Past the end the object keeps its final direction and speed.
114
330
  const overshoot = distance - length;
115
- const previous = points[points.length - 2];
116
- const span = Math.hypot(last.x - previous.x, last.y - previous.y, last.z - previous.z) || 1;
331
+ const previous = points[lastIndex - 1];
332
+ const step = span(previous, last) || 1;
117
333
  return {
118
- x: last.x + ((last.x - previous.x) / span) * overshoot,
119
- y: Math.max(0, last.y + ((last.y - previous.y) / span) * overshoot),
120
- z: last.z + ((last.z - previous.z) / span) * overshoot,
334
+ x: last.x + ((last.x - previous.x) / step) * overshoot,
335
+ y: Math.max(0, last.y + ((last.y - previous.y) / step) * overshoot),
336
+ z: last.z + ((last.z - previous.z) / step) * overshoot,
121
337
  heading,
122
338
  };
123
339
  }
124
- let index = 1;
125
- while (index < cumulative.length - 1 && cumulative[index] < distance) index += 1;
126
- const a = points[index - 1];
127
- const b = points[index];
128
- const segment = cumulative[index] - cumulative[index - 1];
129
- const weight = segment > 1e-9 ? (distance - cumulative[index - 1]) / segment : 0;
340
+ let lo = 0;
341
+ let hi = lastIndex;
342
+ while (hi - lo > 1) {
343
+ const mid = (lo + hi) >> 1;
344
+ if (cumulative[mid] <= distance) lo = mid;
345
+ else hi = mid;
346
+ }
347
+ const a = points[lo];
348
+ const b = points[hi];
349
+ const segment = cumulative[hi] - cumulative[lo];
350
+ const weight = segment > 1e-9 ? (distance - cumulative[lo]) / segment : 0;
130
351
  return {
131
352
  x: a.x + (b.x - a.x) * weight,
132
353
  y: a.y + (b.y - a.y) * weight,
133
354
  z: a.z + (b.z - a.z) * weight,
134
- heading: headingBetween(a, b),
355
+ heading: blendHeading(headings[lo], headings[hi], weight),
135
356
  };
136
357
  }
137
358
 
@@ -145,67 +366,89 @@ function headingBetween(a, b) {
145
366
  }
146
367
 
147
368
  /**
148
- * Where an object stands at `frame`. Returns null when the object has no
149
- * usable path, so callers fall back to its authored transform untouched.
150
- *
151
- * @param {object} object a scene object record
152
- * @param {number} frame absolute timeline frame
153
- * @param {{ frameCount: number, fps: number }} take timeline geometry
369
+ * How far along the travelled curve the route has taken the object at
370
+ * `frame`, in metres (past `length` only when the route extends), with the
371
+ * curve itself. Shared by the pose and the progress readout.
154
372
  */
155
- export function objectTransformAt(object, frame, take = {}) {
156
- const path = createObjectPath(object?.path);
157
- if (!path) return null;
158
- const metrics = pathMetrics(path);
159
- if (metrics.length <= 1e-9) return null;
373
+ function travelledDistance(path, curve, frame, take) {
160
374
  const frameCount = Math.max(1, Math.round(finite(take.frameCount, 1)));
161
375
  const fps = Math.max(1, finite(take.fps, 24));
162
376
  const sampled = clamp(finite(frame), 0, Math.max(0, frameCount - 1));
163
377
  const seconds = sampled / fps;
164
378
  // speed 0 = fill the timeline: cover the whole path across the take.
165
379
  const duration = Math.max(1e-6, (frameCount - 1) / fps);
166
- const speed = path.speed > 0 ? path.speed : metrics.length / duration;
380
+ const speed = path.speed > 0 ? path.speed : curve.length / duration;
167
381
  // The travel window: the stretch of the take the route is walked in. The
168
382
  // timing envelope shapes progress INSIDE this window; the window's length
169
383
  // (and so the arrival frame) never moves — the area is the distance.
170
- const window = metrics.length / speed;
384
+ const window = curve.length / speed;
171
385
  const u = seconds / Math.max(1e-6, window);
172
- let distance;
173
- if (path.loop && metrics.length > 1e-9) {
174
- distance = metrics.length * timingProgress(path.timing, u - Math.floor(u));
175
- } else if (u >= 1) {
176
- // Past the window: arrived. Extend keeps walking the final heading at
177
- // the plain average speed, exactly as it did before envelopes existed.
178
- distance = metrics.length + (path.extend ? speed * (seconds - window) : 0);
179
- } else {
180
- distance = metrics.length * timingProgress(path.timing, u);
181
- }
182
- const at = pointAtDistance(path, metrics, distance);
386
+ if (path.loop && curve.length > 1e-9) return curve.length * timingProgress(path.timing, u - Math.floor(u));
387
+ // Past the window: arrived. Extend keeps walking the final heading at
388
+ // the plain average speed, exactly as it did before envelopes existed.
389
+ if (u >= 1) return curve.length + (path.extend ? speed * (seconds - window) : 0);
390
+ return curve.length * timingProgress(path.timing, u);
391
+ }
392
+
393
+ /**
394
+ * The arc fraction 0..1 the route has the object at on `frame` — what the
395
+ * dots are measured in — or null when the path cannot describe travel.
396
+ *
397
+ * @param {object} path a path record
398
+ * @param {number} frame absolute timeline frame
399
+ * @param {{ frameCount: number, fps: number }} take timeline geometry
400
+ */
401
+ export function pathProgressAt(path, frame, take = {}) {
402
+ const normal = createObjectPath(path);
403
+ if (!normal) return null;
404
+ const curve = pathCurve(normal);
405
+ if (curve.length <= 1e-9) return null;
406
+ return clamp(travelledDistance(normal, curve, frame, take) / curve.length, 0, 1);
407
+ }
408
+
409
+ /**
410
+ * Where an object stands at `frame`. Returns null when the object has no
411
+ * usable path, so callers fall back to its authored transform untouched.
412
+ * `bank` and `pitch` (degrees) are the lean the route's marks ask for there.
413
+ *
414
+ * @param {object} object a scene object record
415
+ * @param {number} frame absolute timeline frame
416
+ * @param {{ frameCount: number, fps: number }} take timeline geometry
417
+ */
418
+ export function objectTransformAt(object, frame, take = {}) {
419
+ const path = createObjectPath(object?.path);
420
+ if (!path) return null;
421
+ const curve = pathCurve(path);
422
+ if (curve.length <= 1e-9) return null;
423
+ const distance = travelledDistance(path, curve, frame, take);
424
+ const at = pointAtDistance(path, curve, distance);
425
+ const lean = pathLeanAt(path, distance / curve.length);
183
426
  return {
184
427
  x: at.x,
185
428
  y: at.y,
186
429
  z: at.z,
187
430
  rot: path.faceTravel && at.heading !== null ? at.heading : null,
431
+ bank: lean.bank,
432
+ pitch: lean.pitch,
188
433
  };
189
434
  }
190
435
 
191
436
  /**
192
- * A drawn stroke becomes the FEWEST points that still carry its shape.
437
+ * A drawn stroke keeps its shape, exactly as the camera rail's does.
193
438
  *
194
- * The rail wants fidelity to the drawn curve; a travel route does not. A route
195
- * is a plan the operator then adjusts point by point, and a stroke that lands
196
- * twenty dots on the floor is a route nobody can grab. So this simplifies
197
- * coarsely and, if the shape is still busy, keeps coarsening until the count
198
- * fits under the ceiling: a straight drag gives two points, a dog-leg three,
199
- * and anything more elaborate stays inside a handful the hand can manage.
200
- * Points are added deliberately afterwards, by double-clicking the line.
439
+ * The rail runs the stroke through the same Douglas-Peucker pass (0.12 m) and
440
+ * keeps every vertex it asks for; a route is the same kind of line, so it gets
441
+ * the same treatment instead of a two-or-three point caricature. Those
442
+ * vertices are the shape; what the hand grabs are the MARKS (pathMarkFractions)
443
+ * — the ends, plus dots added by double-clicking the line. The schema's point
444
+ * ceiling is the only limit: a busier stroke coarsens until it fits.
201
445
  */
202
- const STROKE_MAX_POINTS = 5;
446
+ const STROKE_EPSILON = 0.12;
447
+ const STROKE_MAX_POINTS = MAX_PATH_POINTS;
203
448
 
204
- export function strokeToPathPoints(stroke, simplify, { maxPoints = STROKE_MAX_POINTS, epsilon = 0.55 } = {}) {
449
+ export function strokeToPathPoints(stroke, simplify, { maxPoints = STROKE_MAX_POINTS, epsilon = STROKE_EPSILON } = {}) {
205
450
  if (!stroke || stroke.length < 2) return [];
206
451
  let points = simplify(stroke, epsilon);
207
- // Escalate rather than pick a single magic epsilon: the right coarseness
208
- // depends on how big the drawn route is, which only the stroke knows.
209
452
  for (let step = 0; step < 12 && points.length > maxPoints; step += 1) {
210
453
  epsilon *= 1.8;
211
454
  points = simplify(stroke, epsilon);
@@ -221,4 +464,4 @@ export function strokeToPathPoints(stroke, simplify, { maxPoints = STROKE_MAX_PO
221
464
  return points.map((point) => ({ x: point.x, y: 0, z: point.z }));
222
465
  }
223
466
 
224
- export { MAX_PATH_POINTS, MAX_HEIGHT as MAX_PATH_HEIGHT, STROKE_MAX_POINTS };
467
+ export { MAX_PATH_POINTS, MAX_PATH_MARKS, MARK_GAP, MAX_LEAN as MAX_PATH_LEAN, MAX_HEIGHT as MAX_PATH_HEIGHT, STROKE_MAX_POINTS };