@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
@@ -8,47 +8,152 @@
8
8
  // first section that shows every field is a section nobody can find the model
9
9
  // in.
10
10
  //
11
+ // ── THE MATERIAL'S OWN PARAMS ARE HERE, BECAUSE THEY WERE NOWHERE ───────
12
+ //
13
+ // The picker is a browse-and-apply grid with no controls in it, so a generator's
14
+ // params were fixed at its contract defaults the moment it was applied and were
15
+ // then unreachable from the entire chrome. On a glow generator that means the
16
+ // layer's COLOUR: the author could see it and could not touch it.
17
+ //
18
+ // The rows are built from the generator's CONTRACT through `PropRow`, the one
19
+ // row every contract's props render in (a placed component's, a definition's,
20
+ // Components' "this use"), so a colour param gets the colour field and a
21
+ // bounded number its slider for exactly the reason they do there. Never from
22
+ // the stored params: those hold only what somebody already changed, with no
23
+ // kinds, no ranges and no labels. A param somebody changed carries the row's
24
+ // reset, which deletes it back to the contract's own default.
25
+ //
26
+ // ── EVERY NUMBER DRAGS LIVE, AND EVERY ROW READS ITS OWN MIXED ──────────
27
+ //
28
+ // A number row is `MeshNumber`: its commit and its drag are ONE builder over
29
+ // each mesh layer's own block, so the stage moves while the pointer is down and
30
+ // a drag's release lands the document its moves did. Mixed is asked per field,
31
+ // over the value the field SHOWS (a shape's depth is the derived min(w, h) when
32
+ // it stores none, so two shapes of different sizes disagree on it).
33
+ //
11
34
  // ponytail: add the toon detail rows when someone is actually art-directing
12
35
  // one.
36
+ import { useEffect, useMemo } from "react";
37
+ import type { ReactNode } from "react";
13
38
  import {
39
+ ColorField,
14
40
  Combobox,
41
+ IconClose,
15
42
  InspectorRow,
16
43
  InspectorSection,
17
44
  NumberField,
45
+ PropRow,
18
46
  SegmentedControl,
19
47
  TextField,
48
+ Toggle,
49
+ useLocked,
50
+ wordOptions,
20
51
  } from "@dailies-studio/ui";
21
- import { MESH_LOOPS, MESH_SHADING_MODES } from "@dailies-studio/shot/doc";
22
- import type { Layer3D, MeshLayer3D, MeshLoop, MeshShading } from "@dailies-studio/shot/doc";
52
+ import { orderSpecs } from "@dailies-studio/contract";
53
+ import type { ControlSpec } from "@dailies-studio/contract";
54
+ import { KeyframeDot } from "../KeyframeDot.js";
55
+ import {
56
+ DEFAULT_PRIMITIVE_COLOR,
57
+ DEFAULT_SPLINE_CAPS,
58
+ DEFAULT_SPLINE_UP,
59
+ MAX_SPLINE_ANGLE_DEG,
60
+ MAX_SPLINE_COORDINATE,
61
+ MAX_SPLINE_SCALE,
62
+ MEDIA_LOOPS,
63
+ MESH_PRIMITIVE_KINDS,
64
+ MESH_SHADING_MODES,
65
+ isGraphMaterial,
66
+ meshPrimitiveOf,
67
+ primitiveDepthOf,
68
+ splineVisibleRange,
69
+ // The path table's bounds, read by `MeshNumber` below instead of restated
70
+ // here: `numberRange` is what the keyframe gate clamps against, so a second
71
+ // copy in the chrome is a pill that offers a number the diamond then refuses.
72
+ trackablePath,
73
+ } from "@dailies-studio/shot/doc";
74
+ import type {
75
+ Layer3D,
76
+ MeshLayer3D,
77
+ MediaLoop,
78
+ MeshMaterialParam,
79
+ MeshPrimitive,
80
+ MeshPrimitiveKind,
81
+ MeshShading,
82
+ SplineProfile,
83
+ SplinePrimitive,
84
+ } from "@dailies-studio/shot/doc";
23
85
  import { useShotEditor, useStore } from "../../../state/context.js";
24
- import { useSelectedLayers, useSelectionValue, useSelectionWrite } from "../useDocWrite.js";
86
+ import { useSelectedLayers, useSelectionScrub, useSelectionWrite } from "../useDocWrite.js";
87
+ import { primitiveForKind } from "../../../state/insert.js";
25
88
 
26
89
  type MeshBlock = MeshLayer3D["mesh"];
27
90
 
