@dailies-studio/shot-editor 0.1.0 → 0.2.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 (432) hide show
  1. package/dist/.vite/manifest.json +382 -0
  2. package/dist/assets/AgentPanel-CQejLbiX.js +2 -0
  3. package/dist/assets/AgentPanel-DJEphah0.css +1 -0
  4. package/dist/assets/AgentQuestionDialog-BqkD0kXF.js +1 -0
  5. package/dist/assets/CodeStudio-Dv4ihHpz.js +23 -0
  6. package/dist/assets/CommentsPanel-B4yhhT1T.js +1 -0
  7. package/dist/assets/ComponentLibraryRail-Ch_dsAru.js +1 -0
  8. package/dist/assets/ComponentStage-BRhRotyH.js +1 -0
  9. package/dist/assets/CurvePane-BjFVabKX.css +1 -0
  10. package/dist/assets/CurvePane-R22WzllJ.js +1 -0
  11. package/dist/assets/ExportDialog-CWLQWTmG.js +2 -0
  12. package/dist/assets/FontPicker-DWKaYL7F.js +1 -0
  13. package/dist/assets/FontPicker-v0IOIArM.css +1 -0
  14. package/dist/assets/GenerateDrawer-Bh3SqDKt.js +2 -0
  15. package/dist/assets/InsertDrawer-Ce1fod1L.js +1 -0
  16. package/dist/assets/LibraryBrowser-D9sEGz5w.js +2 -0
  17. package/dist/assets/MakeItYoursSection-CMO4319_.js +2 -0
  18. package/dist/assets/MaterialGraphCanvas-BFzl4wWR.js +23 -0
  19. package/dist/assets/MaterialGraphCanvas-BIUD59ik.css +1 -0
  20. package/dist/assets/MaterialWorkspace-CqTWqaRA.js +14 -0
  21. package/dist/assets/PreferencesDialog-CBf9_OoD.js +1 -0
  22. package/dist/assets/PresetsSection-CUEaoCVU.js +1 -0
  23. package/dist/assets/SoundHistory-BeS8EW1h.js +1 -0
  24. package/dist/assets/SoundMode-BmcZru3i.js +3 -0
  25. package/dist/assets/StockSounds-DdAUGRYK.js +1 -0
  26. package/dist/assets/TemplateFieldSection-CaeXpLFo.js +1 -0
  27. package/dist/assets/VersionHistoryPanel-CxG6XhNP.js +1 -0
  28. package/dist/assets/board-B88EPuLO.css +1 -0
  29. package/dist/assets/board-CL-he7SL.js +3 -0
  30. package/dist/assets/components-0oOSPN0l.js +1 -0
  31. package/dist/assets/googleFontRanges-gp32wJMo.js +1 -0
  32. package/dist/assets/googleFonts-BNon6M7X.js +1 -0
  33. package/dist/assets/iconsAndAliases-DL7gXP5j.js +1863 -0
  34. package/dist/assets/index-4F5NQmI8.css +1 -0
  35. package/dist/assets/index-BGHQGV5z.js +117 -0
  36. package/dist/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  37. package/dist/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  38. package/dist/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  39. package/dist/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  40. package/dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  41. package/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  42. package/dist/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  43. package/dist/assets/libraryModel-CpIwfFha.js +1 -0
  44. package/dist/assets/lucide-6iK64tTB.js +9319 -0
  45. package/dist/assets/motionStamp-CH-Hew4E.js +1 -0
  46. package/dist/assets/takeMeasure-Tdg0TxEi.js +1 -0
  47. package/dist/assets/webmcp-BsB9gxWC.js +2 -0
  48. package/dist/index.html +34 -3
  49. package/package.json +12 -9
  50. package/src/addFamilies.ts +66 -0
  51. package/src/api.ts +1631 -55
  52. package/src/app/App.tsx +977 -198
  53. package/src/app/lazyPanel.tsx +137 -0
  54. package/src/app/lazySurfaces.tsx +203 -0
  55. package/src/audio/index.tsx +41 -10
  56. package/src/audio/meter.ts +485 -0
  57. package/src/audio/meterMath.ts +273 -0
  58. package/src/audio/meterStore.ts +155 -0
  59. package/src/audio/offlineMix.ts +289 -0
  60. package/src/audio/preview.ts +928 -118
  61. package/src/bridge.ts +15 -5
  62. package/src/bridgeTypes.ts +1611 -36
  63. package/src/channels.ts +17 -32
  64. package/src/fonts/googleFonts.ts +4 -0
  65. package/src/hostApi.ts +174 -0
  66. package/src/icons.tsx +770 -0
  67. package/src/layerWords.ts +61 -0
  68. package/src/main.tsx +56 -4
  69. package/src/panels/ResizeHandle.tsx +33 -43
  70. package/src/panels/agent/AgentDots.tsx +36 -0
  71. package/src/panels/agent/AgentPanel.tsx +119 -43
  72. package/src/panels/agent/AgentQuestionDialog.tsx +202 -0
  73. package/src/panels/agent/agent.css +113 -97
  74. package/src/panels/agent/agentGate.ts +35 -0
  75. package/src/panels/code/CodeDefaultsPanel.tsx +345 -0
  76. package/src/panels/code/CodeStudio.tsx +373 -0
  77. package/src/panels/code/FileTree.tsx +265 -0
  78. package/src/panels/code/codeBufferGuard.ts +52 -0
  79. package/src/panels/code/codeParts.tsx +321 -0
  80. package/src/panels/comments/CommentComposer.tsx +239 -0
  81. package/src/panels/comments/CommentThread.tsx +346 -0
  82. package/src/panels/comments/CommentsPanel.tsx +336 -0
  83. package/src/panels/comments/InboxButton.tsx +131 -0
  84. package/src/panels/comments/MentionMenu.tsx +98 -0
  85. package/src/panels/comments/parts.tsx +171 -0
  86. package/src/panels/export/ExportDialog.tsx +919 -96
  87. package/src/panels/export/ExportScrim.tsx +57 -0
  88. package/src/panels/files/AssetsTab.tsx +656 -0
  89. package/src/panels/files/FilePanel.tsx +1466 -216
  90. package/src/panels/files/LayerMarks.tsx +40 -0
  91. package/src/panels/files/LeftColumn.tsx +37 -0
  92. package/src/panels/files/McpSection.tsx +690 -47
  93. package/src/panels/files/MissingAssetsNotice.tsx +60 -0
  94. package/src/panels/files/assetDrag.ts +79 -0
  95. package/src/panels/files/resourceParts.tsx +255 -0
  96. package/src/panels/fonts/FontPicker.tsx +762 -0
  97. package/src/panels/fonts/faceCache.ts +207 -0
  98. package/src/panels/fonts/fontPicker.css +271 -0
  99. package/src/panels/fonts/pickerModel.ts +265 -0
  100. package/src/panels/fonts/pickerRequest.ts +46 -0
  101. package/src/panels/header/AccountAvatar.tsx +76 -0
  102. package/src/panels/header/AgentChip.tsx +77 -0
  103. package/src/panels/header/AppMenu.tsx +213 -0
  104. package/src/panels/header/Header.tsx +564 -84
  105. package/src/panels/header/HistoryControls.tsx +31 -5
  106. package/src/panels/header/PresenceStack.tsx +329 -0
  107. package/src/panels/header/ProjectTitle.tsx +232 -0
  108. package/src/panels/header/ShareButton.tsx +59 -0
  109. package/src/panels/header/StatusBadges.tsx +358 -27
  110. package/src/panels/header/SyncDeletedDialog.tsx +88 -0
  111. package/src/panels/header/ZoomControls.tsx +46 -15
  112. package/src/panels/header/agentHandoff.ts +52 -0
  113. package/src/panels/header/leave.ts +26 -0
  114. package/src/panels/help/HelpSection.tsx +189 -163
  115. package/src/panels/insert/InsertDrawer.tsx +723 -0
  116. package/src/panels/insert/iconSet.ts +109 -0
  117. package/src/panels/inspector/ExposeDot.tsx +117 -0
  118. package/src/panels/inspector/FramingRows.tsx +314 -44
  119. package/src/panels/inspector/InspectorFooter.tsx +91 -0
  120. package/src/panels/inspector/InspectorHead.tsx +425 -0
  121. package/src/panels/inspector/InspectorPanel.tsx +408 -124
  122. package/src/panels/inspector/KeyframeDot.tsx +373 -0
  123. package/src/panels/inspector/KeyframeSection.tsx +257 -0
  124. package/src/panels/inspector/MarkerSection.tsx +55 -11
  125. package/src/panels/inspector/ParamRow.tsx +84 -53
  126. package/src/panels/inspector/RightColumn.tsx +152 -0
  127. package/src/panels/inspector/TimeSection.tsx +866 -0
  128. package/src/panels/inspector/animate/EaseControl.tsx +244 -0
  129. package/src/panels/inspector/animate/PresetsSection.tsx +135 -0
  130. package/src/panels/inspector/animate/ShotSection.tsx +562 -48
  131. package/src/panels/inspector/design/AlignRow.tsx +95 -31
  132. package/src/panels/inspector/design/AppearanceSection.tsx +118 -9
  133. package/src/panels/inspector/design/ClonerSection.tsx +323 -62
  134. package/src/panels/inspector/design/ComponentSection.tsx +50 -43
  135. package/src/panels/inspector/design/CornerSection.tsx +45 -14
  136. package/src/panels/inspector/design/DefinitionProps.tsx +315 -0
  137. package/src/panels/inspector/design/DefinitionSection.tsx +136 -0
  138. package/src/panels/inspector/design/DeviceSection.tsx +210 -0
  139. package/src/panels/inspector/design/FillSection.tsx +83 -51
  140. package/src/panels/inspector/design/FontField.tsx +196 -0
  141. package/src/panels/inspector/design/HtmlSection.tsx +23 -12
  142. package/src/panels/inspector/design/InstanceSection.tsx +373 -154
  143. package/src/panels/inspector/design/MaskSection.tsx +84 -0
  144. package/src/panels/inspector/design/MaterialSection.tsx +177 -0
  145. package/src/panels/inspector/design/MediaSection.tsx +273 -120
  146. package/src/panels/inspector/design/MeshSection.tsx +910 -82
  147. package/src/panels/inspector/design/PaintRows.tsx +550 -0
  148. package/src/panels/inspector/design/PlateStyleSection.tsx +177 -0
  149. package/src/panels/inspector/design/ShadowSection.tsx +221 -48
  150. package/src/panels/inspector/design/SplineFollowSection.tsx +235 -0
  151. package/src/panels/inspector/design/StrokeSection.tsx +228 -43
  152. package/src/panels/inspector/design/TemplateFieldSection.tsx +57 -0
  153. package/src/panels/inspector/design/TransformSection.tsx +244 -50
  154. package/src/panels/inspector/design/TypographySection.tsx +679 -110
  155. package/src/panels/inspector/layout.ts +295 -0
  156. package/src/panels/inspector/propValue.ts +199 -0
  157. package/src/panels/inspector/scene/CameraSection.tsx +232 -22
  158. package/src/panels/inspector/scene/EffectsSection.tsx +722 -71
  159. package/src/panels/inspector/scene/MakeItYoursSection.tsx +180 -0
  160. package/src/panels/inspector/scene/SceneDocSection.tsx +191 -0
  161. package/src/panels/inspector/scene/SceneSection.tsx +219 -44
  162. package/src/panels/inspector/scene/TransitionSection.tsx +523 -72
  163. package/src/panels/inspector/storyboard/StoryboardReferenceSection.tsx +593 -0
  164. package/src/panels/inspector/storyboard/VideoStill.tsx +120 -0
  165. package/src/panels/inspector/timeModel.ts +190 -0
  166. package/src/panels/inspector/useDocWrite.ts +438 -146
  167. package/src/panels/library/LibraryBrowser.tsx +435 -0
  168. package/src/panels/library/LibraryGrid.tsx +190 -0
  169. package/src/panels/library/LibraryPreviewPanel.tsx +349 -0
  170. package/src/panels/library/LibraryRail.tsx +84 -0
  171. package/src/panels/library/LibrarySoundPanel.tsx +293 -0
  172. package/src/panels/library/SoundHistory.tsx +245 -0
  173. package/src/panels/library/StockSounds.tsx +390 -0
  174. package/src/panels/library/libraryModel.ts +147 -0
  175. package/src/panels/library/stockSearch.ts +77 -0
  176. package/src/panels/maskMarks.tsx +117 -0
  177. package/src/panels/material/MaterialCommentNode.tsx +54 -0
  178. package/src/panels/material/MaterialGraphCanvas.tsx +861 -0
  179. package/src/panels/material/MaterialGraphMenu.tsx +45 -0
  180. package/src/panels/material/MaterialGroupNode.tsx +56 -0
  181. package/src/panels/material/MaterialLibrary.tsx +253 -0
  182. package/src/panels/material/MaterialNode.tsx +453 -0
  183. package/src/panels/material/MaterialNodePalette.tsx +349 -0
  184. package/src/panels/material/MaterialParameterControls.tsx +525 -0
  185. package/src/panels/material/MaterialRerouteNode.tsx +42 -0
  186. package/src/panels/material/MaterialWorkspace.tsx +501 -0
  187. package/src/panels/material/NodePreview.tsx +70 -0
  188. package/src/panels/material/flowModel.ts +441 -0
  189. package/src/panels/material/parameterValues.ts +231 -0
  190. package/src/panels/material/previewScheduler.ts +235 -0
  191. package/src/panels/material/reactFlow.css +6 -0
  192. package/src/panels/material/socketGlyph.ts +85 -0
  193. package/src/panels/material/viewportMemory.ts +58 -0
  194. package/src/panels/preferences/PreferencesDialog.tsx +215 -0
  195. package/src/panels/share/ShareComponentDialog.tsx +247 -0
  196. package/src/panels/stage/StageMenu.tsx +35 -5
  197. package/src/panels/stage/StagePanel.tsx +338 -23
  198. package/src/panels/stage/cellSlots.tsx +70 -0
  199. package/src/panels/storyboard/BoardCanvas.tsx +739 -0
  200. package/src/panels/storyboard/BoardCursors.tsx +158 -0
  201. package/src/panels/storyboard/BoardDropDialog.tsx +161 -0
  202. package/src/panels/storyboard/BoardPlan.tsx +42 -0
  203. package/src/panels/storyboard/ComponentSheet.tsx +187 -0
  204. package/src/panels/storyboard/ComponentStage.tsx +235 -0
  205. package/src/panels/storyboard/MiniSketch.tsx +173 -0
  206. package/src/panels/storyboard/PartPicker.tsx +64 -0
  207. package/src/panels/storyboard/PlanPanel.tsx +617 -0
  208. package/src/panels/storyboard/ReferencePreview.tsx +217 -0
  209. package/src/panels/storyboard/ShotBrief.tsx +360 -0
  210. package/src/panels/storyboard/ShotCard.tsx +251 -0
  211. package/src/panels/storyboard/StagePreview.tsx +348 -0
  212. package/src/panels/storyboard/StillThumbs.tsx +162 -0
  213. package/src/panels/storyboard/StoryboardView.tsx +79 -0
  214. package/src/panels/storyboard/board.css +1065 -0
  215. package/src/panels/storyboard/board.ts +12 -0
  216. package/src/panels/storyboard/boardClipboard.ts +111 -0
  217. package/src/panels/storyboard/boardOrder.ts +68 -0
  218. package/src/panels/storyboard/boardRows.tsx +425 -0
  219. package/src/panels/storyboard/liveCap.ts +90 -0
  220. package/src/panels/storyboard/plan.css +295 -0
  221. package/src/panels/storyboard/posterCache.ts +133 -0
  222. package/src/panels/storyboard/shotboard/ComponentPicker.tsx +373 -0
  223. package/src/panels/storyboard/shotboard/ImagePicker.tsx +145 -0
  224. package/src/panels/storyboard/shotboard/ShotBoard.tsx +292 -0
  225. package/src/panels/storyboard/shotboard/SketchCanvas.tsx +443 -0
  226. package/src/panels/storyboard/shotboard/SketchInspector.tsx +471 -0
  227. package/src/panels/storyboard/shotboard/SketchItem.tsx +240 -0
  228. package/src/panels/storyboard/shotboard/SketchLayers.tsx +130 -0
  229. package/src/panels/storyboard/shotboard/SketchPlay.tsx +108 -0
  230. package/src/panels/storyboard/shotboard/SketchStrip.tsx +178 -0
  231. package/src/panels/storyboard/shotboard/SketchToolFooter.tsx +99 -0
  232. package/src/panels/storyboard/shotboard/sketch.css +1392 -0
  233. package/src/panels/storyboard/shotboard/sketchModel.ts +219 -0
  234. package/src/panels/storyboard/storyboardDrop.ts +164 -0
  235. package/src/panels/studio/ComponentLibraryRail.tsx +346 -0
  236. package/src/panels/studio/ComponentSidePanel.tsx +355 -0
  237. package/src/panels/studio/ComponentStudio.tsx +243 -0
  238. package/src/panels/studio/ComponentsHome.tsx +318 -0
  239. package/src/panels/studio/ComponentsPreview.tsx +179 -0
  240. package/src/panels/studio/EveryUse.tsx +244 -0
  241. package/src/panels/studio/ThisUsePanel.tsx +679 -0
  242. package/src/panels/studio/componentParts.tsx +341 -0
  243. package/src/panels/studio/components.ts +6 -0
  244. package/src/panels/studio/newCodeComponent.ts +66 -0
  245. package/src/panels/timeline/AudioFxButton.tsx +290 -0
  246. package/src/panels/timeline/AudioLane.tsx +674 -85
  247. package/src/panels/timeline/BlockRow.tsx +154 -85
  248. package/src/panels/timeline/ChannelRow.tsx +378 -129
  249. package/src/panels/timeline/CurvePane.tsx +508 -0
  250. package/src/panels/timeline/Filmstrip.tsx +361 -0
  251. package/src/panels/timeline/GenerateDrawer.tsx +407 -0
  252. package/src/panels/timeline/KeyStretch.tsx +115 -0
  253. package/src/panels/timeline/LifespanRow.tsx +730 -47
  254. package/src/panels/timeline/MarkerLane.tsx +46 -8
  255. package/src/panels/timeline/PlayButton.tsx +91 -0
  256. package/src/panels/timeline/PropertyPicker.tsx +254 -0
  257. package/src/panels/timeline/ShotRow.tsx +400 -0
  258. package/src/panels/timeline/SoundMode.tsx +1406 -0
  259. package/src/panels/timeline/TimelineDock.tsx +2338 -272
  260. package/src/panels/timeline/TransportVolume.tsx +163 -0
  261. package/src/panels/timeline/beatRuler.ts +74 -0
  262. package/src/panels/timeline/curveModel.ts +500 -0
  263. package/src/panels/timeline/duckUnderVoice.ts +68 -0
  264. package/src/panels/timeline/keyframeMenu.ts +138 -0
  265. package/src/panels/timeline/labelWidth.ts +49 -0
  266. package/src/panels/timeline/marquee.ts +155 -0
  267. package/src/panels/timeline/model.ts +1860 -199
  268. package/src/panels/timeline/snap.ts +125 -43
  269. package/src/panels/timeline/staggerPreview.tsx +92 -0
  270. package/src/panels/timeline/takeMeasure.ts +72 -0
  271. package/src/panels/timeline/useLive.ts +48 -0
  272. package/src/panels/timeline/viewport.ts +234 -0
  273. package/src/panels/toolbar/BackChip.tsx +116 -0
  274. package/src/panels/toolbar/CameraViewBar.tsx +118 -49
  275. package/src/panels/toolbar/CanvasBar.tsx +117 -0
  276. package/src/panels/toolbar/InsertButton.tsx +38 -0
  277. package/src/panels/toolbar/IsolationTrail.tsx +156 -0
  278. package/src/panels/toolbar/PanelToggles.tsx +86 -0
  279. package/src/panels/toolbar/ShadingBar.tsx +119 -26
  280. package/src/panels/toolbar/ToolDockBar.tsx +329 -80
  281. package/src/panels/toolbar/ToolFooter.tsx +102 -0
  282. package/src/panels/toolbar/ViewModeBar.tsx +247 -38
  283. package/src/panels/toolbar/WorkPlaneBar.tsx +94 -0
  284. package/src/panels/toolbar/useAddRows.tsx +326 -0
  285. package/src/panels/toolbar/useDismiss.ts +122 -0
  286. package/src/panels/versions/VersionHistoryPanel.tsx +274 -0
  287. package/src/panels/versions/versionModel.ts +103 -0
  288. package/src/panels/virtualWindow.ts +271 -0
  289. package/src/shotTokens.css +151 -60
  290. package/src/stage/FrustumGizmo.tsx +504 -84
  291. package/src/stage/GridPlane.tsx +225 -16
  292. package/src/stage/NodePreviews.tsx +628 -0
  293. package/src/stage/PerfHud.tsx +437 -47
  294. package/src/stage/SplitRenderer.tsx +816 -169
  295. package/src/stage/StageApp.tsx +4274 -486
  296. package/src/stage/StageRootBoundary.tsx +114 -0
  297. package/src/stage/StageTools.tsx +651 -151
  298. package/src/stage/TransformGizmo.tsx +696 -0
  299. package/src/stage/adaptivePresentation.ts +145 -0
  300. package/src/stage/bridgeClient.ts +2522 -41
  301. package/src/stage/exportWorker.ts +9 -0
  302. package/src/stage/feed.ts +305 -14
  303. package/src/stage/fontPreview.ts +176 -0
  304. package/src/stage/frustumGeometry.ts +319 -1
  305. package/src/stage/furnitureTokens.ts +33 -0
  306. package/src/stage/gizmoMath.ts +508 -16
  307. package/src/stage/gpuTimer.ts +276 -0
  308. package/src/stage/instanceModules.ts +194 -33
  309. package/src/stage/interactions/move.ts +24 -5
  310. package/src/stage/isolation.ts +291 -33
  311. package/src/stage/jsSelfProfiler.ts +325 -0
  312. package/src/stage/main.tsx +116 -16
  313. package/src/stage/modalState.ts +222 -23
  314. package/src/stage/moduleEpoch.ts +60 -0
  315. package/src/stage/motionPathStage.ts +279 -0
  316. package/src/stage/motionPreview.ts +74 -0
  317. package/src/stage/overlays/Overlays.tsx +1112 -159
  318. package/src/stage/overlays/Rulers.tsx +495 -0
  319. package/src/stage/panes.ts +159 -10
  320. package/src/stage/project.ts +68 -3
  321. package/src/stage/renderScaleController.ts +730 -0
  322. package/src/stage/rulers.ts +129 -0
  323. package/src/stage/sceneExtent.ts +75 -0
  324. package/src/stage/shading.ts +20 -0
  325. package/src/stage/splineStage.ts +632 -0
  326. package/src/stage/stagePerformanceTelemetry.ts +717 -0
  327. package/src/stage/stageTheme.ts +36 -0
  328. package/src/stage/stageWarmup.ts +11 -0
  329. package/src/stage/textAutoSize.ts +124 -0
  330. package/src/stage/tier1Export.ts +641 -0
  331. package/src/stage/tokenColor.ts +14 -9
  332. package/src/stage/view.ts +56 -9
  333. package/src/stage/workingView.ts +277 -33
  334. package/src/state/accountPrefs.ts +261 -0
  335. package/src/state/agentActions.ts +173 -15
  336. package/src/state/agentActivity.ts +198 -0
  337. package/src/state/agentQuestion.ts +239 -0
  338. package/src/state/bootOpen.ts +42 -0
  339. package/src/state/cameraVerbs.ts +139 -0
  340. package/src/state/clientId.ts +6 -1
  341. package/src/state/comments.ts +1099 -0
  342. package/src/state/context.tsx +19 -1
  343. package/src/state/editorPrefs.ts +220 -0
  344. package/src/state/errorReports.ts +121 -0
  345. package/src/state/fieldWrite.ts +161 -0
  346. package/src/state/fontRecents.ts +70 -0
  347. package/src/state/fonts.ts +656 -0
  348. package/src/state/graphClipboard.ts +414 -0
  349. package/src/state/imageThumbs.ts +216 -0
  350. package/src/state/insert.ts +444 -67
  351. package/src/state/isolationTrail.ts +92 -0
  352. package/src/state/keyMoves.ts +134 -0
  353. package/src/state/keyframeClipboard.ts +177 -0
  354. package/src/state/keymap.ts +1153 -117
  355. package/src/state/layoutPrefs.ts +272 -0
  356. package/src/state/layouts.ts +205 -0
  357. package/src/state/measureMedia.ts +167 -0
  358. package/src/state/menu.ts +2341 -28
  359. package/src/state/motionStamp.ts +7 -0
  360. package/src/state/panelSize.ts +375 -5
  361. package/src/state/pendingChanges.ts +119 -0
  362. package/src/state/playHold.ts +78 -0
  363. package/src/state/playbackPreparation.ts +562 -0
  364. package/src/state/posterCapture.ts +242 -0
  365. package/src/state/presence.ts +254 -0
  366. package/src/state/projectComponents.ts +160 -0
  367. package/src/state/renderEvents.ts +298 -18
  368. package/src/state/runtimeErrors.ts +46 -0
  369. package/src/state/selection.ts +12 -0
  370. package/src/state/shortcuts.ts +1606 -0
  371. package/src/state/sketchGuide.ts +51 -0
  372. package/src/state/splineRefs.ts +87 -0
  373. package/src/state/stageHealth.ts +259 -0
  374. package/src/state/stageWatchdog.ts +47 -0
  375. package/src/state/stillStage.ts +330 -0
  376. package/src/state/stillThumbs.ts +474 -0
  377. package/src/state/store.ts +23591 -2982
  378. package/src/state/storyboardModel.ts +528 -0
  379. package/src/state/sync.ts +1257 -0
  380. package/src/state/syncCache.ts +159 -0
  381. package/src/state/triangles.ts +13 -0
  382. package/src/state/types.ts +1698 -55
  383. package/src/state/unsavedGuard.ts +49 -0
  384. package/src/state/viewMode.ts +200 -0
  385. package/src/state/webmcp.ts +1128 -0
  386. package/src/state/workspace.ts +1147 -0
  387. package/src/styles/assets-tab.css +304 -0
  388. package/src/styles/canvas.css +545 -0
  389. package/src/styles/comments.css +1435 -0
  390. package/src/styles/controls.css +162 -0
  391. package/src/styles/dialogs.css +390 -0
  392. package/src/styles/insert-drawer.css +586 -0
  393. package/src/styles/inspector-board.css +207 -0
  394. package/src/styles/inspector-object.css +486 -0
  395. package/src/styles/inspector-shot.css +252 -0
  396. package/src/styles/inspector.css +360 -0
  397. package/src/styles/left-column.css +1628 -0
  398. package/src/styles/library-preview.css +575 -0
  399. package/src/styles/library.css +512 -0
  400. package/src/styles/modes/code.css +569 -0
  401. package/src/styles/modes/component.css +1561 -0
  402. package/src/styles/modes/material.css +1201 -0
  403. package/src/styles/modes/storyboard.css +518 -0
  404. package/src/styles/share-component.css +49 -0
  405. package/src/styles/shell.css +653 -0
  406. package/src/styles/shot-brief.css +360 -0
  407. package/src/styles/states.css +76 -0
  408. package/src/styles/sync.css +380 -0
  409. package/src/styles/timeline-graph.css +384 -0
  410. package/src/styles/timeline-meters.css +89 -0
  411. package/src/styles/timeline-rows.css +1959 -0
  412. package/src/styles/timeline-sound.css +1003 -0
  413. package/src/styles/timeline.css +754 -0
  414. package/src/styles/tool-footer.css +525 -0
  415. package/src/styles/topbar.css +886 -0
  416. package/src/styles/versions.css +262 -0
  417. package/src/styles.css +72 -2921
  418. package/src/tools.ts +108 -23
  419. package/dist/assets/index-B7notge7.css +0 -1
  420. package/dist/assets/index-BBE6Ta9L.js +0 -52
  421. package/src/panels/files/icons.tsx +0 -118
  422. package/src/panels/header/DocSwitcher.tsx +0 -85
  423. package/src/panels/header/TransportBar.tsx +0 -86
  424. package/src/panels/inspector/animate/BlockTiming.tsx +0 -341
  425. package/src/panels/inspector/animate/DuringBlocks.tsx +0 -85
  426. package/src/panels/inspector/animate/InOutPresets.tsx +0 -57
  427. package/src/panels/inspector/animate/ResolvedChannels.tsx +0 -150
  428. package/src/panels/inspector/animate/useBlocks.ts +0 -43
  429. package/src/panels/material/MaterialPicker.tsx +0 -172
  430. package/src/panels/timeline/SceneStrip.tsx +0 -145
  431. package/src/panels/toolbar/AddMenu.tsx +0 -230
  432. package/src/stage/MoveGizmo.tsx +0 -411
@@ -15,9 +15,9 @@
15
15
  // It bumps React only when a corner moved by more than half a CSS pixel. Below
16
16
  // that the SVG rounds to the same path and the render is pure cost, and the
17
17
  // camera moves on every frame of a dolly.
18
- import { useMemo } from "react";
18
+ import { useLayoutEffect, useMemo, useRef } from "react";
19
19
  import { useFrame, useThree } from "@react-three/fiber";
20
- import { OrthographicCamera, PerspectiveCamera, Raycaster, Vector2 } from "three";
20
+ import { Matrix4, OrthographicCamera, PerspectiveCamera, Raycaster, Vector2, Vector3 } from "three";
21
21
  import type { Camera, Object3D } from "three";
22
22
  import {
23
23
  SHOT_LAYER_BOX,
@@ -25,26 +25,50 @@ import {
25
25
  SHOT_LAYER_LOCKED,
26
26
  SHOT_LAYER_ORDER,
27
27
  SHOT_LAYER_PICKABLE,
28
+ layerStampEpoch,
28
29
  } from "@dailies-studio/shot";
29
- import { memoLast, outermost } from "@dailies-studio/shot/doc";
30
- import type { LayerLookup } from "@dailies-studio/shot/doc";
30
+ import {
31
+ cameraClipPlanes,
32
+ layerAliveAt,
33
+ maskTargetOf,
34
+ memoLast,
35
+ meshPrimitiveOf,
36
+ motionPathOf,
37
+ outermost,
38
+ parentOf,
39
+ resolveLayerAt,
40
+ resolveTracksAt,
41
+ } from "@dailies-studio/shot/doc";
42
+ import type { Doc3D, LayerLookup, MotionPath } from "@dailies-studio/shot/doc";
31
43
  import { nearestHandle, quadCentre, rotateRingAt } from "./handles.js";
32
44
  import {
45
+ maskHitsLast,
33
46
  pickInRect,
34
47
  pickLayers,
35
- pickLayersByDistance,
36
48
  projectQuad,
37
49
  quadMoved,
50
+ rayHitsQuad,
38
51
  screenQuad,
52
+ toScreen,
39
53
  unionQuad,
54
+ worldQuad,
40
55
  } from "./project.js";
41
56
  import type { LayerFrame, ScreenQuad } from "./project.js";
42
- import type { Grab, StageFeed } from "./feed.js";
57
+ import { projectPeers } from "./feed.js";
58
+ import type { FailedQuad, MaskQuad, PeerQuad, StageFeed } from "./feed.js";
43
59
  import type { PaneRect, StagePane } from "./panes.js";
44
- import { WORKING_FOV_DEG, workingPose } from "./workingView.js";
60
+ import { WORKING_FOV_DEG, orthoEyePushBack, viewBasis, workingPose } from "./workingView.js";
45
61
  import type { WorkingView } from "./workingView.js";
46
- import { isolationExtent, isolationEye } from "./isolation.js";
47
- import type { IsolationFrame } from "./isolation.js";
62
+ import type { HeadOnCamera } from "./isolation.js";
63
+ import {
64
+ projectSplineLayer,
65
+ splineScreenBounds,
66
+ splineScreenHit,
67
+ splineScreenIntersectsRect,
68
+ } from "./splineStage.js";
69
+ import type { ScreenSpline } from "./splineStage.js";
70
+ import { motionPathHandleAt, motionPathKeyAt, motionPathsMoved, projectMotionPath } from "./motionPathStage.js";
71
+ import type { ScreenMotionPath } from "./motionPathStage.js";
48
72
 
49
73
  /** Editor-only geometry lives on layer bit 1, and only the working camera sees
50
74
  * it. Nothing draws on it yet — the frustum gizmo is the first thing that
@@ -70,9 +94,147 @@ function unionMoved(a: StageFeed["union"], b: StageFeed["union"]): boolean {
70
94
  return false;
71
95
  }
72
96
 
97
+ /**
98
+ * The frames this pass brought up to date (`fresh`). Cleared at the top of
99
+ * each pass; one pass at a time, since the stage has one canvas and
100
+ * `useFrame` is never re-entered.
101
+ */
102
+ const FRESH = new Set<LayerFrame>();
103
+
104
+ /**
105
+ * A frame with its world matrix, and its parents', up to date for this pass.
106
+ *
107
+ * The runtime wrote the pass's positions, but three only folds them into
108
+ * `matrixWorld` when it renders, AFTER the frame loop. Read stale, a box that
109
+ * grew (auto text on commit) drew its new size around its old centre until
110
+ * the next commit. So every projection the pass makes takes its frame through
111
+ * here, and a frame nothing projects is left to the render, which recomposes
112
+ * every one anyway: bringing them all up front was the whole graph twice per
113
+ * frame, cloners included. Parents and self only: children (glyph's commit
114
+ * hook lives in `updateMatrixWorld`) are the renderer's.
115
+ */
116
+ function fresh(frame: LayerFrame): LayerFrame {
117
+ if (!FRESH.has(frame)) {
118
+ FRESH.add(frame);
119
+ frame.object?.updateWorldMatrix(true, false);
120
+ }
121
+ return frame;
122
+ }
123
+
73
124
  /** Reused per frame; picking allocates enough already without a raycaster. */
74
125
  const RAYCASTER = new Raycaster();
75
126
  const NDC = new Vector2();
127
+ const PANE_AT = new Vector3();
128
+ const IDENTITY = new Matrix4();
129
+ /** How near a key dot a press has to land, in CSS px: the dot is r 4. */
130
+ const PATH_KEY_GRAB_PX = 8;
131
+
132
+ /**
133
+ * The matrix a layer's Position is written in: its group's PARENT, which is
134
+ * the parent layer's group, or a wrapper between (a splineFollow rig).
135
+ *
136
+ * A layer with no frame this pass (outside its lifetime, or hidden) still
137
+ * draws its path, dimmed, so the matrix is found another way: the group if it
138
+ * is still mounted, then the parent layer's group, then a mounted sibling's
139
+ * parent, then the scene root.
140
+ */
141
+ function parentWorldOf(
142
+ id: string,
143
+ byId: ReadonlyMap<string, LayerFrame>,
144
+ root: Object3D,
145
+ layers: LayerLookup,
146
+ ): Matrix4 {
147
+ const self = byId.get(id);
148
+ const own = self?.object?.parent;
149
+ if (self !== undefined && own != null) {
150
+ fresh(self);
151
+ return own.matrixWorld;
152
+ }
153
+ let mounted: Object3D | null = null;
154
+ root.traverse((o) => {
155
+ if (mounted === null && o.userData[SHOT_LAYER_KEY] === id) mounted = o;
156
+ });
157
+ const mountedParent = (mounted as Object3D | null)?.parent;
158
+ if (mountedParent != null) {
159
+ mountedParent.updateWorldMatrix(true, false);
160
+ return mountedParent.matrixWorld;
161
+ }
162
+ const parentId = parentOf(layers, id);
163
+ const parentFrame = parentId === null ? undefined : byId.get(parentId);
164
+ if (parentFrame?.object !== undefined) {
165
+ fresh(parentFrame);
166
+ return parentFrame.object.matrixWorld;
167
+ }
168
+ for (const [otherId, frame] of byId) {
169
+ if (otherId !== id && frame.object?.parent != null && parentOf(layers, otherId) === parentId) {
170
+ fresh(frame);
171
+ return frame.object.parent.matrixWorld;
172
+ }
173
+ }
174
+ return IDENTITY;
175
+ }
176
+
177
+ interface ProjectedSplineFrame {
178
+ frame: LayerFrame;
179
+ screen: ScreenSpline;
180
+ renderGeometry: boolean;
181
+ }
182
+
183
+ function projectedSplines(
184
+ frames: readonly LayerFrame[],
185
+ layers: LayerLookup | null,
186
+ sceneFrame: number,
187
+ fps: Doc3D["fps"] | null,
188
+ camera: Camera | null,
189
+ width: number,
190
+ height: number,
191
+ ): ProjectedSplineFrame[] {
192
+ if (layers === null || camera === null || width <= 0 || height <= 0) return [];
193
+ const out: ProjectedSplineFrame[] = [];
194
+ for (const frame of frames) {
195
+ // THE STORED LAYER SAYS WHETHER IT IS A SPLINE, before any track is
196
+ // sampled: neither the kind nor the primitive's kind is trackable, and
197
+ // every other tracked layer (each cloner clone too) paid a full resolve
198
+ // per pane per frame only to be skipped.
199
+ const stored = layers.layersById[frame.id];
200
+ if (stored?.kind !== "mesh" || meshPrimitiveOf(stored.mesh)?.kind !== "spline") continue;
201
+ const layer = fps === null ? stored : resolveTracksAt(stored, sceneFrame, fps);
202
+ if (layer.kind !== "mesh") continue;
203
+ const primitive = meshPrimitiveOf(layer.mesh);
204
+ if (primitive?.kind !== "spline") continue;
205
+ const renderGeometry = primitive.renderGeometry !== false;
206
+ const screen = projectSplineLayer(primitive, fresh(frame), camera, width, height, [], renderGeometry);
207
+ if (screen !== null) out.push({ frame, screen, renderGeometry });
208
+ }
209
+ return out;
210
+ }
211
+
212
+ function uniqueIds(rows: readonly { id: string }[]): string[] {
213
+ const seen = new Set<string>();
214
+ const out: string[] = [];
215
+ for (const row of rows) {
216
+ if (seen.has(row.id)) continue;
217
+ seen.add(row.id);
218
+ out.push(row.id);
219
+ }
220
+ return out;
221
+ }
222
+
223
+ function guidesMoved(
224
+ before: StageFeed["splineGuidesWorking"],
225
+ after: StageFeed["splineGuidesWorking"],
226
+ ): boolean {
227
+ if (before.length !== after.length) return true;
228
+ for (let index = 0; index < after.length; index += 1) {
229
+ const a = before[index];
230
+ const b = after[index];
231
+ if (a === undefined || b === undefined || a.id !== b.id || a.selected !== b.selected || a.pts.length !== b.pts.length) {
232
+ return true;
233
+ }
234
+ if (a.pts.some((value, pointIndex) => Math.abs(value - (b.pts[pointIndex] ?? Number.NaN)) > 0.5)) return true;
235
+ }
236
+ return false;
237
+ }
76
238
 
77
239
  /**
78
240
  * `collectFrames`, replayed while nothing about the graph's MEMBERSHIP changed.
@@ -89,9 +251,21 @@ const NDC = new Vector2();
89
251
  * (the whole store is immutable), and an edit is what changes a layer's box,
90
252
  * its lock or its pickability — all of which this walk COPIES rather than
91
253
  * referencing;
92
- * - `sceneFrame`, because a lifetime ends and a layer leaves the tree;
254
+ * - `stamps`, the runtime's stamp counter, because those copied values are also
255
+ * PER-FRAME values: a size track moves the box without touching `layers`;
93
256
  * - `epoch`, the mount counter, because a model or an instance module resolving
94
- * commits a subtree that no value above predicts.
257
+ * commits a subtree that no value above predicts. A lifetime ending is a
258
+ * `LayerNode` unmounting, so it rides this one too.
259
+ *
260
+ * ── WHY `stamps` AND NOT THE PLAYHEAD ────────────────────────────────────
261
+ *
262
+ * The frame used to be the key term standing in for "a copied value moved",
263
+ * which is correct and maximally pessimistic: the playhead changes on every
264
+ * played frame, the stamps change only while something is actually resizing or
265
+ * restacking, so playback of a scene with no size track re-walked the entire
266
+ * graph 30 times a second to rebuild an identical answer. `LayerNode` now
267
+ * compares each stamp before writing it and bumps `layerStampEpoch` only on a
268
+ * real change, which is the same guarantee stated honestly.
95
269
  *
96
270
  * What is deliberately NOT in the key is motion. `matrixWorld` is stored as a
97
271
  * LIVE REFERENCE (see `collectFrames`), so an orbit, a pan, a scrub within one
@@ -99,20 +273,41 @@ const NDC = new Vector2();
99
273
  * point at. That is why replaying the walk is exact rather than merely close.
100
274
  */
101
275
  const collectFramesCached = memoLast(
102
- (root: Object3D, _layers: LayerLookup | null, _sceneFrame: number, _epoch: number): LayerFrame[] =>
276
+ (root: Object3D, _layers: LayerLookup | null, _stamps: number, _epoch: number): LayerFrame[] =>
103
277
  collectFrames(root),
104
278
  );
105
279
 
280
+ /**
281
+ * The walk's answer by id (the first frame of each, see the pass) and its
282
+ * pickable part, replayed while the walk's answer and the isolation scope
283
+ * hold: they change with nothing else, and rebuilding them was a Map and two
284
+ * arrays the size of the graph on every pass, cloners included.
285
+ */
286
+ export const indexFrames = memoLast(
287
+ (
288
+ frames: readonly LayerFrame[],
289
+ isolated: ReadonlySet<string> | null,
290
+ ): { byId: ReadonlyMap<string, LayerFrame>; pickable: readonly LayerFrame[] } => {
291
+ const byId = new Map<string, LayerFrame>();
292
+ for (const f of frames) if (!byId.has(f.id)) byId.set(f.id, f);
293
+ const pickable = frames.filter((f) => f.pickable !== false && (isolated === null || isolated.has(f.id)));
294
+ return { byId, pickable };
295
+ },
296
+ );
297
+
106
298
  export function StageTools({
107
299
  feed,
108
300
  panes,
109
301
  workingView,
110
302
  selection,
111
303
  layers,
112
- sceneFrame,
304
+ sceneFrameAt,
305
+ fps,
113
306
  isolationRoot,
114
307
  isolated,
115
- isolationView,
308
+ headOn,
309
+ motionPaths,
310
+ passDrawing,
116
311
  }: {
117
312
  feed: StageFeed;
118
313
  /** The two pane rectangles, in CSS px from the CANVAS top-left. Everything
@@ -124,18 +319,31 @@ export function StageTools({
124
319
  * not `scene`, because `scene` in this file is three's own scene graph. Null
125
320
  * before a document has arrived, or between scenes. */
126
321
  layers: LayerLookup | null;
127
- /** The playhead, in the scene's own frames. Read only as part of the frame
128
- * walk's cache key: a lifetime ending is a layer leaving the graph. */
129
- sceneFrame: number;
322
+ /** The playhead, in the scene's own frames, read at FRAME time from the
323
+ * stage's clock (StageApp's `playhead`). Samples the active spline's
324
+ * tracked profile/taper; the frame walk keys on `layerStampEpoch` instead. */
325
+ sceneFrameAt: () => number;
326
+ /** Track sampling clock for active spline profile/taper values. */
327
+ fps: Doc3D["fps"] | null;
130
328
  /** The isolated layer itself, which is the BOUNDARY the parent walk stops at
131
329
  * — not the same thing as `isolated`, which is its whole subtree. */
132
330
  isolationRoot: string | null;
133
331
  /** The isolated subtree's ids, or null when nothing is isolated. Picking is
134
332
  * scoped to it — see the filter below. */
135
333
  isolated: ReadonlySet<string> | null;
136
- /** The head-on frame for that subtree, or null. Drives the working pane's
137
- * camera while isolation is on. */
138
- isolationView: IsolationFrame | null;
334
+ /** The derived head-on camera, or null once the orbit rig owns the pane.
335
+ * Already solved against the chrome inset by `StageApp` — this file installs
336
+ * it and does not re-derive any part of it. Non-null for BOTH head-on modes:
337
+ * an isolated subtree and an open component definition. */
338
+ headOn: HeadOnCamera | null;
339
+ /** Draw the selected layers' motion paths (gap 3). StageApp folds the
340
+ * `motionPaths` pref with every state that hides them: playing, watching,
341
+ * a material preview, an export, and any gesture but a path key drag. */
342
+ motionPaths: boolean;
343
+ /** A preparing pass is drawing its own frames under a frozen picture
344
+ * (bridge v51 `passDrawing`), read at frame time: the client's stable
345
+ * getter, never a prop value a render would have to deliver. */
346
+ passDrawing: () => boolean;
139
347
  }): null {
140
348
  const scene = useThree((s) => s.scene);
141
349
  const camera = useThree((s) => s.camera);
@@ -170,8 +378,119 @@ export function StageTools({
170
378
  return cam;
171
379
  }, []);
172
380
 
381
+ /**
382
+ * Pose the camera as a pull-based operation, not only as a side effect of
383
+ * `useFrame`. ClockDriver's compile barrier runs before priority-0 frame
384
+ * subscribers, and SplitRenderer must compile with the same camera that the
385
+ * subscriber below will hand to the draw.
386
+ */
387
+ const prepareWorkingCamera = (): Camera | null => {
388
+ const workingRect = panes.working;
389
+ if (workingRect === null) {
390
+ feed.workingCamera = null;
391
+ return null;
392
+ }
393
+ if (headOn !== null) {
394
+ // A HEAD-ON FRAME TAKES THE WORKING PANE'S SLOT. Not a third pane: a
395
+ // scissored pane per mode is the trap SplitRenderer's header is about,
396
+ // and the two views an author wants while inside a card are "this card,
397
+ // flat" and "the shot". The split that already exists is that view.
398
+ isolationCamera.left = -headOn.halfWidth;
399
+ isolationCamera.right = headOn.halfWidth;
400
+ isolationCamera.top = headOn.halfHeight;
401
+ isolationCamera.bottom = -headOn.halfHeight;
402
+ isolationCamera.near = headOn.near;
403
+ isolationCamera.far = headOn.far;
404
+ isolationCamera.position.set(headOn.eye[0], headOn.eye[1], headOn.eye[2]);
405
+ isolationCamera.up.set(headOn.up[0], headOn.up[1], headOn.up[2]);
406
+ isolationCamera.lookAt(headOn.target[0], headOn.target[1], headOn.target[2]);
407
+ isolationCamera.updateProjectionMatrix();
408
+ isolationCamera.updateMatrixWorld(true);
409
+ feed.workingCamera = isolationCamera;
410
+ return isolationCamera;
411
+ }
412
+
413
+ // Perspective and orthographic views share one pose. The orthographic eye
414
+ // stands back rather than using a negative near plane, so drawing, picking
415
+ // and the compile frustum all see the same positive slab.
416
+ const ortho = workingView.ortho === true;
417
+ const cam = ortho ? workingOrtho : workingCamera;
418
+ const { position } = workingPose(workingView);
419
+ const back = ortho ? orthoEyePushBack(workingView.distance) : 0;
420
+ const dir = ortho
421
+ ? [
422
+ (position[0] - workingView.target[0]) / Math.max(workingView.distance, 1),
423
+ (position[1] - workingView.target[1]) / Math.max(workingView.distance, 1),
424
+ (position[2] - workingView.target[2]) / Math.max(workingView.distance, 1),
425
+ ]
426
+ : [0, 0, 0];
427
+ cam.position.set(
428
+ position[0] + dir[0]! * back,
429
+ position[1] + dir[1]! * back,
430
+ position[2] + dir[2]! * back,
431
+ );
432
+ const upAt = viewBasis(workingView).up;
433
+ cam.up.set(upAt[0], upAt[1], upAt[2]);
434
+ cam.lookAt(workingView.target[0], workingView.target[1], workingView.target[2]);
435
+ const aspect = workingRect.height === 0 ? 1 : workingRect.width / workingRect.height;
436
+ if (ortho) {
437
+ const halfHeight = workingView.distance * Math.tan((WORKING_FOV_DEG * Math.PI) / 360);
438
+ workingOrtho.top = halfHeight;
439
+ workingOrtho.bottom = -halfHeight;
440
+ workingOrtho.right = halfHeight * aspect;
441
+ workingOrtho.left = -halfHeight * aspect;
442
+ workingOrtho.near = 0.1;
443
+ workingOrtho.far = back * 2 + workingView.distance;
444
+ } else {
445
+ workingCamera.fov = WORKING_FOV_DEG;
446
+ workingCamera.aspect = aspect;
447
+ // The Shot's own rule (`cameraClipPlanes`): a fixed 0.1 against an
448
+ // orbit radius of thousands of px z-fights a model's faces here too.
449
+ // Always the derived near, whatever the Shot holds: this view is the
450
+ // editor's own, never an exported picture, so no film's bytes ride on
451
+ // it, and the clip it costs a Shot of planes is 1/200 of the orbit
452
+ // radius in front of the eye.
453
+ const clip = cameraClipPlanes(workingView.distance, true);
454
+ workingCamera.near = clip.near;
455
+ workingCamera.far = clip.far;
456
+ }
457
+ cam.updateProjectionMatrix();
458
+ cam.updateMatrixWorld(true);
459
+ feed.workingCamera = cam;
460
+ return cam;
461
+ };
462
+
463
+ // The layout effect above poses the camera on every commit, and the frame
464
+ // subscriber below used to pose it AGAIN on the advance that same commit
465
+ // triggers: two `updateProjectionMatrix` + `updateMatrixWorld(true)` per
466
+ // frame for one answer. The flag says "this commit already posed it"; the
467
+ // subscriber consumes it, so a heartbeat advance with no commit in between
468
+ // still poses once, and a commit never poses twice.
469
+ const posedThisCommit = useRef(false);
470
+ useLayoutEffect(() => {
471
+ feed.prepareWorkingCamera = prepareWorkingCamera;
472
+ prepareWorkingCamera();
473
+ posedThisCommit.current = true;
474
+ return () => {
475
+ if (feed.prepareWorkingCamera !== prepareWorkingCamera) return;
476
+ feed.prepareWorkingCamera = null;
477
+ feed.workingCamera = null;
478
+ };
479
+ });
480
+
173
481
  useFrame(() => {
174
- const frames = collectFramesCached(scene, layers, sceneFrame, feed.sceneEpoch);
482
+ // ── THE FURNITURE HOLDS STILL WHILE A PASS DRAWS (bridge v51) ─────────
483
+ //
484
+ // A preparing pass draws other frames under the frozen picture, and
485
+ // every projection below would follow them: the outlines, the hints, the
486
+ // motion paths, the closures the pointer lanes pick through. So the feed
487
+ // keeps the frozen frame's answers until the pass publishes the author's
488
+ // frame again; that draw projects everything afresh before the lift.
489
+ if (passDrawing()) return;
490
+ const frames = collectFramesCached(scene, layers, layerStampEpoch(), feed.sceneEpoch);
491
+ // Every projection below brings its own frame's matrix up (`fresh`), and
492
+ // nothing else does: a pass with no selection projects next to nothing.
493
+ FRESH.clear();
175
494
  feed.frames = frames;
176
495
 
177
496
  // ── ONE FRAME PER ID, THE FIRST ─────────────────────────────────────
@@ -194,8 +513,7 @@ export function StageTools({
194
513
  // plumbing for a case that needs a cloner, the working pane, and a click on
195
514
  // a clone that is not the first. Named here; do it when someone reports
196
515
  // handles on the wrong clone.
197
- const byId = new Map<string, LayerFrame>();
198
- for (const f of frames) if (!byId.has(f.id)) byId.set(f.id, f);
516
+ //
199
517
  // ISOLATION SCOPES PICKING, and it does it HERE rather than through
200
518
  // `SHOT_LAYER_PICKABLE`. The runtime re-stamps that userData on every
201
519
  // commit from a dep-less layout effect, so anything the editor wrote onto
@@ -205,123 +523,12 @@ export function StageTools({
205
523
  // A press that lands on nothing is what exits: with the surroundings
206
524
  // unpickable, clicking outside the subtree already produces an empty hit
207
525
  // list, so "click away to leave" needs no second gesture.
208
- const pickable = frames.filter(
209
- (f) => f.pickable !== false && (isolated === null || isolated.has(f.id)),
210
- );
526
+ const { byId, pickable } = indexFrames(frames, isolated);
211
527
  const cameraRect = panes.camera;
212
528
  const workingRect = panes.working;
213
529
 
214
- if (workingRect === null) {
215
- feed.workingCamera = null;
216
- } else if (isolationView !== null) {
217
- // ISOLATION TAKES THE WORKING PANE'S SLOT. Not a third pane: a scissored
218
- // pane per mode is the trap SplitRenderer's header is about, and the two
219
- // views an author wants while inside a card are "this card, flat" and
220
- // "the shot" — which is the split that already exists.
221
- //
222
- // Orthographic and posed from the subtree's OWN basis, so it can roll and
223
- // can look straight down — neither of which the orbit rig can do (its up
224
- // is reset to world +y every frame and its polar angle is clamped off
225
- // both poles).
226
- const aspect = workingRect.height === 0 ? 1 : workingRect.width / workingRect.height;
227
- const extent = isolationExtent(isolationView, aspect);
228
- const { eye, up, far } = isolationEye(isolationView);
229
- isolationCamera.left = -extent.halfWidth;
230
- isolationCamera.right = extent.halfWidth;
231
- isolationCamera.top = extent.halfHeight;
232
- isolationCamera.bottom = -extent.halfHeight;
233
- isolationCamera.near = 0.1;
234
- isolationCamera.far = far;
235
- isolationCamera.position.set(eye[0], eye[1], eye[2]);
236
- isolationCamera.up.set(up[0], up[1], up[2]);
237
- isolationCamera.lookAt(
238
- isolationView.target[0],
239
- isolationView.target[1],
240
- isolationView.target[2],
241
- );
242
- isolationCamera.updateProjectionMatrix();
243
- isolationCamera.updateMatrixWorld(true);
244
- feed.workingCamera = isolationCamera;
245
- } else {
246
- // ── PERSPECTIVE OR ORTHOGRAPHIC, SAME POSE ────────────────────────
247
- //
248
- // Numpad-5. The two rigs are POSED identically — same eye, same target,
249
- // same up — and differ only in the projection, so the toggle turns the
250
- // vanishing point on and off without moving the camera an inch.
251
- //
252
- // The ortho half-height is `distance * tan(fov/2)`, which is exactly what
253
- // the perspective lens frames AT THE TARGET. So `workingScreenScale`
254
- // needs no branch: its `2 * distance * tan(fov/2)` is already both
255
- // answers.
256
- //
257
- // The two agree at the target POINT and not everywhere: an ortho
258
- // projection has no perspective divide, so a layer's corners — which sit
259
- // at different depths once the view is orbited — are foreshortened by one
260
- // rig and not the other. The error grows with distance from the target;
261
- // on it, it is under one percent of the layer's own span
262
- // (stageGeometry.test.ts pins that), which reads as the same picture and
263
- // is what makes the toggle a toggle. Far from the target it is a real
264
- // difference, and it is the whole reason to have an ortho view at all.
265
- const ortho = workingView.ortho === true;
266
- const cam = ortho ? workingOrtho : workingCamera;
267
- const { position } = workingPose(workingView);
268
- // ── THE ORTHO EYE STANDS BACK, IT DOES NOT REACH FORWARD ──────────
269
- //
270
- // An orthographic projection does not depend on where along the view axis
271
- // the eye sits, so the two ways to cover the scene look identical: a
272
- // negative near plane, or a positive near plane with the eye pushed back.
273
- // They are NOT identical to the pointer. `Raycaster.setFromCamera` starts
274
- // an ortho ray AT the near plane, and `rayHitsTriangle` rejects `t < 0` —
275
- // so with a negative near plane every layer behind the eye plane drew
276
- // perfectly and answered no click, no marquee, no gizmo and no frustum
277
- // hit (`pickInRect`'s guard, `MoveGizmo`'s `behind` and `FrustumGizmo`'s
278
- // all read view-space z the same way).
279
- //
280
- // Standing back is the construction `isolationEye` already uses, and it
281
- // fixes all four consumers with no branch in any of them.
282
- const back = ortho ? Math.max(workingView.distance * 8, 10_000) : 0;
283
- const dir = ortho
284
- ? [
285
- (position[0] - workingView.target[0]) / Math.max(workingView.distance, 1),
286
- (position[1] - workingView.target[1]) / Math.max(workingView.distance, 1),
287
- (position[2] - workingView.target[2]) / Math.max(workingView.distance, 1),
288
- ]
289
- : [0, 0, 0];
290
- cam.position.set(
291
- position[0] + dir[0]! * back,
292
- position[1] + dir[1]! * back,
293
- position[2] + dir[2]! * back,
294
- );
295
- // Re-set every frame: `lookAt` reads `up`, and leaving it to whatever a
296
- // previous frame or another consumer left there is how an orbit picks up
297
- // a roll nobody authored.
298
- cam.up.set(0, 1, 0);
299
- cam.lookAt(workingView.target[0], workingView.target[1], workingView.target[2]);
300
- const aspect = workingRect.height === 0 ? 1 : workingRect.width / workingRect.height;
301
- if (ortho) {
302
- const halfHeight = workingView.distance * Math.tan((WORKING_FOV_DEG * Math.PI) / 360);
303
- workingOrtho.top = halfHeight;
304
- workingOrtho.bottom = -halfHeight;
305
- workingOrtho.right = halfHeight * aspect;
306
- workingOrtho.left = -halfHeight * aspect;
307
- // A POSITIVE NEAR PLANE, with the eye already pushed back by `back`
308
- // above. The whole slab is in front of the camera, so every hit test in
309
- // this lane agrees with what is drawn.
310
- workingOrtho.near = 0.1;
311
- workingOrtho.far = back * 2 + workingView.distance;
312
- } else {
313
- workingCamera.fov = WORKING_FOV_DEG;
314
- workingCamera.aspect = aspect;
315
- // The shot camera's own near/far, for the same reason it can afford
316
- // them: every material here draws with `depthTest: false`, so depth
317
- // precision buys nothing and a generous far plane costs nothing.
318
- workingCamera.near = 0.1;
319
- workingCamera.far = Math.max(workingView.distance * 8, 10_000);
320
- }
321
- cam.updateProjectionMatrix();
322
- cam.updateMatrixWorld(true);
323
- feed.workingCamera = cam;
324
- }
530
+ if (!posedThisCommit.current) prepareWorkingCamera();
531
+ posedThisCommit.current = false;
325
532
 
326
533
  /** The pane's rect and camera, or null when that pane is not on screen. */
327
534
  const viewOf = (pane: StagePane): { rect: PaneRect; cam: Camera } | null => {
@@ -330,6 +537,34 @@ export function StageTools({
330
537
  ? null
331
538
  : { rect: workingRect, cam: feed.workingCamera };
332
539
  };
540
+ const sceneFrame = sceneFrameAt();
541
+ const cameraSplines = projectedSplines(
542
+ pickable,
543
+ layers,
544
+ sceneFrame,
545
+ fps,
546
+ cameraRect === null ? null : camera,
547
+ cameraRect?.width ?? 0,
548
+ cameraRect?.height ?? 0,
549
+ );
550
+ const workingSplines = projectedSplines(
551
+ pickable,
552
+ layers,
553
+ sceneFrame,
554
+ fps,
555
+ workingRect === null ? null : feed.workingCamera,
556
+ workingRect?.width ?? 0,
557
+ workingRect?.height ?? 0,
558
+ );
559
+ const splineIds = new Set([...cameraSplines, ...workingSplines].map((row) => row.frame.id));
560
+ const ordinaryPickable = splineIds.size === 0 ? pickable : pickable.filter((frame) => !splineIds.has(frame.id));
561
+ const splineMap = (rows: readonly ProjectedSplineFrame[]): Map<string, ProjectedSplineFrame> => {
562
+ const out = new Map<string, ProjectedSplineFrame>();
563
+ for (const row of rows) if (!out.has(row.frame.id)) out.set(row.frame.id, row);
564
+ return out;
565
+ };
566
+ const cameraSplineById = splineMap(cameraSplines);
567
+ const workingSplineById = splineMap(workingSplines);
333
568
 
334
569
  feed.rayAt = (pane, x, y) => {
335
570
  const view = viewOf(pane);
@@ -345,14 +580,52 @@ export function StageTools({
345
580
  // difference identically zero.
346
581
  return RAYCASTER.ray.clone();
347
582
  };
583
+ feed.paneAt = (pane, design) => {
584
+ const view = viewOf(pane);
585
+ if (view === null) return null;
586
+ // Design y runs down and world y up: world = (x, -y, 0).
587
+ return toScreen(PANE_AT.set(design[0], -design[1], 0), view.cam, view.rect.width, view.rect.height);
588
+ };
589
+ feed.worldToPane = (pane, world) => {
590
+ const view = viewOf(pane);
591
+ return view === null ? null : toScreen(world, view.cam, view.rect.width, view.rect.height);
592
+ };
348
593
 
594
+ const isMask = (id: string): boolean => layers?.layersById[id]?.mask !== undefined;
349
595
  feed.pick = (pane, x, y) => {
350
596
  const ray = feed.rayAt?.(pane, x, y) ?? null;
351
597
  if (ray === null) return [];
352
- // The two panes sort their hits differently, and that divergence is the
353
- // point — see `pickLayersByDistance`. Paint order answers "what is on top"
354
- // in the camera pane; in a free orbit it answers nothing.
355
- return pane === "working" ? pickLayersByDistance(ray, pickable) : pickLayers(ray, pickable);
598
+ const splineRows = pane === "working" ? workingSplines : cameraSplines;
599
+ if (pane === "camera") {
600
+ const rows = pickLayers(ray, ordinaryPickable).map((id) => ({
601
+ id,
602
+ order: byId.get(id)?.order ?? 0,
603
+ }));
604
+ for (const row of splineRows) {
605
+ if (row.frame.locked !== true && splineScreenHit(row.screen, [x, y])) {
606
+ rows.push({ id: row.frame.id, order: row.frame.order });
607
+ }
608
+ }
609
+ rows.sort((a, b) => b.order - a.order);
610
+ return maskHitsLast(uniqueIds(rows), isMask);
611
+ }
612
+ // In the free working view, interleave spline centreline hits with plane
613
+ // hits by ray distance. A spline is one-dimensional, so its layer origin
614
+ // is the stable depth tie-breaker for the sampled screen hit.
615
+ const rows: Array<{ id: string; t: number; order: number }> = [];
616
+ for (const frame of ordinaryPickable) {
617
+ if (frame.locked === true) continue;
618
+ const t = rayHitsQuad(ray, worldQuad(frame));
619
+ if (t !== null) rows.push({ id: frame.id, t, order: frame.order });
620
+ }
621
+ for (const row of splineRows) {
622
+ if (row.frame.locked === true || !splineScreenHit(row.screen, [x, y])) continue;
623
+ const origin = new Vector3().setFromMatrixPosition(row.frame.matrixWorld);
624
+ const t = Math.max(0, origin.sub(ray.origin).dot(ray.direction));
625
+ rows.push({ id: row.frame.id, t, order: row.frame.order });
626
+ }
627
+ rows.sort((a, b) => a.t - b.t || b.order - a.order);
628
+ return maskHitsLast(uniqueIds(rows), isMask);
356
629
  };
357
630
 
358
631
  // The outline a click would select, projected in the pane it was asked
@@ -362,14 +635,40 @@ export function StageTools({
362
635
  const view = viewOf(pane);
363
636
  const hit = feed.pick?.(pane, x, y)[0];
364
637
  if (view === null || layers === null || hit === undefined) return null;
365
- const frame = byId.get(outermost(layers, hit, isolationRoot));
366
- return frame === undefined ? null : screenQuad(frame, view.cam, view.rect.width, view.rect.height);
638
+ const selectedId = outermost(layers, hit, isolationRoot);
639
+ const spline = (pane === "working" ? workingSplineById : cameraSplineById).get(selectedId);
640
+ if (spline !== undefined) return splineScreenBounds(spline.screen, 4);
641
+ const frame = byId.get(selectedId);
642
+ return frame === undefined ? null : screenQuad(fresh(frame), view.cam, view.rect.width, view.rect.height);
643
+ };
644
+
645
+ // One layer's outline BY ID — `hoverQuad` minus the pick. The chrome-hover
646
+ // sink is its caller (see `feed.quadForLayer`); pull-based, so it answers
647
+ // while the stage is idle.
648
+ const quadForLayer = (pane: StagePane, layerId: string): ScreenQuad | null => {
649
+ const view = viewOf(pane);
650
+ if (view === null) return null;
651
+ const spline = (pane === "working" ? workingSplineById : cameraSplineById).get(layerId);
652
+ if (spline !== undefined) return splineScreenBounds(spline.screen, 4);
653
+ const frame = byId.get(layerId);
654
+ return frame === undefined ? null : screenQuad(fresh(frame), view.cam, view.rect.width, view.rect.height);
367
655
  };
656
+ feed.quadForLayer = quadForLayer;
368
657
 
369
658
  feed.pickRect = (pane, rect) => {
370
659
  const view = viewOf(pane);
371
660
  if (view === null) return [];
372
- return pickInRect(pickable, view.cam, view.rect.width, view.rect.height, rect);
661
+ const rows = pickInRect(ordinaryPickable, view.cam, view.rect.width, view.rect.height, rect).map((id) => ({
662
+ id,
663
+ order: byId.get(id)?.order ?? 0,
664
+ }));
665
+ for (const row of pane === "working" ? workingSplines : cameraSplines) {
666
+ if (row.frame.locked !== true && splineScreenIntersectsRect(row.screen, rect, 2)) {
667
+ rows.push({ id: row.frame.id, order: row.frame.order });
668
+ }
669
+ }
670
+ rows.sort((a, b) => a.order - b.order);
671
+ return uniqueIds(rows);
373
672
  };
374
673
 
375
674
  feed.grabAt = (pane, x, y, selection) => {
@@ -462,9 +761,10 @@ export function StageTools({
462
761
  };
463
762
  }
464
763
  }
465
- // Then the BODY of anything already selected. Pressing an unselected
466
- // layer must select it rather than drag it — dragging something the
467
- // author had not selected is the classic way to move the wrong thing.
764
+ // Then the BODY of anything already selected. A press on an unselected
765
+ // layer answers null here: StageApp's empty-space lane picks it on the
766
+ // press and asks again once the new selection lands, so one press-drag
767
+ // selects and moves.
468
768
  const hits = feed.pick?.(pane, x, y) ?? [];
469
769
  const hit = hits.find((id) => selection.includes(id));
470
770
  const frame = hit === undefined ? undefined : byId.get(hit);
@@ -486,7 +786,9 @@ export function StageTools({
486
786
  changed = true;
487
787
  continue;
488
788
  }
489
- selected.push(frame);
789
+ // Brought up here, so its quads, the union and TransformGizmo's centre
790
+ // (`feed.frames`, read later in this frame loop) see this pass's pose.
791
+ selected.push(fresh(frame));
490
792
  if (feed.ids[i] !== id) changed = true;
491
793
  // The lock is a document flag, so it changes only on a commit — but the
492
794
  // overlay repaints off `feed.version`, not off React, so it has to be in
@@ -526,6 +828,165 @@ export function StageTools({
526
828
  : projectQuad(world, feed.workingCamera, workingRect.width, workingRect.height),
527
829
  };
528
830
  if (unionMoved(feed.union, union)) changed = true;
831
+ const selectedIds = new Set(selection);
832
+ const splineGuidesWorking = workingSplines
833
+ .filter((row) => !row.renderGeometry || selectedIds.has(row.frame.id))
834
+ .map((row) => ({
835
+ id: row.frame.id,
836
+ pts: row.screen.line.flatMap((point) => [point[0], point[1]]),
837
+ selected: selectedIds.has(row.frame.id),
838
+ }));
839
+ if (guidesMoved(feed.splineGuidesWorking, splineGuidesWorking)) changed = true;
840
+ // ── THE CHROME'S HOVER HINT RIDES THE SAME PASS ───────────────────────
841
+ // The sink seeds these at write time (an idle stage runs no frame); this
842
+ // keeps them true while the camera moves. Same half-pixel gate as the
843
+ // selection quads, so a stationary hint costs nothing.
844
+ const hintCamera = feed.hintId === null ? null : quadForLayer("camera", feed.hintId);
845
+ const hintWorking = feed.hintId === null ? null : quadForLayer("working", feed.hintId);
846
+ if (hintQuadMoved(feed.hintCamera, hintCamera) || hintQuadMoved(feed.hintWorking, hintWorking)) {
847
+ changed = true;
848
+ }
849
+ // ── AND SO DOES A BROKEN PIECE'S PLACEHOLDER ──────────────────────────
850
+ // A layer the runtime isolated keeps its group (its transform and stamps
851
+ // are still written), so its outline is the hole where it would draw.
852
+ const failedCamera: FailedQuad[] = [];
853
+ const failedWorking: FailedQuad[] = [];
854
+ for (const id of feed.failedIds) {
855
+ const label = feed.failedLabels.get(id) ?? "Component";
856
+ const inCamera = quadForLayer("camera", id);
857
+ if (inCamera !== null) failedCamera.push({ quad: inCamera, label });
858
+ const inWorking = quadForLayer("working", id);
859
+ if (inWorking !== null) failedWorking.push({ quad: inWorking, label });
860
+ }
861
+ if (failedMoved(feed.failedCamera, failedCamera) || failedMoved(feed.failedWorking, failedWorking)) {
862
+ changed = true;
863
+ }
864
+ // ── AND SO DOES A SELECTED GROUP'S MASK (gap 7) ───────────────────────
865
+ // The matte that cuts the selection, dashed. Not for a mask that is itself
866
+ // selected: its own outline already says where it is.
867
+ const maskCamera: MaskQuad[] = [];
868
+ const maskWorking: MaskQuad[] = [];
869
+ if (layers !== null) {
870
+ const masks = new Set(selection.map((id) => maskTargetOf(layers, id)));
871
+ for (const id of masks) {
872
+ if (id === null || selectedIds.has(id)) continue;
873
+ const round = layers.layersById[id]?.kind === "ellipse";
874
+ const inCamera = quadForLayer("camera", id);
875
+ if (inCamera !== null) maskCamera.push({ quad: inCamera, round });
876
+ const inWorking = quadForLayer("working", id);
877
+ if (inWorking !== null) maskWorking.push({ quad: inWorking, round });
878
+ }
879
+ }
880
+ if (masksMoved(feed.maskCamera, maskCamera) || masksMoved(feed.maskWorking, maskWorking)) changed = true;
881
+ // ── AND SO DO OTHER PEOPLE'S SELECTIONS (bridge v43) ──────────────────
882
+ const peersCamera = projectPeers(feed.peerSel, quadForLayer, "camera", ids);
883
+ const peersWorking = projectPeers(feed.peerSel, quadForLayer, "working", ids);
884
+ if (peersMoved(feed.peersCamera, peersCamera) || peersMoved(feed.peersWorking, peersWorking)) changed = true;
885
+ // ── AND SO DOES THE OPEN STUDIO PIN'S LAYER RING (bridge v45) ─────────
886
+ const pinLayerCamera = feed.pinLayerId === null ? null : quadForLayer("camera", feed.pinLayerId);
887
+ if (pinLayerCamera === null ? feed.pinLayerCamera !== null : quadMoved(feed.pinLayerCamera ?? undefined, pinLayerCamera)) {
888
+ changed = true;
889
+ }
890
+ // ── AND SO DOES THE PEN PREVIEW, ON ITS PANE'S CAMERA ALONE ───────────
891
+ // It projects at render time, so a camera that moves under a still cursor
892
+ // (a scrubbed camera move, a wheel zoom) must repaint it. Read only while
893
+ // the pen holds points: otherwise a moving camera costs no repaint. The
894
+ // pen draws on the frame: the camera pane, or with no camera pane on
895
+ // screen a definition's one pane, whose camera is the working one.
896
+ const penCamera = cameraRect !== null ? camera : feed.workingCamera;
897
+ const cameraPose =
898
+ feed.penLive && penCamera !== null
899
+ ? [...penCamera.projectionMatrix.elements, ...penCamera.matrixWorldInverse.elements]
900
+ : null;
901
+ if (cameraPose !== null && cameraPose.some((v, i) => v !== feed.cameraPose?.[i])) changed = true;
902
+ // ── AND SO DO THE MOTION PATHS (gap 3) ─────────────────────────────────
903
+ // Projected from the STORED layer, so a key edit redraws on its commit
904
+ // and a scrub between keys moves only the tick and the current dot.
905
+ const motionCamera: ScreenMotionPath[] = [];
906
+ const motionWorking: ScreenMotionPath[] = [];
907
+ const motionParents = new Map<string, { parent: Matrix4; path: MotionPath }>();
908
+ if (motionPaths && layers !== null && fps !== null) {
909
+ for (const id of selection) {
910
+ const stored = layers.layersById[id];
911
+ // A splineFollow layer's position is the spline's: it has no path of its own.
912
+ if (stored === undefined || stored.splineFollow !== undefined) continue;
913
+ if (isolated !== null && !isolated.has(id)) continue;
914
+ const path = motionPathOf(stored, fps);
915
+ if (path === null) continue;
916
+ const parent = parentWorldOf(id, byId, scene, layers);
917
+ motionParents.set(id, { parent, path });
918
+ const dim = !layerAliveAt(stored, sceneFrame);
919
+ const at = (f: number) => resolveLayerAt(stored, f, fps).position;
920
+ const sample =
921
+ sceneFrame < path.from || sceneFrame > path.to
922
+ ? null
923
+ : {
924
+ now: at(sceneFrame),
925
+ before: at(Math.max(path.from, sceneFrame - 0.5)),
926
+ after: at(Math.min(path.to, sceneFrame + 0.5)),
927
+ };
928
+ if (cameraRect !== null) {
929
+ motionCamera.push(
930
+ projectMotionPath(id, path, sceneFrame, dim, sample, parent, camera, cameraRect.width, cameraRect.height),
931
+ );
932
+ }
933
+ if (workingRect !== null && feed.workingCamera !== null) {
934
+ motionWorking.push(
935
+ projectMotionPath(
936
+ id,
937
+ path,
938
+ sceneFrame,
939
+ dim,
940
+ sample,
941
+ parent,
942
+ feed.workingCamera,
943
+ workingRect.width,
944
+ workingRect.height,
945
+ ),
946
+ );
947
+ }
948
+ }
949
+ }
950
+ if (motionPathsMoved(feed.motionPathsCamera, motionCamera) || motionPathsMoved(feed.motionPathsWorking, motionWorking)) {
951
+ changed = true;
952
+ }
953
+ // The selected dot goes with its key: a deleted key, a deselected layer
954
+ // or the pref off leaves no handles to show.
955
+ const picked = feed.selectedPathKey;
956
+ if (picked !== null && !motionParents.get(picked.layerId)?.path.keys.some((k) => k.id === picked.keyId)) {
957
+ feed.selectedPathKey = null;
958
+ changed = true;
959
+ }
960
+ feed.pathKeyHit = (pane, x, y) =>
961
+ motionPathKeyAt(pane === "camera" ? feed.motionPathsCamera : feed.motionPathsWorking, x, y, PATH_KEY_GRAB_PX);
962
+ feed.pathHandleHit = (pane, x, y) =>
963
+ motionPathHandleAt(
964
+ pane === "camera" ? feed.motionPathsCamera : feed.motionPathsWorking,
965
+ feed.selectedPathKey,
966
+ x,
967
+ y,
968
+ PATH_KEY_GRAB_PX,
969
+ );
970
+ feed.pathKeyAnchor = (layerId, keyId) => {
971
+ const row = motionParents.get(layerId);
972
+ const index = row?.path.keys.findIndex((k) => k.id === keyId) ?? -1;
973
+ const key = row?.path.keys[index];
974
+ if (row === undefined || key === undefined) return null;
975
+ const parent = row.parent.clone();
976
+ const world = new Vector3(key.point[0], -key.point[1], -key.point[2]).applyMatrix4(parent);
977
+ const neighbours = [row.path.keys[index - 1], row.path.keys[index + 1]].flatMap((k) =>
978
+ k === undefined ? [] : [k.point],
979
+ );
980
+ return {
981
+ world,
982
+ parent,
983
+ frame: key.frame,
984
+ point: key.point,
985
+ neighbours,
986
+ ...(key.spatialIn === undefined ? {} : { spatialIn: key.spatialIn }),
987
+ ...(key.spatialOut === undefined ? {} : { spatialOut: key.spatialOut }),
988
+ };
989
+ };
529
990
  // A pane appearing or disappearing changes an array's LENGTH without moving
530
991
  // any corner, so the emptiness check is what makes a mode switch repaint —
531
992
  // otherwise the overlay keeps drawing the pane that just went away.
@@ -541,6 +1002,19 @@ export function StageTools({
541
1002
  feed.union = union;
542
1003
  feed.quadsCamera = quadsCamera;
543
1004
  feed.quadsWorking = quadsWorking;
1005
+ feed.splineGuidesWorking = splineGuidesWorking;
1006
+ feed.hintCamera = hintCamera;
1007
+ feed.hintWorking = hintWorking;
1008
+ feed.failedCamera = failedCamera;
1009
+ feed.failedWorking = failedWorking;
1010
+ feed.maskCamera = maskCamera;
1011
+ feed.maskWorking = maskWorking;
1012
+ feed.peersCamera = peersCamera;
1013
+ feed.peersWorking = peersWorking;
1014
+ feed.pinLayerCamera = pinLayerCamera;
1015
+ feed.cameraPose = cameraPose;
1016
+ feed.motionPathsCamera = motionCamera;
1017
+ feed.motionPathsWorking = motionWorking;
544
1018
  feed.version += 1;
545
1019
  // The overlay leaf's own subscription (see feed.onOverlay) — never a
546
1020
  // StageApp state setter, which re-committed the whole stage per moved
@@ -551,6 +1025,31 @@ export function StageTools({
551
1025
  return null;
552
1026
  }
553
1027
 
1028
+ /** `quadMoved` with null on either side: appearing or vanishing IS a move. */
1029
+ function hintQuadMoved(prev: ScreenQuad | null, next: ScreenQuad | null): boolean {
1030
+ if (prev === null || next === null) return prev !== next;
1031
+ return quadMoved(prev, next);
1032
+ }
1033
+
1034
+ /** A list of quads moved: one appeared, one went, or one moved. */
1035
+ function peersMoved(prev: readonly PeerQuad[], next: readonly PeerQuad[]): boolean {
1036
+ if (prev.length !== next.length) return true;
1037
+ return next.some((row, index) => {
1038
+ const was = prev[index];
1039
+ return was?.name !== row.name || was.color !== row.color || was.tag !== row.tag || was.ring !== row.ring || quadMoved(was.quad, row.quad);
1040
+ });
1041
+ }
1042
+
1043
+ function masksMoved(prev: readonly MaskQuad[], next: readonly MaskQuad[]): boolean {
1044
+ if (prev.length !== next.length) return true;
1045
+ return next.some((row, index) => prev[index]?.round !== row.round || quadMoved(prev[index]?.quad, row.quad));
1046
+ }
1047
+
1048
+ function failedMoved(prev: readonly FailedQuad[], next: readonly FailedQuad[]): boolean {
1049
+ if (prev.length !== next.length) return true;
1050
+ return next.some((row, index) => prev[index]?.label !== row.label || quadMoved(prev[index]?.quad, row.quad));
1051
+ }
1052
+
554
1053
 
555
1054
  /**
556
1055
  * Every layer group in the scene, as the geometry the editor reasons about.
@@ -562,16 +1061,17 @@ export function StageTools({
562
1061
  */
563
1062
  export function collectFrames(root: Object3D): LayerFrame[] {
564
1063
  const out: LayerFrame[] = [];
565
- root.traverse((o) => {
1064
+ root.traverseVisible((o) => {
566
1065
  const id: unknown = o.userData[SHOT_LAYER_KEY];
567
1066
  const box: unknown = o.userData[SHOT_LAYER_BOX];
568
1067
  if (typeof id !== "string" || !Array.isArray(box)) return;
569
1068
  out.push({
570
1069
  id,
571
1070
  size: [Number(box[0]), Number(box[1])],
572
- // Live reference, not a clone: `traverse` runs after the transforms are
573
- // written, and the quad is read in the same tick.
1071
+ // Live reference, not a clone. It is only as fresh as the last
1072
+ // `updateWorldMatrix`, which the overlay pass runs on `object` first.
574
1073
  matrixWorld: o.matrixWorld,
1074
+ object: o,
575
1075
  order: typeof o.userData[SHOT_LAYER_ORDER] === "number" ? (o.userData[SHOT_LAYER_ORDER] as number) : 0,
576
1076
  locked: o.userData[SHOT_LAYER_LOCKED] === true,
577
1077
  pickable: o.userData[SHOT_LAYER_PICKABLE] !== false,