91
+ /** A widget with no `mixed` of its own shows its Mixed this way: a segmented
92
+ * control and a combobox both read an empty value as "none of these", and a
93
+ * combobox shows its placeholder over it. */
94
+ const NO_VALUE = "";
95
+ const MIXED = "Mixed";
96
+
97
+ /** The two parts of a spline profile's offset, one vec2 track keyed together. */
98
+ const OFFSET_PARTS = ["Offset X", "Offset Y"] as const;
99
+
28
100
  export function MeshSection() {
29
101
  const write = useSelectionWrite();
30
102
  const layers = useSelectedLayers();
31
103
  const store = useStore();
32
104
  const doc = useShotEditor((s) => s.doc);
33
105
  const library = useShotEditor((s) => s.library);
34
- const mesh = useSelectionValue((layer) => (layer.kind === "mesh" ? layer.mesh : undefined));
35
- if (!layers.some((l) => l.kind === "mesh") || mesh.value === undefined) return null;
106
+ const contracts = useShotEditor((s) => s.contracts);
107
+ const splineEdit = useShotEditor((s) => s.splineEdit);
108
+ // A VIEWER'S PANEL LOCKS EVERY VERB THAT WRITES. The widgets read the lock
109
+ // themselves; this section's own buttons are plain ones, so they read it
110
+ // here. Opening the node editor only navigates, and stays live.
111
+ const locked = useLocked();
112
+ // THE MESH LAYERS THEMSELVES, not `useSelectionValue`. That hook compares
113
+ // members with `Object.is`, and `layer.mesh` is an object — so every
114
+ // multi-selection was "Mixed" by identity, over values the author could see
115
+ // agree, and the two number fields wore that placeholder permanently. Mixed is
116
+ // asked per FIELD below instead.
117
+ const meshes = layers.filter((l): l is MeshLayer3D => l.kind === "mesh");
118
+ const m = meshes[0]?.mesh;
119
+ // Pushed up by the stage as the model parses — the chrome is three-free and
120
+ // cannot read a glTF's material list itself. Empty for a primitive and for a
121
+ // model that has not loaded yet.
122
+ const slots = useShotEditor((s) => (meshes[0] === undefined ? undefined : s.meshMaterialSlots[meshes[0].id])) ?? [];
123
+ const legacyMaterial = m?.material === undefined || isGraphMaterial(m.material) ? undefined : m.material;
124
+ const graphBinding = m?.material !== undefined && isGraphMaterial(m.material) ? m.material : undefined;
125
+ const graph = graphBinding === undefined ? undefined : doc?.materials?.[graphBinding.materialId];
126
+ const generator = legacyMaterial?.generator;
127
+ // Fetched the moment a material is ON SCREEN rather than when a row asks for
128
+ // it: the rows ARE the contract, so there is nothing to render until it is
129
+ // here. Cached in the store, so re-selecting the same layer costs no request.
130
+ useEffect(() => {
131
+ if (generator !== undefined) void store.loadContract(generator);
132
+ }, [store, generator]);
133
+ // Model assets only. Offering an image would let an author point a mesh layer
134
+ // at a PNG, which `validateShotDoc` refuses on save — a menu that can only
135
+ // produce an invalid document is worse than a short menu. Memoised on the
136
+ // asset table: the section repaints on every document identity change (once
137
+ // per rAF frame of any drag), and the table itself changes only on an upload.
138
+ const assets = doc?.assets;
139
+ const models = useMemo(
140
+ () =>
141
+ Object.entries(assets ?? {})
142
+ .filter(([, asset]) => asset.kind === "model")
143
+ .map(([id, asset]) => ({ value: id, label: asset.path.split("/").pop() ?? id })),
144
+ [assets],
145
+ );
146
+ if (m === undefined) return null;
36
147
 
37
- const m = mesh.value;
38
148
  // The catalog's display name, or the raw id when the library has not loaded
39
149
  // (or when the document names a generator this build does not ship). Showing
40
150
  // the id rather than "Choose…" is what makes the second case visible at all —
41
151
  // a material that silently reads as unset is one an author would set twice.
42
- const materialName =
43
- m.material === undefined
44
- ? null
45
- : (library.find((row) => row.id === m.material?.generator)?.name ?? m.material.generator);
46
- // Model assets only. Offering an image would let an author point a mesh layer
47
- // at a PNG, which `validateShotDoc` refuses on save — a menu that can only
48
- // produce an invalid document is worse than a short menu.
49
- const models = Object.entries(doc?.assets ?? {})
50
- .filter(([, asset]) => asset.kind === "model")
51
- .map(([id, asset]) => ({ value: id, label: asset.path.split("/").pop() ?? id }));
152
+ const materialName = graph?.name ?? (
153
+ generator === undefined ? null : (library.find((row) => row.id === generator)?.name ?? generator)
154
+ );
155
+ const materialContract = generator === undefined ? undefined : contracts[generator];
156
+ const meshIds = meshes.map((l) => l.id);
52
157
 
53
158
  /** Every write goes through one mapper so a field that CLEARS itself deletes
54
159
  * its key rather than setting it to `undefined`: codegen prints the block by
@@ -59,58 +164,273 @@ export function MeshSection() {
59
164
  const set = <K extends keyof MeshBlock>(key: K, value: MeshBlock[K]): void =>
60
165
  patch((block) => ({ ...block, [key]: value }));
61
166
 
167
+ /** Does the selection disagree about ONE field? A scalar read per row, which
168
+ * is the only shape `Object.is` answers honestly. */
169
+ const mixedOn = <T,>(read: (block: MeshBlock) => T): boolean =>
170
+ meshes.some((l) => !Object.is(read(l.mesh), read(m)));
171
+
172
+ /** ONE primitive field, edited on each layer's OWN base primitive and only
173
+ * on layers of that kind (`withPrim`). */
174
+ const setPrim = <K extends MeshPrimitive["kind"]>(
175
+ kind: K,
176
+ edit: (p: Extract<MeshPrimitive, { kind: K }>) => MeshPrimitive,
177
+ ): void => patch((block) => withPrim(block, kind, edit));
178
+ const primMixed = <K extends MeshPrimitive["kind"]>(
179
+ kind: K,
180
+ read: (p: Extract<MeshPrimitive, { kind: K }>) => unknown,
181
+ ): boolean =>
182
+ mixedOn((block) => {
183
+ const p = meshPrimitiveOf(block);
184
+ return p?.kind === kind ? read(p as Extract<MeshPrimitive, { kind: K }>) : undefined;
185
+ });
186
+
187
+ // A SHAPE section and a MODEL section are one component with one gate: the
188
+ // schema says a mesh is exactly one of the two, so every row below asks
189
+ // which it is dressing. The clip rows hide for a shape (asset-only, inert),
190
+ // and the shape rows hide for a model.
191
+ const primitive = meshPrimitiveOf(m);
192
+ const shapeLayerBox = meshes[0]?.transform.size ?? [300, 300];
193
+ // The depth each layer DRAWS (absent is min(w, h) of its own box), which is
194
+ // both what the field shows and what Mixed is asked of.
195
+ const depthMixed = meshes.some(
196
+ (l) => primitiveDepthOf(l.mesh, l.transform.size) !== primitiveDepthOf(m, shapeLayerBox),
197
+ );
198
+ const kindMixed = mixedOn((block) => meshPrimitiveOf(block)?.kind);
199
+ const shadingMixed = mixedOn((block) => block.shading?.mode ?? "unlit");
200
+
62
201
  return (
63
- <InspectorSection title="Model" persistKey="shot:mesh">
64
- <InspectorRow label="Asset">
65
- <Combobox
66
- options={models}
67
- value={m.asset}
68
- ariaLabel="Model asset"
69
- placeholder="Search models"
70
- onChange={(next) => set("asset", next)}
71
- />
72
- </InspectorRow>
202
+ <InspectorSection className="insp-object" title={primitive !== null ? "Shape" : "Model"} persistKey="shot:mesh">
203
+ {primitive !== null ? (
204
+ <>
205
+ <InspectorRow label="Shape">
206
+ <Combobox
207
+ options={MESH_PRIMITIVE_KINDS.map((kind) => ({
208
+ value: kind,
209
+ label: kind.charAt(0).toUpperCase() + kind.slice(1),
210
+ }))}
211
+ value={kindMixed ? NO_VALUE : primitive.kind}
212
+ ariaLabel="Shape kind"
213
+ placeholder={MIXED}
214
+ // A FRESH record on a kind switch: per-shape params are the old
215
+ // shape's silhouette, and a torus's arc smuggled into a box is a
216
+ // stale key the author cannot see.
217
+ onChange={(next) => set("primitive", primitiveForKind(next as MeshPrimitiveKind))}
218
+ />
219
+ </InspectorRow>
220
+ <ShapeColorRow
221
+ value={m.color ?? DEFAULT_PRIMITIVE_COLOR}
222
+ mixed={mixedOn((block) => (block.color ?? DEFAULT_PRIMITIVE_COLOR).toLowerCase())}
223
+ />
224
+ {primitive.kind === "plane" || primitive.kind === "spline" ? null : (
225
+ <MeshNumber
226
+ label="Depth"
227
+ ariaLabel="Shape depth"
228
+ // The EFFECTIVE depth: absent means min(w, h), and showing that
229
+ // number is what makes the default legible instead of a blank.
230
+ value={primitiveDepthOf(m, shapeLayerBox)}
231
+ mixed={depthMixed}
232
+ unit="px"
233
+ min={1}
234
+ step={1}
235
+ path="mesh.depthPx"
236
+ edit={(block, next) => ({ ...block, depthPx: Math.round(next) })}
237
+ />
238
+ )}
239
+ {primitive.kind === "box" ? (
240
+ <MeshNumber
241
+ label="Corner"
242
+ ariaLabel="Corner radius"
243
+ value={primitive.cornerPx ?? 0}
244
+ mixed={primMixed("box", (p) => p.cornerPx ?? 0)}
245
+ unit="px"
246
+ min={0}
247
+ // No `max`: primitive.ts clamps the BUILT bevel to half the
248
+ // smallest dimension and keeps the authored number.
249
+ step={1}
250
+ path="mesh.primitive.cornerPx"
251
+ // 3D edge rounding on the BODY, deliberately not paint.radius
252
+ // (paint is inert on every mesh).
253
+ edit={(block, next) => withPrim(block, "box", (p) => ({ ...p, cornerPx: Math.round(next) }))}
254
+ />
255
+ ) : null}
256
+ {primitive.kind === "torus" ? (
257
+ <>
258
+ <MeshNumber
259
+ label="Tube"
260
+ ariaLabel="Tube ratio"
261
+ value={primitive.tubeRatio ?? 0.3}
262
+ mixed={primMixed("torus", (p) => p.tubeRatio ?? 0.3)}
263
+ min={0.01}
264
+ max={0.99}
265
+ step={0.01}
266
+ path="mesh.primitive.tubeRatio"
267
+ edit={(block, next) => withPrim(block, "torus", (p) => ({ ...p, tubeRatio: next }))}
268
+ />
269
+ {/* THE PROGRESS-RING SWEEP: keyframing this is how a ring
270
+ fills. */}
271
+ <MeshNumber
272
+ label="Arc"
273
+ ariaLabel="Arc degrees"
274
+ value={primitive.arcDeg ?? 360}
275
+ mixed={primMixed("torus", (p) => p.arcDeg ?? 360)}
276
+ unit="°"
277
+ min={1}
278
+ max={360}
279
+ step={1}
280
+ path="mesh.primitive.arcDeg"
281
+ edit={(block, next) => withPrim(block, "torus", (p) => ({ ...p, arcDeg: Math.round(next) }))}
282
+ />
283
+ </>
284
+ ) : null}
285
+ {primitive.kind === "spline" ? (
286
+ <SplineRows
287
+ primitive={primitive}
288
+ hasTaperTracks={meshes.some(
289
+ (layer) =>
290
+ layer.tracks?.["mesh.primitive.startScale"] !== undefined ||
291
+ layer.tracks?.["mesh.primitive.endScale"] !== undefined,
292
+ )}
293
+ hasTwistTrack={meshes.some(
294
+ (layer) => layer.tracks?.["mesh.primitive.twistDeg"] !== undefined,
295
+ )}
296
+ editing={meshes.length === 1 && splineEdit?.layerId === meshes[0]!.id}
297
+ locked={locked}
298
+ onEdit={() =>
299
+ store.setSplineEditContext(
300
+ meshes.length !== 1 || splineEdit?.layerId === meshes[0]!.id
301
+ ? null
302
+ : { layerId: meshes[0]!.id, selectedPointIds: [] },
303
+ )
304
+ }
305
+ onChange={(edit) => setPrim("spline", edit)}
306
+ mixed={(read) => primMixed("spline", read)}
307
+ onRefusal={(message) => store.reportError(message)}
308
+ />
309
+ ) : null}
310
+ </>
311
+ ) : (
312
+ <InspectorRow label="Asset">
313
+ <Combobox
314
+ options={models}
315
+ value={mixedOn((block) => block.asset ?? "") ? NO_VALUE : (m.asset ?? "")}
316
+ ariaLabel="Model asset"
317
+ placeholder={mixedOn((block) => block.asset ?? "") ? MIXED : "Search models"}
318
+ onChange={(next) => set("asset", next)}
319
+ />
320
+ </InspectorRow>
321
+ )}
73
322
 
74
- <InspectorRow label="Shading">
75
- <SegmentedControl
76
- ariaLabel="Mesh shading"
77
- value={m.shading?.mode ?? "unlit"}
78
- options={MESH_SHADING_MODES.map((mode) => ({ value: mode, label: mode }))}
79
- onChange={(next) => set("shading", { mode: next as MeshShading["mode"] } as MeshShading)}
80
- />
81
- </InspectorRow>
323
+ {graph !== undefined && graph.model !== "studio" ? null : (
324
+ <InspectorRow label="Shading">
325
+ <SegmentedControl
326
+ ariaLabel="Mesh shading"
327
+ // A disagreeing selection selects NOTHING rather than the lead's
328
+ // mode: an unmatched value is how a segmented control says Mixed.
329
+ value={shadingMixed ? NO_VALUE : (m.shading?.mode ?? "unlit")}
330
+ options={wordOptions(MESH_SHADING_MODES)}
331
+ onChange={(next) => set("shading", { mode: next as MeshShading["mode"] } as MeshShading)}
332
+ />
333
+ </InspectorRow>
334
+ )}
82
335
 
83
- {/* THE MATERIAL IS A NAME AND TWO BUTTONS, not a list. The catalog is 48
84
- entries with thumbnails, which is a browser and not an inspector row —
85
- so this says what is on and opens the picker, exactly as the Asset row
86
- would if a model were 48 things you had to look at to choose between.
87
- The generator's own params are edited in the picker's own lane; this
88
- row's job is "what is it, and take it off". */}
89
- <InspectorRow label="Material">
90
- <div className="mesh-material-row">
91
- <button
92
- type="button"
93
- className="mesh-material-name"
94
- onClick={() => store.openMaterialPicker(layers.filter((l) => l.kind === "mesh").map((l) => l.id))}
95
- >
96
- {materialName ?? "Choose…"}
97
- </button>
98
- {m.material === undefined ? null : (
336
+ {/* New authoring enters the graph workspace. Legacy catalog bindings stay
337
+ readable below, but the old picker is intentionally no longer the
338
+ primary creation surface. The remove is a verb INSIDE the row's box,
339
+ the house accessory, so the row is as wide with a material as
340
+ without one. */}
341
+ <InspectorRow
342
+ label="Material"
343
+ trailing={
344
+ m.material === undefined ? undefined : (
99
345
  <button
100
346
  type="button"
101
- className="mesh-material-clear"
347
+ className="insp-key is-danger"
102
348
  aria-label="Remove material"
103
349
  title="Back to the model's own materials"
104
- onClick={() =>
105
- store.removeMeshMaterial(layers.filter((l) => l.kind === "mesh").map((l) => l.id))
106
- }
350
+ disabled={locked}
351
+ onClick={() => store.removeMeshMaterial(meshIds)}
107
352
  >
108
- ×
353
+ <IconClose />
109
354
  </button>
110
- )}
111
- </div>
355
+ )
356
+ }
357
+ >
358
+ <button
359
+ type="button"
360
+ className="text-input mesh-material-name"
361
+ disabled={meshes.length !== 1}
362
+ title={meshes.length === 1 ? "Edit material nodes on this mesh" : "Select one mesh to edit its material"}
363
+ onClick={() => {
364
+ const mesh = meshes[0];
365
+ if (meshes.length === 1 && mesh !== undefined) store.openMaterialWorkspace(mesh.id);
366
+ }}
367
+ >
368
+ {materialName ?? "Set up nodes…"}
369
+ </button>
112
370
  </InspectorRow>
113
371
 
372
+ {/* THE MODEL'S OWN MATERIALS, one row each.
373
+ The list is pushed up by the stage as the glTF parses, so it is absent
374
+ for a primitive and for a model that has not loaded — and absent is
375
+ the right answer in both: a mesh with one surface has nothing to
376
+ choose between, and offering slot rows for a model nobody has seen
377
+ yet would be offering names this chrome does not have. Each row enters
378
+ the workspace ON that slot, which is the only way to author a submesh
379
+ without first opening the whole model and then changing the header. */}
380
+ {meshes.length !== 1 || slots.length === 0 ? null : (
381
+ <div className="mesh-slot-list" role="group" aria-label="Model materials">
382
+ {slots.map((entry) => (
383
+ <button
384
+ type="button"
385
+ key={entry.index}
386
+ className="mesh-slot-row"
387
+ title={`Edit the nodes on ${entry.name.trim() === "" ? `material ${entry.index}` : entry.name}`}
388
+ onClick={() => {
389
+ const mesh = meshes[0];
390
+ if (mesh !== undefined) store.openMaterialWorkspace(mesh.id, { slot: entry.index });
391
+ }}
392
+ >
393
+ <span className="mesh-slot-index">{entry.index}</span>
394
+ <span className="mesh-slot-name">
395
+ {entry.name.trim() === "" ? `Material ${entry.index}` : entry.name}
396
+ </span>
397
+ <small>
398
+ {entry.drawables} mesh{entry.drawables === 1 ? "" : "es"}
399
+ {entry.maps.length === 0 ? "" : ` · ${entry.maps.length} map${entry.maps.length === 1 ? "" : "s"}`}
400
+ </small>
401
+ </button>
402
+ ))}
403
+ </div>
404
+ )}
405
+
406
+ {/* ONE MESH AT A TIME, which is InstanceSection's rule for the same
407
+ reason: these keys belong to ONE generator's contract, and a selection
408
+ may wear two different generators — so a fan-out would write keys the
409
+ other material has never heard of. */}
410
+ {m.material === undefined ? null : meshes.length > 1 ? (
411
+ <div className="insp-notice">
412
+ Select one mesh on its own to edit the material&apos;s controls.
413
+ </div>
414
+ ) : graph !== undefined ? (
415
+ // MaterialSection, below, owns a graph material's controls.
416
+ null
417
+ ) : materialContract === undefined ? (
418
+ // Ceiling: `loadContract` swallows its failure by design, so a generator
419
+ // this build does not ship rests here rather than saying so. The Material
420
+ // row above still names it, which is what tells the author which one.
421
+ <div className="insp-notice">Loading the material&apos;s controls…</div>
422
+ ) : (
423
+ // Contract order: `order` first, then the manifest's own declaration
424
+ // order, the rule every spec-driven panel lists by.
425
+ orderSpecs(materialContract).map((spec) => (
426
+ <MaterialParamRow key={spec.key} spec={spec} value={legacyMaterial?.params?.[spec.key]} />
427
+ ))
428
+ )}
429
+
430
+ {/* The clip rows are ASSET-ONLY (a shape has no clips; the fields are
431
+ inert-kept on one), so a shape section simply does not show them. */}
432
+ {primitive !== null ? null : (
433
+ <>
114
434
  {/* Free TEXT, not a dropdown. Clip names live inside the GLB and the
115
435
  chrome never parses one — it holds a document, not a scene graph — so
116
436
  a picker here could only ever offer the name that is already set.
@@ -118,7 +438,8 @@ export function MeshSection() {
118
438
  bridge is a feed field, not an inspector change. */}
119
439
  <InspectorRow label="Clip">
120
440
  <TextField
121
- value={m.clip ?? ""}
441
+ value={mixedOn((block) => block.clip ?? "") ? "" : (m.clip ?? "")}
442
+ {...(mixedOn((block) => block.clip ?? "") ? { placeholder: MIXED } : {})}
122
443
  ariaLabel="Animation clip"
123
444
  onCommit={(next) =>
124
445
  patch((block) => {
@@ -134,39 +455,546 @@ export function MeshSection() {
134
455
  />
135
456
  </InspectorRow>
136
457
 
137
- <InspectorRow label="Start">
138
- <NumberField
139
- value={m.clipStartFrame ?? 0}
140
- mixed={mesh.mixed}
141
- ariaLabel="Clip start frame"
142
- unit="f"
143
- step={1}
144
- onCommit={(next) => set("clipStartFrame", Math.round(next))}
145
- />
146
- </InspectorRow>
458
+ <MeshNumber
459
+ label="Start"
460
+ ariaLabel="Clip start frame"
461
+ value={m.clipStartFrame ?? 0}
462
+ mixed={mixedOn((block) => block.clipStartFrame ?? 0)}
463
+ unit="f"
464
+ step={1}
465
+ edit={(block, next) => ({ ...block, clipStartFrame: Math.round(next) })}
466
+ />
147
467
 
148
468
  <InspectorRow label="Loop">
149
469
  <SegmentedControl
150
470
  ariaLabel="Clip loop"
151
- value={m.loop ?? "hold"}
152
- options={MESH_LOOPS.map((loop) => ({ value: loop, label: loop }))}
153
- onChange={(next) => set("loop", next as MeshLoop)}
471
+ value={mixedOn((block) => block.loop ?? "hold") ? NO_VALUE : (m.loop ?? "hold")}
472
+ options={wordOptions(MEDIA_LOOPS)}
473
+ onChange={(next) => set("loop", next as MediaLoop)}
154
474
  />
155
475
  </InspectorRow>
156
476
 
157
- <InspectorRow label="Rate">
158
- <NumberField
159
- value={m.timeScale ?? 1}
160
- mixed={mesh.mixed}
161
- ariaLabel="Clip rate"
477
+ <MeshNumber
478
+ label="Rate"
479
+ ariaLabel="Clip rate"
480
+ value={m.timeScale ?? 1}
481
+ mixed={mixedOn((block) => block.timeScale ?? 1)}
482
+ // "Times as fast": the unit says multiplier, and the whole box scrubs.
483
+ unit="×"
484
+ step={0.1}
485
+ // Zero is REFUSED by validation: it freezes the clip forever, which
486
+ // is a pose you can already spell by clearing the clip, and it is
487
+ // indistinguishable from a slider that failed to write. Negative
488
+ // plays the clip backwards and is allowed.
489
+ edit={(block, next) => ({ ...block, timeScale: next === 0 ? 1 : next })}
490
+ />
491
+ </>
492
+ )}
493
+ </InspectorSection>
494
+ );
495
+ }
496
+
497
+ /** ONE primitive field's edit, on a block's OWN base primitive and only when it
498
+ * is that kind. Spreading the lead's painted primitive instead turned a
499
+ * sphere into a box, copied one spline's points onto another, and baked the
500
+ * lead's sampled animated fields into every base. */
501
+ function withPrim<K extends MeshPrimitive["kind"]>(
502
+ block: MeshBlock,
503
+ kind: K,
504
+ edit: (p: Extract<MeshPrimitive, { kind: K }>) => MeshPrimitive,
505
+ ): MeshBlock {
506
+ const p = meshPrimitiveOf(block);
507
+ return p?.kind === kind ? { ...block, primitive: edit(p as Extract<MeshPrimitive, { kind: K }>) } : block;
508
+ }
509
+
510
+ /**
511
+ * One number of the mesh block, as one box: its HARD limits default to the path
512
+ * table's own `numberRange`, so the field and the keyframe gate cannot answer
513
+ * "what is a legal number here" two ways, and an explicit `min`/`max` still
514
+ * wins for a row whose chrome limit is deliberately tighter than the schema's
515
+ * (a path declaring `exclusiveMin` has to be one of those: a bound of 0 the
516
+ * gate refuses is not a bound a field may offer). `step` stays the caller's,
517
+ * because it is a feel and not a limit.
518
+ *
519
+ * `edit` is the ONE builder for the typed value and the drag, applied to each
520
+ * mesh layer's own block: a capture is handed a layer out of the gesture's
521
+ * BASE document, so a drag never compounds its own moves, and its release
522
+ * lands exactly the document its last move drew.
523
+ */
524
+ function MeshNumber({
525
+ label,
526
+ ariaLabel,
527
+ value,
528
+ mixed,
529
+ unit,
530
+ min,
531
+ max,
532
+ step,
533
+ path,
534
+ parts,
535
+ pin = true,
536
+ edit,
537
+ }: {
538
+ label: string;
539
+ ariaLabel?: string;
540
+ value: number;
541
+ mixed: boolean;
542
+ unit?: string;
543
+ min?: number;
544
+ max?: number;
545
+ step: number;
546
+ /** The track the value belongs to, which puts its diamond in the box. */
547
+ path?: string;
548
+ /** A vector's parts, keyed together by that diamond. */
549
+ parts?: readonly string[];
550
+ /** False for a vector's later boxes: its first box carries the one pin. */
551
+ pin?: boolean;
552
+ edit: (block: MeshBlock, value: number) => MeshBlock;
553
+ }) {
554
+ const write = useSelectionWrite();
555
+ const build = (layer: Layer3D, next: number): Layer3D =>
556
+ layer.kind === "mesh" ? ({ ...layer, mesh: edit(layer.mesh, next) } as Layer3D) : layer;
557
+ const scrub = useSelectionScrub((layer, raw) => build(layer, Number(raw)));
558
+ const tabled = path === undefined ? undefined : trackablePath(path)?.numberRange;
559
+ const lo = min ?? tabled?.min;
560
+ const hi = max ?? tabled?.max;
561
+ const trailing: ReactNode =
562
+ path === undefined ? undefined : (
563
+ <KeyframeDot path={path} label={label} pin={pin} {...(parts === undefined ? {} : { parts })} />
564
+ );
565
+ return (
566
+ <InspectorRow>
567
+ <NumberField
568
+ label={label}
569
+ value={value}
570
+ mixed={mixed}
571
+ ariaLabel={ariaLabel ?? label}
572
+ {...(unit === undefined ? {} : { unit })}
573
+ {...(lo === undefined ? {} : { min: lo })}
574
+ {...(hi === undefined ? {} : { max: hi })}
575
+ step={step}
576
+ trailing={trailing}
577
+ onCommit={(next) => write((layer) => build(layer, next))}
578
+ onScrubStart={scrub.start}
579
+ onScrub={scrub.move}
580
+ onScrubEnd={scrub.end}
581
+ />
582
+ </InspectorRow>
583
+ );
584
+ }
585
+
586
+ function SplineRows({
587
+ primitive,
588
+ hasTaperTracks,
589
+ hasTwistTrack,
590
+ editing,
591
+ locked,
592
+ onEdit,
593
+ onChange,
594
+ mixed,
595
+ onRefusal,
596
+ }: {
597
+ /** The LEAD spline, for display only. */
598
+ primitive: SplinePrimitive;
599
+ hasTaperTracks: boolean;
600
+ hasTwistTrack: boolean;
601
+ editing: boolean;
602
+ /** A viewer's panel: point editing writes the spline, so it does not open
603
+ * (the stage takes no spline edit from a viewer either). */
604
+ locked: boolean;
605
+ onEdit(): void;
606
+ /** Every row hands an UPDATER, applied to each layer's own spline, so one
607
+ * field edit never carries the lead's points or other fields across. */
608
+ onChange(edit: (p: SplinePrimitive) => SplinePrimitive): void;
609
+ mixed(read: (p: SplinePrimitive) => unknown): boolean;
610
+ onRefusal(message: string): void;
611
+ }) {
612
+ const frame = primitive.frameSize;
613
+ const up = primitive.up ?? DEFAULT_SPLINE_UP;
614
+ const profile = profileOf(primitive);
615
+ const setProfile = (edit: (prof: SplineProfile) => SplineProfile): void =>
616
+ onChange((p) => withProfile(p, edit));
617
+ const offset = profile.offset ?? [0, 0];
618
+ const nonTubeClosed = primitive.closed === true && profile.kind !== "tube";
619
+ const forbiddenCloseTracks = hasTaperTracks || (profile.kind !== "tube" && hasTwistTrack);
620
+ return (
621
+ <>
622
+ <InspectorRow label="Points">
623
+ <button type="button" className="btn" aria-pressed={editing} disabled={locked} onClick={onEdit}>
624
+ {editing ? "Finish editing" : `Edit ${primitive.points.length} points`}
625
+ </button>
626
+ </InspectorRow>
627
+ <InspectorRow label="Basis">
628
+ <SegmentedControl
629
+ ariaLabel="Spline basis"
630
+ value={mixed((p) => p.basis ?? "bezier") ? NO_VALUE : (primitive.basis ?? "bezier")}
631
+ options={[{ value: "bezier", label: "Bezier" }, { value: "catmull-rom", label: "Catmull" }]}
632
+ onChange={(basis) =>
633
+ onChange((p) =>
634
+ basis === "catmull-rom"
635
+ ? { ...p, basis, points: p.points.map(({ in: _dropIn, out: _dropOut, ...point }) => point) }
636
+ : { ...p, basis: "bezier" },
637
+ )
638
+ }
639
+ />
640
+ </InspectorRow>
641
+ <InspectorRow
642
+ label="Closed"
643
+ {...(primitive.points.length < 3
644
+ ? { labelTitle: "A closed spline needs at least three points" }
645
+ : forbiddenCloseTracks && primitive.closed !== true
646
+ ? {
647
+ labelTitle:
648
+ profile.kind !== "tube" && hasTwistTrack
649
+ ? "Remove taper and twist tracks before closing this ribbon or rail"
650
+ : "Remove separate taper tracks before closing the spline",
651
+ }
652
+ : {})}
653
+ >
654
+ <Toggle
655
+ checked={primitive.closed === true}
656
+ mixed={mixed((p) => p.closed === true)}
657
+ ariaLabel="Closed spline"
658
+ disabled={primitive.closed !== true && (primitive.points.length < 3 || forbiddenCloseTracks)}
659
+ onChange={(closed) =>
660
+ onChange((p) => {
661
+ if (!closed) return { ...p, closed: false };
662
+ if (p.points.length < 3) return p;
663
+ const loopScale = Math.max(0, Math.min(MAX_SPLINE_SCALE, p.startScale ?? 1));
664
+ const twistDeg =
665
+ profileOf(p).kind === "tube"
666
+ ? p.twistDeg
667
+ : Math.round((p.twistDeg ?? 0) / 360) * 360;
668
+ return {
669
+ ...p,
670
+ closed: true,
671
+ startScale: loopScale,
672
+ endScale: loopScale,
673
+ ...(twistDeg === undefined ? {} : { twistDeg }),
674
+ };
675
+ })
676
+ }
677
+ />
678
+ </InspectorRow>
679
+ <SplineNumber label="Frame W" value={frame[0]} mixed={mixed((p) => p.frameSize[0])} min={1} max={MAX_SPLINE_COORDINATE} step={1} unit="px" edit={(p, value) => ({ ...p, frameSize: [value, p.frameSize[1]] })} />
680
+ <SplineNumber label="Frame H" value={frame[1]} mixed={mixed((p) => p.frameSize[1])} min={1} max={MAX_SPLINE_COORDINATE} step={1} unit="px" edit={(p, value) => ({ ...p, frameSize: [p.frameSize[0], value] })} />
681
+ {(["X", "Y", "Z"] as const).map((axis, index) => (
682
+ <SplineNumber
683
+ key={axis}
684
+ label={`Up ${axis}`}
685
+ value={up[index] ?? 0}
686
+ mixed={mixed((p) => (p.up ?? DEFAULT_SPLINE_UP)[index])}
687
+ min={-MAX_SPLINE_COORDINATE}
688
+ max={MAX_SPLINE_COORDINATE}
162
689
  step={0.1}
163
- // Zero is REFUSED by validation — it freezes the clip forever, which
164
- // is a pose you can already spell by clearing the clip, and it is
165
- // indistinguishable from a slider that failed to write. Negative
166
- // plays the clip backwards and is allowed.
167
- onCommit={(next) => set("timeScale", next === 0 ? 1 : next)}
690
+ edit={(p, value) => {
691
+ const own = p.up ?? DEFAULT_SPLINE_UP;
692
+ const next: [number, number, number] = [own[0] ?? 0, own[1] ?? 1, own[2] ?? 0];
693
+ next[index] = value;
694
+ return {
695
+ ...p,
696
+ up: next.every((component) => Math.abs(component) < 1e-4)
697
+ ? [...DEFAULT_SPLINE_UP]
698
+ : next,
699
+ };
700
+ }}
701
+ />
702
+ ))}
703
+ <InspectorRow label="Profile">
704
+ <SegmentedControl
705
+ ariaLabel="Spline profile"
706
+ value={mixed((p) => profileOf(p).kind) ? NO_VALUE : profile.kind}
707
+ options={[
708
+ { value: "tube", label: "Tube" },
709
+ {
710
+ value: "ribbon",
711
+ label: "Ribbon",
712
+ ...(primitive.closed === true && profile.kind === "tube" && hasTwistTrack
713
+ ? { title: "Remove the closed tube's twist track first" }
714
+ : {}),
715
+ },
716
+ {
717
+ value: "rail",
718
+ label: "Rail",
719
+ ...(primitive.closed === true && profile.kind === "tube" && hasTwistTrack
720
+ ? { title: "Remove the closed tube's twist track first" }
721
+ : {}),
722
+ },
723
+ ]}
724
+ onChange={(kind) => {
725
+ if (
726
+ primitive.closed === true &&
727
+ profile.kind === "tube" &&
728
+ kind !== "tube" &&
729
+ hasTwistTrack
730
+ ) {
731
+ onRefusal("Remove the closed tube's twist track before switching to ribbon or rail.");
732
+ return;
733
+ }
734
+ setProfile((prof) => {
735
+ if (prof.kind === kind) return prof;
736
+ const common = {
737
+ ...(prof.offset === undefined ? {} : { offset: [...prof.offset] as [number, number] }),
738
+ ...(prof.angleDeg === undefined ? {} : { angleDeg: prof.angleDeg }),
739
+ };
740
+ return kind === "ribbon"
741
+ ? { kind, widthPx: 16, ...common }
742
+ : kind === "rail"
743
+ ? { kind, widthPx: 16, heightPx: 8, ...common }
744
+ : { kind: "tube", radiusPx: 8, ...common };
745
+ });
746
+ }}
168
747
  />
169
748
  </InspectorRow>
170
- </InspectorSection>
749
+ {/* The three profile sizes state their own `min` and take their `max` from
750
+ the table: the table's bound is `exclusiveMin: 0`, which a field cannot
751
+ spell (0 would commit and the keyframe gate would then refuse it), so
752
+ 0.01 is deliberately TIGHTER than the schema by one quantum. */}
753
+ {profile.kind === "tube" ? (
754
+ <SplineNumber path="mesh.primitive.profile.radiusPx" label="Radius" value={profile.radiusPx ?? 8} mixed={mixed((p) => { const prof = profileOf(p); return prof.kind === "tube" ? (prof.radiusPx ?? 8) : undefined; })} min={0.01} step={1} unit="px" edit={(p, radiusPx) => withProfile(p, (prof) => (prof.kind === "tube" ? { ...prof, radiusPx } : prof))} />
755
+ ) : (
756
+ <SplineNumber path="mesh.primitive.profile.widthPx" label="Width" value={profile.widthPx ?? 16} mixed={mixed((p) => { const prof = profileOf(p); return prof.kind === "tube" ? undefined : (prof.widthPx ?? 16); })} min={0.01} step={1} unit="px" edit={(p, widthPx) => withProfile(p, (prof) => (prof.kind === "tube" ? prof : { ...prof, widthPx }))} />
757
+ )}
758
+ {profile.kind === "rail" ? (
759
+ <SplineNumber path="mesh.primitive.profile.heightPx" label="Height" value={profile.heightPx ?? 8} mixed={mixed((p) => { const prof = profileOf(p); return prof.kind === "rail" ? (prof.heightPx ?? 8) : undefined; })} min={0.01} step={1} unit="px" edit={(p, heightPx) => withProfile(p, (prof) => (prof.kind === "rail" ? { ...prof, heightPx } : prof))} />
760
+ ) : null}
761
+ {/* ONE TRACK FOR BOTH OFFSETS: the profile offset is a vec2, so each of
762
+ its two boxes carries the vector's diamond and they light together,
763
+ and Offset X's box holds the track's one expose pin. */}
764
+ <SplineNumber path="mesh.primitive.profile.offset" parts={OFFSET_PARTS} label="Offset X" value={offset[0]} mixed={mixed((p) => (profileOf(p).offset ?? [0, 0])[0])} min={-MAX_SPLINE_COORDINATE} max={MAX_SPLINE_COORDINATE} step={1} unit="px" edit={(p, value) => withProfile(p, (prof) => ({ ...prof, offset: [value, (prof.offset ?? [0, 0])[1]] }))} />
765
+ <SplineNumber path="mesh.primitive.profile.offset" parts={OFFSET_PARTS} pin={false} label="Offset Y" value={offset[1]} mixed={mixed((p) => (profileOf(p).offset ?? [0, 0])[1])} min={-MAX_SPLINE_COORDINATE} max={MAX_SPLINE_COORDINATE} step={1} unit="px" edit={(p, value) => withProfile(p, (prof) => ({ ...prof, offset: [(prof.offset ?? [0, 0])[0], value] }))} />
766
+ <SplineNumber path="mesh.primitive.profile.angleDeg" label="Profile angle" value={profile.angleDeg ?? 0} mixed={mixed((p) => profileOf(p).angleDeg ?? 0)} min={-MAX_SPLINE_ANGLE_DEG} max={MAX_SPLINE_ANGLE_DEG} step={1} unit="°" edit={(p, angleDeg) => withProfile(p, (prof) => ({ ...prof, angleDeg }))} />
767
+ {profile.kind === "ribbon" ? (
768
+ <p className="insp-note">Ribbon is double-sided and uncapped.</p>
769
+ ) : splineVisibleRange(primitive).fullLoop ? (
770
+ <p className="insp-note">A full closed loop has no ends. Caps apply after opening or trimming it.</p>
771
+ ) : (
772
+ <InspectorRow label="Caps">
773
+ <SegmentedControl
774
+ ariaLabel="Spline caps"
775
+ value={mixed((p) => p.caps ?? DEFAULT_SPLINE_CAPS) ? NO_VALUE : (primitive.caps ?? DEFAULT_SPLINE_CAPS)}
776
+ options={[{ value: "none", label: "None" }, { value: "flat", label: "Flat" }, { value: "round", label: "Round" }]}
777
+ onChange={(caps) => onChange((p) => ({ ...p, caps: caps as NonNullable<SplinePrimitive["caps"]> }))}
778
+ />
779
+ </InspectorRow>
780
+ )}
781
+ <SplineNumber path="mesh.primitive.trimStart" label="Trim start" value={primitive.trimStart ?? 0} mixed={mixed((p) => p.trimStart ?? 0)} step={0.01} edit={(p, trimStart) => ({ ...p, trimStart })} />
782
+ <SplineNumber path="mesh.primitive.trimEnd" label="Trim end" value={primitive.trimEnd ?? 1} mixed={mixed((p) => p.trimEnd ?? 1)} step={0.01} edit={(p, trimEnd) => ({ ...p, trimEnd })} />
783
+ <SplineNumber path="mesh.primitive.trimOffset" label="Trim offset" value={primitive.trimOffset ?? 0} mixed={mixed((p) => p.trimOffset ?? 0)} step={0.01} edit={(p, trimOffset) => ({ ...p, trimOffset })} />
784
+ <SplineNumber
785
+ {...(nonTubeClosed ? {} : { path: "mesh.primitive.twistDeg" as const })}
786
+ label="Twist"
787
+ value={primitive.twistDeg ?? 0}
788
+ mixed={mixed((p) => p.twistDeg ?? 0)}
789
+ min={-MAX_SPLINE_ANGLE_DEG}
790
+ max={MAX_SPLINE_ANGLE_DEG}
791
+ step={nonTubeClosed ? 360 : 1}
792
+ unit="°"
793
+ edit={(p, value) => {
794
+ // A closed ribbon or rail must twist in whole turns, per layer.
795
+ const whole = p.closed === true && profileOf(p).kind !== "tube";
796
+ return { ...p, twistDeg: whole ? Math.round(value / 360) * 360 : value };
797
+ }}
798
+ />
799
+ {primitive.closed === true ? (
800
+ <SplineNumber
801
+ label="Loop scale"
802
+ value={primitive.startScale ?? 1}
803
+ mixed={mixed((p) => p.startScale ?? 1)}
804
+ min={0}
805
+ max={MAX_SPLINE_SCALE}
806
+ step={0.05}
807
+ edit={(p, value) => ({ ...p, startScale: value, endScale: value })}
808
+ />
809
+ ) : (
810
+ <>
811
+ <SplineNumber path="mesh.primitive.startScale" label="Start scale" value={primitive.startScale ?? 1} mixed={mixed((p) => p.startScale ?? 1)} min={0} max={MAX_SPLINE_SCALE} step={0.05} edit={(p, startScale) => ({ ...p, startScale })} />
812
+ <SplineNumber path="mesh.primitive.endScale" label="End scale" value={primitive.endScale ?? 1} mixed={mixed((p) => p.endScale ?? 1)} min={0} max={MAX_SPLINE_SCALE} step={0.05} edit={(p, endScale) => ({ ...p, endScale })} />
813
+ </>
814
+ )}
815
+ <InspectorRow label="Geometry">
816
+ <Toggle
817
+ checked={primitive.renderGeometry !== false}
818
+ mixed={mixed((p) => p.renderGeometry !== false)}
819
+ ariaLabel="Render spline geometry"
820
+ onChange={(renderGeometry) => onChange((p) => ({ ...p, renderGeometry }))}
821
+ />
822
+ </InspectorRow>
823
+ </>
171
824
  );
172
825
  }
826
+
827
+ function profileOf(p: SplinePrimitive): SplineProfile {
828
+ return p.profile ?? { kind: "tube", radiusPx: 8 };
829
+ }
830
+
831
+ /** A profile edit on ONE spline: a closed ribbon or rail twists in whole
832
+ * turns, so leaving the tube rounds that spline's own twist to one. */
833
+ function withProfile(p: SplinePrimitive, edit: (prof: SplineProfile) => SplineProfile): SplinePrimitive {
834
+ const next = edit(profileOf(p));
835
+ const twistDeg =
836
+ p.closed === true && next.kind !== "tube"
837
+ ? Math.round((p.twistDeg ?? 0) / 360) * 360
838
+ : p.twistDeg;
839
+ return { ...p, profile: next, ...(twistDeg === undefined ? {} : { twistDeg }) };
840
+ }
841
+
842
+ /** One spline number: a `MeshNumber` whose edit is over each layer's own
843
+ * spline (`withPrim`), never the lead's. */
844
+ function SplineNumber({
845
+ edit,
846
+ ...row
847
+ }: {
848
+ label: string;
849
+ value: number;
850
+ mixed: boolean;
851
+ path?: string;
852
+ parts?: readonly string[];
853
+ pin?: boolean;
854
+ unit?: string;
855
+ min?: number;
856
+ max?: number;
857
+ step: number;
858
+ edit: (p: SplinePrimitive, value: number) => SplinePrimitive;
859
+ }) {
860
+ return <MeshNumber {...row} edit={(block, value) => withPrim(block, "spline", (p) => edit(p, value))} />;
861
+ }
862
+
863
+ /**
864
+ * The shape's base albedo. Its own component for the same reason
865
+ * `MaterialParamRow` is one: `ColorPicker` routes its whole drag through
866
+ * `scrub.move`, so a row without the document bracket is a thumb that moves
867
+ * over a document that never changes.
868
+ */
869
+ function ShapeColorRow({ value, mixed }: { value: string; mixed: boolean }) {
870
+ const write = useSelectionWrite();
871
+ const scrub = useSelectionScrub((layer, raw) => writeShapeColor(layer, raw));
872
+ return (
873
+ <InspectorRow>
874
+ {/* The hex row Fill and Stroke draw, so the inspector has ONE colour
875
+ grammar, its diamond inside the field. Unlabelled like theirs: the
876
+ swatch is the colour, and the accessible name ("Shape colour") says
877
+ what it colours. OPAQUE, because `mesh.color` is `#rrggbb` only (the
878
+ path's `opaqueHex`): an alpha the picker offered would be a value the
879
+ write below refuses. */}
880
+ <ColorField
881
+ value={value}
882
+ mixed={mixed}
883
+ opaque
884
+ ariaLabel="Shape colour"
885
+ commit={(hex) => write((layer) => writeShapeColor(layer, hex))}
886
+ scrub={scrub}
887
+ trailing={<KeyframeDot path="mesh.color" />}
888
+ />
889
+ </InspectorRow>
890
+ );
891
+ }
892
+
893
+ /** PURE, over the layer it is handed — `writeParam`'s rule: a value the
894
+ * document cannot hold leaves the layer untouched by reference. */
895
+ function writeShapeColor(layer: Layer3D, raw: unknown): Layer3D {
896
+ if (layer.kind !== "mesh") return layer;
897
+ if (typeof raw !== "string" || !OPAQUE_HEX.test(raw)) return layer;
898
+ return { ...layer, mesh: { ...layer.mesh, color: raw.toLowerCase() } };
899
+ }
900
+
901
+ /** `#rrggbb`: a mesh colour's and a legacy material colour param's one
902
+ * spelling (validate refuses every other). */
903
+ const OPAQUE_HEX = /^#[0-9a-fA-F]{6}$/;
904
+
905
+ /**
906
+ * One generator param, as the one contract row.
907
+ *
908
+ * BOTH HALVES ARE WIRED, and the drag half is the one that was missing
909
+ * everywhere: `ColorPicker` routes its SV square, its hue bar and its alpha bar
910
+ * entirely through `scrub.move` and never calls `commit` while the pointer is
911
+ * down, so a row holding the commit half alone has a control whose thumb moves
912
+ * and whose document never changes.
913
+ *
914
+ * A stored param carries the row's RESET, which deletes it: an absent key is
915
+ * "the contract's own default", and with no reset a param once touched could
916
+ * never follow a later manifest edit again. A colour is OPAQUE: a legacy param
917
+ * holds `#rrggbb` only (validate), so the picker offers no alpha it would
918
+ * refuse.
919
+ */
920
+ function MaterialParamRow(props: { spec: ControlSpec; value: MeshMaterialParam | undefined }) {
921
+ const { spec } = props;
922
+ const write = useSelectionWrite();
923
+ // The builder re-reads its subject from the layer it is HANDED, which every
924
+ // capture re-derives from the gesture's base — closing over a value read at
925
+ // render is what makes a control run away from the cursor.
926
+ const scrub = useSelectionScrub((layer, raw) => writeParam(layer, spec.key, raw));
927
+ // An unset param shows (and edits from) the spec's default, and stores
928
+ // nothing until it is touched: an absent key means "the contract's own
929
+ // default", so baking one in would freeze it against a later manifest edit.
930
+ const value = props.value ?? ("default" in spec ? spec.default : undefined);
931
+ return (
932
+ <PropRow
933
+ spec={spec}
934
+ value={value}
935
+ commit={(raw) => write((layer) => writeParam(layer, spec.key, raw))}
936
+ scrub={scrub}
937
+ {...(spec.kind === "color" ? { opaqueColor: true } : {})}
938
+ {...(props.value === undefined
939
+ ? {}
940
+ : {
941
+ reset: {
942
+ onReset: () => write((layer) => withoutParam(layer, spec.key)),
943
+ title: "Back to the material's own default",
944
+ },
945
+ })}
946
+ />
947
+ );
948
+ }
949
+
950
+ /** PURE, and over the layer it is handed — the shape both write paths need. A
951
+ * value the document's own vocabulary cannot hold leaves the layer untouched
952
+ * (by reference, so no history entry and no save), because the alternative is
953
+ * a document the next save refuses. */
954
+ function writeParam(layer: Layer3D, key: string, raw: unknown): Layer3D {
955
+ const value = asParam(raw);
956
+ if (value === null || layer.kind !== "mesh") return layer;
957
+ const material = layer.mesh.material;
958
+ if (material === undefined || isGraphMaterial(material)) return layer;
959
+ return {
960
+ ...layer,
961
+ mesh: {
962
+ ...layer.mesh,
963
+ material: { ...material, params: { ...material.params, [key]: value } },
964
+ },
965
+ };
966
+ }
967
+
968
+ /** The param DELETED, so it follows the contract's default again. Its `params`
969
+ * go too once empty: a material with no changed params is `{ generator }`. */
970
+ function withoutParam(layer: Layer3D, key: string): Layer3D {
971
+ if (layer.kind !== "mesh") return layer;
972
+ const material = layer.mesh.material;
973
+ if (material === undefined || isGraphMaterial(material)) return layer;
974
+ const stored = material.params;
975
+ if (stored === undefined || stored[key] === undefined) return layer;
976
+ const { [key]: _drop, ...params } = stored;
977
+ const { params: _all, ...bare } = material;
978
+ return {
979
+ ...layer,
980
+ mesh: { ...layer.mesh, material: Object.keys(params).length === 0 ? bare : { ...bare, params } },
981
+ };
982
+ }
983
+
984
+ /** `MeshMaterialParam`, which is exactly the widget vocabulary `effectContract`
985
+ * can produce (number / boolean / colour / vec2 / vec3), and exactly what
986
+ * validate accepts: a colour is `#rrggbb`, and any other string is refused. */
987
+ function asParam(raw: unknown): MeshMaterialParam | null {
988
+ if (typeof raw === "number") return Number.isFinite(raw) ? raw : null;
989
+ if (typeof raw === "boolean") return raw;
990
+ if (typeof raw === "string") return OPAQUE_HEX.test(raw) ? raw : null;
991
+ if (
992
+ Array.isArray(raw) &&
993
+ (raw.length === 2 || raw.length === 3) &&
994
+ raw.every((n) => typeof n === "number" && Number.isFinite(n))
995
+ ) {
996
+ const v = raw as number[];
997
+ return v.length === 2 ? [v[0]!, v[1]!] : [v[0]!, v[1]!, v[2]!];
998
+ }
999
+ return null;
1000
+ }