@pascal-app/editor 1.0.6 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (382) hide show
  1. package/package.json +18 -15
  2. package/src/components/editor/action-menu-button.tsx +4 -1
  3. package/src/components/editor/alignment-3d-guide-layer.tsx +102 -11
  4. package/src/components/editor/bake-thumbnail.tsx +3 -2
  5. package/src/components/editor/custom-camera-controls.tsx +110 -42
  6. package/src/components/editor/duplicate-node.ts +2 -1
  7. package/src/components/editor/elevation-3d-guide-layer.tsx +1 -1
  8. package/src/components/editor/first-person-controls.tsx +77 -68
  9. package/src/components/editor/fit-scene-framing.test.ts +318 -0
  10. package/src/components/editor/fit-scene-framing.ts +142 -0
  11. package/src/components/editor/floating-action-menu.tsx +31 -11
  12. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  13. package/src/components/editor/floor-openings-overlay.tsx +1 -1
  14. package/src/components/editor/floor-region-controls.tsx +2 -2
  15. package/src/components/editor/floorplan-panel.test.ts +220 -0
  16. package/src/components/editor/floorplan-panel.tsx +458 -140
  17. package/src/components/editor/floorplan-wall-typing-keys.test.tsx +152 -0
  18. package/src/components/editor/floorplan-wall-typing-keys.ts +79 -0
  19. package/src/components/editor/footprint-height-handle.tsx +2 -2
  20. package/src/components/editor/grid.tsx +24 -15
  21. package/src/components/editor/group-actions.ts +19 -1
  22. package/src/components/editor/group-floating-action-menu.tsx +1 -1
  23. package/src/components/editor/group-move-3d.ts +27 -2
  24. package/src/components/editor/group-selection-box-3d.tsx +4 -1
  25. package/src/components/editor/group-transform-shared.ts +23 -1
  26. package/src/components/editor/index.tsx +150 -20
  27. package/src/components/editor/inspector-extension-actions.tsx +36 -0
  28. package/src/components/editor/localized-html.tsx +22 -0
  29. package/src/components/editor/measurement-pill.tsx +1 -1
  30. package/src/components/editor/node-action-menu.tsx +46 -12
  31. package/src/components/editor/node-arrow-handles.tsx +2 -3
  32. package/src/components/editor/open-wall-ends-3d-layer.tsx +2 -3
  33. package/src/components/editor/open-wall-ends-hint.tsx +1 -1
  34. package/src/components/editor/opening-guides-3d-layer.tsx +1 -1
  35. package/src/components/editor/quick-measurement-card.tsx +1 -1
  36. package/src/components/editor/registry-action-contributions.test.tsx +67 -6
  37. package/src/components/editor/registry-action-contributions.tsx +115 -15
  38. package/src/components/editor/riser-diagram-panel.tsx +59 -9
  39. package/src/components/editor/room-controls.tsx +4 -3
  40. package/src/components/editor/room-handles-2d.tsx +158 -7
  41. package/src/components/editor/room-handles.tsx +244 -54
  42. package/src/components/editor/room-highlight.tsx +2 -0
  43. package/src/components/editor/room-labels.tsx +33 -17
  44. package/src/components/editor/selection-manager.tsx +15 -102
  45. package/src/components/editor/separator-end-grip.tsx +74 -0
  46. package/src/components/editor/site-edge-labels.tsx +7 -3
  47. package/src/components/editor/thumbnail-generator.test.ts +15 -6
  48. package/src/components/editor/thumbnail-generator.tsx +26 -1
  49. package/src/components/editor/use-floorplan-background-placement.ts +55 -52
  50. package/src/components/editor/views/view-bar.tsx +111 -26
  51. package/src/components/editor/wall-measurement-label.tsx +31 -15
  52. package/src/components/editor/wall-move-side-handles.tsx +15 -7
  53. package/src/components/editor/wall-pull-grip.tsx +193 -0
  54. package/src/components/editor/wall-region-handles.tsx +2 -2
  55. package/src/components/editor/wall-snap-beacon-layer.tsx +134 -33
  56. package/src/components/editor-2d/floorplan-adjacent-walls-layer.test.ts +76 -0
  57. package/src/components/editor-2d/floorplan-adjacent-walls-layer.tsx +186 -0
  58. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +63 -3
  59. package/src/components/editor-2d/floorplan-group-move.tsx +32 -4
  60. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +6 -0
  61. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +33 -24
  62. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +12 -5
  63. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +2 -1
  64. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +29 -5
  65. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +96 -4
  66. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +52 -12
  67. package/src/components/editor-2d/renderers/floorplan-draft-measurement.tsx +7 -1
  68. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +13 -1
  69. package/src/components/editor-2d/renderers/floorplan-placement-typing-hud.test.tsx +35 -0
  70. package/src/components/editor-2d/renderers/floorplan-placement-typing-hud.tsx +65 -0
  71. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +34 -1
  72. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +211 -39
  73. package/src/components/procedural-items/inspector.tsx +59 -12
  74. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +2 -0
  75. package/src/components/systems/ceiling/ceiling-system.test.ts +141 -0
  76. package/src/components/systems/ceiling/ceiling-system.tsx +53 -18
  77. package/src/components/systems/roof/roof-edit-system.test.tsx +15 -1
  78. package/src/components/systems/roof/roof-edit-system.tsx +30 -19
  79. package/src/components/tools/distribution/distribution-run-tool.test.tsx +371 -3
  80. package/src/components/tools/distribution/distribution-run-tool.tsx +200 -58
  81. package/src/components/tools/item/move-tool.tsx +1 -6
  82. package/src/components/tools/item/placement-history.test.tsx +1 -1
  83. package/src/components/tools/item/placement-strategies.test.ts +64 -1
  84. package/src/components/tools/item/placement-strategies.ts +5 -9
  85. package/src/components/tools/item/use-placement-coordinator.tsx +31 -35
  86. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +1 -1
  87. package/src/components/tools/registry/item-surface-move.test.ts +11 -0
  88. package/src/components/tools/registry/move-registry-node-tool.test.ts +42 -1
  89. package/src/components/tools/registry/move-registry-node-tool.tsx +38 -14
  90. package/src/components/tools/select/select-candidates.test.ts +45 -0
  91. package/src/components/tools/select/select-candidates.ts +13 -2
  92. package/src/components/tools/shared/cursor-sphere.tsx +10 -3
  93. package/src/components/tools/shared/draft-measurement-label.tsx +1 -1
  94. package/src/components/tools/shared/placement-box.tsx +2 -6
  95. package/src/components/tools/shared/placement-coordinate-input.tsx +94 -0
  96. package/src/components/tools/shared/placement-dimension-guides.tsx +1 -1
  97. package/src/components/tools/shared/segment-angle.ts +0 -4
  98. package/src/components/tools/site/terrain-brush-cursor.tsx +1 -1
  99. package/src/components/tools/tool-manager.tsx +1 -24
  100. package/src/components/tools/wall/wall-drafting.test.ts +299 -23
  101. package/src/components/tools/wall/wall-drafting.ts +132 -24
  102. package/src/components/tools/wall/wall-pull-snap.test.ts +274 -0
  103. package/src/components/tools/wall/wall-pull-snap.ts +563 -0
  104. package/src/components/tools/wall/wall-snap-geometry.test.ts +113 -1
  105. package/src/components/tools/wall/wall-snap-geometry.ts +137 -0
  106. package/src/components/tools/wall/wall-typed-entry.test.ts +99 -0
  107. package/src/components/tools/wall/wall-typed-entry.ts +70 -0
  108. package/src/components/tools/zone/zone-tool.tsx +3 -3
  109. package/src/components/ui/action-menu/action-button.tsx +2 -0
  110. package/src/components/ui/action-menu/camera-actions.tsx +8 -7
  111. package/src/components/ui/action-menu/control-modes.tsx +9 -5
  112. package/src/components/ui/action-menu/follow-pascal-pill.tsx +3 -1
  113. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +7 -1
  114. package/src/components/ui/action-menu/furnish-tools.tsx +7 -6
  115. package/src/components/ui/action-menu/index.tsx +1 -0
  116. package/src/components/ui/action-menu/measurement-control.tsx +35 -29
  117. package/src/components/ui/action-menu/point-ask-button.tsx +4 -3
  118. package/src/components/ui/action-menu/structure-tools.tsx +25 -23
  119. package/src/components/ui/action-menu/view-toggles.test.ts +18 -0
  120. package/src/components/ui/action-menu/view-toggles.tsx +256 -51
  121. package/src/components/ui/build-panel/build-panel.tsx +163 -25
  122. package/src/components/ui/command-palette/editor-commands.tsx +38 -11
  123. package/src/components/ui/controls/finish-swatch.tsx +45 -19
  124. package/src/components/ui/controls/material-picker.tsx +24 -17
  125. package/src/components/ui/controls/metric-control.test.ts +52 -0
  126. package/src/components/ui/controls/metric-control.tsx +62 -24
  127. package/src/components/ui/controls/segmented-control.tsx +5 -2
  128. package/src/components/ui/controls/slider-control.test.tsx +23 -0
  129. package/src/components/ui/controls/slider-control.tsx +47 -19
  130. package/src/components/ui/controls/tool-options-panel.tsx +74 -44
  131. package/src/components/ui/floating-level-selector.tsx +100 -48
  132. package/src/components/ui/helpers/building-helper.tsx +5 -3
  133. package/src/components/ui/helpers/contextual-helper-panel.tsx +97 -37
  134. package/src/components/ui/helpers/helper-manager.test.tsx +106 -3
  135. package/src/components/ui/helpers/helper-manager.tsx +136 -57
  136. package/src/components/ui/helpers/item-helper.tsx +6 -4
  137. package/src/components/ui/helpers/registered-tool-helper.tsx +3 -1
  138. package/src/components/ui/level-duplicate-dialog.tsx +32 -23
  139. package/src/components/ui/level-wall-copy-prompt.tsx +124 -0
  140. package/src/components/ui/panels/collections/collections-popover.tsx +11 -11
  141. package/src/components/ui/panels/deck-boards-fields.tsx +136 -0
  142. package/src/components/ui/panels/floor-foundation-panel.tsx +114 -39
  143. package/src/components/ui/panels/floor-opening-panel.tsx +74 -26
  144. package/src/components/ui/panels/inspector-extensions.tsx +66 -0
  145. package/src/components/ui/panels/mobile-panel-sheet.tsx +3 -1
  146. package/src/components/ui/panels/mobile-selection-bar.tsx +34 -28
  147. package/src/components/ui/panels/multi-field-value.test.ts +27 -0
  148. package/src/components/ui/panels/multi-field-value.ts +20 -1
  149. package/src/components/ui/panels/multi-height-mode.tsx +11 -10
  150. package/src/components/ui/panels/multi-parametric-inspector.tsx +122 -34
  151. package/src/components/ui/panels/multi-selection-panel.tsx +76 -36
  152. package/src/components/ui/panels/node-display.ts +35 -24
  153. package/src/components/ui/panels/panel-manager.tsx +9 -5
  154. package/src/components/ui/panels/panel-wrapper.tsx +63 -127
  155. package/src/components/ui/panels/parametric-field-control.tsx +13 -10
  156. package/src/components/ui/panels/parametric-inspector.tsx +18 -12
  157. package/src/components/ui/panels/reference-panel.tsx +51 -35
  158. package/src/components/ui/panels/room-construction-rows.tsx +465 -66
  159. package/src/components/ui/panels/room-name-options.test.ts +36 -3
  160. package/src/components/ui/panels/room-name-options.ts +110 -11
  161. package/src/components/ui/panels/room-openings-field.tsx +22 -13
  162. package/src/components/ui/panels/room-panel.tsx +207 -100
  163. package/src/components/ui/panels/shape-choice.tsx +7 -5
  164. package/src/components/ui/panels/wall-region-list.tsx +7 -7
  165. package/src/components/ui/primitives/shortcut-token.tsx +10 -6
  166. package/src/components/ui/right-stack.test.ts +13 -6
  167. package/src/components/ui/right-stack.tsx +21 -10
  168. package/src/components/ui/sidebar/icon-rail.tsx +11 -9
  169. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +10 -10
  170. package/src/components/ui/sidebar/panels/items-panel/index.tsx +10 -9
  171. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +3 -0
  172. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +102 -32
  173. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +1 -5
  174. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +4 -3
  175. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -3
  176. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +4 -2
  177. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +7 -5
  178. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +4 -2
  179. package/src/components/ui/sidebar/panels/site-panel/default-names.ts +75 -0
  180. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +4 -2
  181. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +4 -2
  182. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +4 -2
  183. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +4 -2
  184. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +4 -2
  185. package/src/components/ui/sidebar/panels/site-panel/index.tsx +118 -72
  186. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +36 -3
  187. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +4 -2
  188. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +7 -4
  189. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +8 -2
  190. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +8 -5
  191. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +4 -2
  192. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +9 -4
  193. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +4 -2
  194. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +4 -2
  195. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +8 -7
  196. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +7 -6
  197. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +16 -1
  198. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +15 -8
  199. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +4 -2
  200. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +4 -2
  201. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +3 -1
  202. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +19 -9
  203. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +25 -14
  204. package/src/components/ui/sidebar/tab-bar.tsx +4 -1
  205. package/src/components/ui/sidebar/use-plugin-panels.tsx +19 -10
  206. package/src/components/ui/snap-target-badge.tsx +7 -6
  207. package/src/components/ui/wall-type/wall-type-editor-dialog.tsx +721 -0
  208. package/src/components/ui/wall-type/wall-type-menu.tsx +167 -0
  209. package/src/components/viewer/floorplan-compass-button.tsx +12 -3
  210. package/src/components/viewer/floorplan-preview.tsx +27 -19
  211. package/src/components/viewer/render-settings-menu.tsx +294 -0
  212. package/src/components/viewer/sky-control.tsx +710 -0
  213. package/src/components/viewer/use-viewer-ui-sounds.test.ts +15 -0
  214. package/src/components/viewer/use-viewer-ui-sounds.ts +46 -0
  215. package/src/components/viewer/viewer-controls-bar.tsx +278 -260
  216. package/src/components/viewer/viewer-hover-label.tsx +12 -4
  217. package/src/components/viewer/viewer-rooms.tsx +62 -5
  218. package/src/components/viewer/viewer-scene-header.tsx +28 -17
  219. package/src/components/viewer/viewer-stage-switcher.tsx +4 -2
  220. package/src/components/viewer-overlay.tsx +8 -0
  221. package/src/components/walkthrough-hud.tsx +75 -2
  222. package/src/hooks/use-keyboard.test.ts +85 -4
  223. package/src/hooks/use-keyboard.ts +65 -7
  224. package/src/hooks/use-selected-room.ts +12 -1
  225. package/src/i18n/floor-footprint-name.ts +30 -0
  226. package/src/i18n/level-name.ts +39 -0
  227. package/src/i18n/locales/en/messages.po +15849 -0
  228. package/src/i18n/locales/en/messages.ts +1 -0
  229. package/src/i18n/locales/en-XA/messages.ts +1 -0
  230. package/src/i18n/locales/es/messages.po +39331 -0
  231. package/src/i18n/locales/es/messages.ts +1 -0
  232. package/src/i18n/locales/fr/messages.po +39331 -0
  233. package/src/i18n/locales/fr/messages.ts +1 -0
  234. package/src/i18n/locales/ja/messages.po +39331 -0
  235. package/src/i18n/locales/ja/messages.ts +1 -0
  236. package/src/i18n/locales/zh-CN/messages.po +39331 -0
  237. package/src/i18n/locales/zh-CN/messages.ts +1 -0
  238. package/src/i18n/locales.test.ts +63 -0
  239. package/src/i18n/locales.ts +32 -0
  240. package/src/i18n/message-slot.tsx +25 -0
  241. package/src/i18n/node-messages.ts +169 -0
  242. package/src/i18n/pseudo-locale.ts +17 -0
  243. package/src/i18n/published-entry.test.ts +17 -0
  244. package/src/i18n/scene-theme-name.ts +16 -0
  245. package/src/index.tsx +224 -45
  246. package/src/lib/add-storey.test.ts +3 -3
  247. package/src/lib/contextual-help.test.ts +20 -1
  248. package/src/lib/contextual-help.ts +101 -19
  249. package/src/lib/continuation.ts +38 -11
  250. package/src/lib/elevation-guides.ts +21 -6
  251. package/src/lib/elevator-support.ts +1 -72
  252. package/src/lib/floor-footprints.test.ts +3 -2
  253. package/src/lib/floor-footprints.ts +1 -9
  254. package/src/lib/floor-foundation-support.test.ts +15 -6
  255. package/src/lib/floor-opening-draft.ts +1 -1
  256. package/src/lib/floor-region-session.ts +1 -1
  257. package/src/lib/floorplan/dimension-drive.test.ts +36 -2
  258. package/src/lib/floorplan/dimension-drive.ts +68 -3
  259. package/src/lib/floorplan/floorplan-export.test.ts +16 -5
  260. package/src/lib/floorplan/floorplan-export.tsx +13 -16
  261. package/src/lib/floorplan/floorplan-extension.test.ts +11 -12
  262. package/src/lib/floorplan/floorplan-extension.ts +37 -18
  263. package/src/lib/floorplan/floorplan-mode.test.ts +13 -6
  264. package/src/lib/floorplan/floorplan-mode.ts +14 -12
  265. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +1 -1
  266. package/src/lib/floorplan/floorplan-readonly.ts +3 -3
  267. package/src/lib/floorplan/open-wall-ends.ts +2 -1
  268. package/src/lib/floorplan/schedules.ts +10 -6
  269. package/src/lib/floorplan/site-plan/site-annotations.test.ts +46 -1
  270. package/src/lib/floorplan/site-plan/site-annotations.ts +3 -0
  271. package/src/lib/floorplan/site-plan/site-parts.ts +6 -3
  272. package/src/lib/geometry-script/author.test.ts +114 -0
  273. package/src/lib/geometry-script/author.ts +55 -23
  274. package/src/lib/geometry-script/client.ts +3 -0
  275. package/src/lib/geometry-script/finish-scripts.ts +7 -7
  276. package/src/lib/gesture-lifecycle.ts +3 -1
  277. package/src/lib/glb-export.test.ts +327 -0
  278. package/src/lib/glb-export.ts +70 -2
  279. package/src/lib/grid-frame.test.ts +56 -3
  280. package/src/lib/grid-frame.ts +28 -1
  281. package/src/lib/history.test.ts +2 -1
  282. package/src/lib/hud-title.ts +48 -24
  283. package/src/lib/ifc-export.test.ts +41 -1
  284. package/src/lib/inspector-card-mode.test.ts +3 -31
  285. package/src/lib/inspector-card-mode.ts +5 -10
  286. package/src/lib/inspector-expanded.test.ts +13 -0
  287. package/src/lib/inspector-expanded.ts +13 -1
  288. package/src/lib/interaction/scope.ts +1 -0
  289. package/src/lib/keyboard-pan.test.ts +7 -0
  290. package/src/lib/keyboard-pan.ts +9 -2
  291. package/src/lib/layer-thickness.test.ts +22 -0
  292. package/src/lib/layer-thickness.ts +79 -0
  293. package/src/lib/level-selection.test.ts +18 -2
  294. package/src/lib/level-selection.ts +31 -8
  295. package/src/lib/linear-display.ts +1 -1
  296. package/src/lib/live-gesture-round-trip.test.ts +1 -1
  297. package/src/lib/lot/drop-in.test.ts +59 -1
  298. package/src/lib/lot/drop-in.ts +42 -50
  299. package/src/lib/material-swatch-style.ts +24 -0
  300. package/src/lib/measurement-label.test.ts +1 -1
  301. package/src/lib/measurement-parser.ts +7 -3
  302. package/src/lib/measurements.test.ts +24 -6
  303. package/src/lib/measurements.ts +1 -203
  304. package/src/lib/mezzanine-selection.test.ts +3 -1
  305. package/src/lib/node-action-movement.test.tsx +115 -0
  306. package/src/lib/node-action-movement.ts +2 -0
  307. package/src/lib/orbit-near-plane.test.ts +12 -0
  308. package/src/lib/orbit-near-plane.ts +13 -0
  309. package/src/lib/paint-regions.test.ts +8 -5
  310. package/src/lib/paint-scope.test.ts +53 -0
  311. package/src/lib/paint-scope.ts +18 -4
  312. package/src/lib/panel-rows.ts +17 -8
  313. package/src/lib/placement-metadata.ts +4 -0
  314. package/src/lib/point-ask/describe-target.ts +6 -8
  315. package/src/lib/point-ask/region.ts +1 -1
  316. package/src/lib/print-shell-compiler.ts +12 -1
  317. package/src/lib/print-wall-solids.test.ts +11 -0
  318. package/src/lib/print-wall-solids.ts +9 -0
  319. package/src/lib/reference-scale.ts +83 -0
  320. package/src/lib/room-assembly-overlay.ts +1 -1
  321. package/src/lib/room-built-on.test.ts +28 -17
  322. package/src/lib/room-built-on.ts +21 -17
  323. package/src/lib/room-construction-commands.ts +3 -2
  324. package/src/lib/room-construction.test.ts +28 -1
  325. package/src/lib/room-construction.ts +14 -0
  326. package/src/lib/room-controls-phase6.test.ts +141 -1
  327. package/src/lib/room-handle-drag.ts +554 -58
  328. package/src/lib/room-name-catalog.test.ts +8 -0
  329. package/src/lib/room-name-catalog.ts +8 -1
  330. package/src/lib/room-name-messages.test.ts +132 -0
  331. package/src/lib/room-name-messages.ts +147 -0
  332. package/src/lib/room-name-storage.test.ts +334 -0
  333. package/src/lib/room-push-dimensions.test.ts +38 -1
  334. package/src/lib/room-push-dimensions.ts +226 -2
  335. package/src/lib/room-selection.test.ts +23 -6
  336. package/src/lib/scene-bounds.test.ts +144 -0
  337. package/src/lib/scene-bounds.ts +79 -15
  338. package/src/lib/scene-clipboard.test.ts +70 -49
  339. package/src/lib/scene-clipboard.ts +11 -5
  340. package/src/lib/selection-cycle.ts +63 -0
  341. package/src/lib/separator-edit.test.ts +431 -0
  342. package/src/lib/separator-edit.ts +179 -0
  343. package/src/lib/separator-lines.ts +38 -0
  344. package/src/lib/sfx-bus.ts +27 -1
  345. package/src/lib/sfx-player.ts +16 -0
  346. package/src/lib/snapping-mode.test.ts +23 -1
  347. package/src/lib/tool-cursor-icon.ts +5 -1
  348. package/src/lib/tool-shortcuts.ts +130 -0
  349. package/src/lib/viewer-selection.test.ts +28 -24
  350. package/src/lib/viewer-selection.ts +21 -6
  351. package/src/lib/wall-draw-variant.test.ts +7 -6
  352. package/src/lib/wall-draw-variant.ts +107 -6
  353. package/src/lib/wall-nudge.test.ts +44 -0
  354. package/src/lib/wall-nudge.ts +131 -0
  355. package/src/lib/wall-parallel-snap.test.ts +83 -0
  356. package/src/lib/wall-parallel-snap.ts +176 -0
  357. package/src/lib/wall-placement-pointer.test.ts +23 -0
  358. package/src/lib/wall-placement-pointer.ts +18 -0
  359. package/src/lib/wall-pull.test.ts +245 -0
  360. package/src/lib/wall-pull.ts +209 -0
  361. package/src/lib/wall-push-line-up.test.ts +132 -0
  362. package/src/lib/wall-region-list.ts +17 -6
  363. package/src/lib/wall-run-snap.test.ts +188 -0
  364. package/src/lib/wall-run-snap.ts +83 -0
  365. package/src/lib/wall-type.test.ts +125 -0
  366. package/src/lib/wall-type.ts +119 -0
  367. package/src/lib/window-interaction.test.ts +44 -0
  368. package/src/lib/world-grid-snap.test.ts +139 -2
  369. package/src/lib/world-grid-snap.ts +102 -105
  370. package/src/store/use-alignment-guides.ts +27 -1
  371. package/src/store/use-editor.tsx +53 -0
  372. package/src/store/use-floorplan-annotation-visibility.test.ts +64 -0
  373. package/src/store/use-floorplan-annotation-visibility.ts +47 -22
  374. package/src/store/use-placement-typing.test.ts +165 -0
  375. package/src/store/use-placement-typing.ts +186 -0
  376. package/src/store/use-wall-snap-indicator.ts +15 -2
  377. package/src/components/tools/elevator/elevator-defaults.ts +0 -8
  378. package/src/components/tools/elevator/elevator-tool.tsx +0 -372
  379. package/src/components/tools/elevator/move-elevator-tool.tsx +0 -262
  380. package/src/lib/door-interaction.ts +0 -102
  381. package/src/lib/measurement-label.ts +0 -68
  382. package/src/lib/window-interaction.ts +0 -100
@@ -1,5 +1,6 @@
1
1
  'use client'
2
2
 
3
+ import { usePascalI18n } from '@pascal-app/core'
3
4
  import type { AnyNode } from '@pascal-app/core'
4
5
  import { Copy, Move, SlidersHorizontal, Trash2 } from 'lucide-react'
5
6
  import type { MouseEventHandler } from 'react'
@@ -11,8 +12,8 @@ interface MobileSelectionBarProps {
11
12
  node: AnyNode | null
12
13
  label?: string
13
14
  icon?: string
14
- onMove: () => void
15
- onDuplicate: () => void
15
+ onMove?: () => void
16
+ onDuplicate?: () => void
16
17
  onDelete: () => void
17
18
  onEdit: () => void
18
19
  }
@@ -29,7 +30,8 @@ export function MobileSelectionBar({
29
30
  onDelete,
30
31
  onEdit,
31
32
  }: MobileSelectionBarProps) {
32
- const display = getNodeDisplay(node)
33
+ const i18n = usePascalI18n()
34
+ const display = getNodeDisplay(node, i18n)
33
35
  const resolvedLabel = label ?? display.label
34
36
  const resolvedIcon = icon ?? display.icon
35
37
 
@@ -38,7 +40,7 @@ export function MobileSelectionBar({
38
40
  return (
39
41
  <div className="pointer-events-auto absolute right-3 bottom-6 left-3 z-50 flex h-12 items-stretch gap-1 rounded-2xl border border-border/50 bg-background/95 px-2 shadow-2xl backdrop-blur-xl">
40
42
  <button
41
- aria-label={`Edit ${resolvedLabel}`}
43
+ aria-label={i18n._({ id: 'editor.panels.mobileSelectionBar.editResolvedlabel', message: 'Edit {resolvedLabel}', values: { resolvedLabel } })}
42
44
  className={cn(
43
45
  'flex min-w-0 flex-1 items-center gap-2 rounded-lg px-2 text-left transition-colors hover:bg-black/5 dark:hover:bg-white/8',
44
46
  )}
@@ -56,30 +58,34 @@ export function MobileSelectionBar({
56
58
  </button>
57
59
 
58
60
  <div className="flex items-center gap-0.5 border-border/40 border-l pl-1">
61
+ {onMove && (
62
+ <button
63
+ aria-label={i18n._({ id: 'editor.panels.mobileSelectionBar.move', message: 'Move' })}
64
+ className={ACTION_BTN}
65
+ onClick={(e) => {
66
+ stop(e)
67
+ onMove()
68
+ }}
69
+ type="button"
70
+ >
71
+ <Move className="h-4 w-4" />
72
+ </button>
73
+ )}
74
+ {onDuplicate && (
75
+ <button
76
+ aria-label={i18n._({ id: 'editor.panels.mobileSelectionBar.duplicate', message: 'Duplicate' })}
77
+ className={ACTION_BTN}
78
+ onClick={(e) => {
79
+ stop(e)
80
+ onDuplicate()
81
+ }}
82
+ type="button"
83
+ >
84
+ <Copy className="h-4 w-4" />
85
+ </button>
86
+ )}
59
87
  <button
60
- aria-label="Move"
61
- className={ACTION_BTN}
62
- onClick={(e) => {
63
- stop(e)
64
- onMove()
65
- }}
66
- type="button"
67
- >
68
- <Move className="h-4 w-4" />
69
- </button>
70
- <button
71
- aria-label="Duplicate"
72
- className={ACTION_BTN}
73
- onClick={(e) => {
74
- stop(e)
75
- onDuplicate()
76
- }}
77
- type="button"
78
- >
79
- <Copy className="h-4 w-4" />
80
- </button>
81
- <button
82
- aria-label="Delete"
88
+ aria-label={i18n._({ id: 'editor.panels.mobileSelectionBar.delete', message: 'Delete' })}
83
89
  className={cn(
84
90
  ACTION_BTN,
85
91
  'hover:bg-red-500/15 hover:text-red-600 dark:hover:bg-red-500/15 dark:hover:text-red-400',
@@ -93,7 +99,7 @@ export function MobileSelectionBar({
93
99
  <Trash2 className="h-4 w-4" />
94
100
  </button>
95
101
  <button
96
- aria-label="Edit properties"
102
+ aria-label={i18n._({ id: 'editor.panels.mobileSelectionBar.editProperties', message: 'Edit properties' })}
97
103
  className={ACTION_BTN}
98
104
  onClick={(e) => {
99
105
  stop(e)
@@ -15,6 +15,7 @@ import {
15
15
  import {
16
16
  buildMultiNodePatches,
17
17
  commitMultiNodeFields,
18
+ fieldTargetIds,
18
19
  fieldVisibleForAll,
19
20
  reduceFieldValue,
20
21
  reduceHeightBoundMode,
@@ -57,6 +58,32 @@ describe('reduceFieldValue', () => {
57
58
  expect(reduceFieldValue(['a', 'b'], 'thickness', nodes)).toEqual({ kind: 'mixed' })
58
59
  })
59
60
 
61
+ test('an omitted value reads as the field default, not as zero', () => {
62
+ const nodes = {
63
+ a: makeNode('a', 'wall'),
64
+ b: makeNode('b', 'wall', { thickness: 0.1 }),
65
+ c: makeNode('c', 'wall', { thickness: 0.2 }),
66
+ }
67
+ expect(reduceFieldValue(['a', 'b'], 'thickness', nodes, 0.1)).toEqual({
68
+ kind: 'same',
69
+ value: 0.1,
70
+ })
71
+ expect(reduceFieldValue(['a', 'c'], 'thickness', nodes, 0.1)).toEqual({ kind: 'mixed' })
72
+ })
73
+
74
+ test('a field that names the nodes it applies to edits only those', () => {
75
+ const nodes = {
76
+ a: makeNode('a', 'wall', { thickness: 0.2 }),
77
+ b: makeNode('b', 'wall', { thickness: 0.18, assembly: { layers: [] } }),
78
+ c: makeNode('c', 'wall', { thickness: 0.2 }),
79
+ }
80
+ const plain = (node: AnyNode) => !(node as { assembly?: unknown }).assembly
81
+ const targets = fieldTargetIds(['a', 'b', 'c'], plain, nodes)
82
+ expect(targets).toEqual(['a', 'c'])
83
+ expect(reduceFieldValue(targets, 'thickness', nodes)).toEqual({ kind: 'same', value: 0.2 })
84
+ expect(fieldTargetIds(['a', 'b'], undefined, nodes)).toEqual(['a', 'b'])
85
+ })
86
+
60
87
  test('vec3 compares elementwise', () => {
61
88
  const nodes = {
62
89
  a: makeNode('a', 'item', { position: [1, 0, 2] }),
@@ -20,17 +20,23 @@ function fieldValuesEqual(a: unknown, b: unknown): boolean {
20
20
  return false
21
21
  }
22
22
 
23
+ /**
24
+ * The value every node holds for `key`, or mixed. `fallback` is what a node
25
+ * that omits the key shows (the field's default), so an absent value and an
26
+ * explicit default read as the same.
27
+ */
23
28
  export function reduceFieldValue(
24
29
  nodeIds: readonly string[],
25
30
  key: string,
26
31
  nodes: Readonly<Record<string, AnyNode | undefined>>,
32
+ fallback?: unknown,
27
33
  ): ReducedFieldValue {
28
34
  let seen = false
29
35
  let shared: unknown
30
36
  for (const id of nodeIds) {
31
37
  const node = nodes[id]
32
38
  if (!node) continue
33
- const value = (node as Record<string, unknown>)[key]
39
+ const value = (node as Record<string, unknown>)[key] ?? fallback
34
40
  if (!seen) {
35
41
  seen = true
36
42
  shared = value
@@ -78,6 +84,19 @@ export function fieldVisibleForAll(
78
84
  return true
79
85
  }
80
86
 
87
+ /** The selected nodes a field edits: every one, unless it names those it `appliesTo`. */
88
+ export function fieldTargetIds<T extends string>(
89
+ nodeIds: readonly T[],
90
+ appliesTo: ((node: AnyNode) => boolean) | undefined,
91
+ nodes: Readonly<Record<string, AnyNode | undefined>>,
92
+ ): T[] {
93
+ if (!appliesTo) return [...nodeIds]
94
+ return nodeIds.filter((id) => {
95
+ const node = nodes[id]
96
+ return node ? appliesTo(node) : false
97
+ })
98
+ }
99
+
81
100
  export function firstNumericFieldValue(
82
101
  nodeIds: readonly string[],
83
102
  key: string,
@@ -11,12 +11,12 @@ import {
11
11
  resolveCeilingHeight,
12
12
  terrainSupportLift,
13
13
  useScene,
14
- type WallNode,
14
+ type WallNode, usePascalI18n,
15
15
  } from '@pascal-app/core'
16
16
  import { useViewer } from '@pascal-app/viewer'
17
17
  import { useCallback } from 'react'
18
18
  import { useShallow } from 'zustand/react/shallow'
19
- import { formatLinearMeasurement } from '../../../lib/measurements'
19
+ import { formatLinearMeasurement } from '@pascal-app/core'
20
20
  import { SegmentedControl } from '../controls/segmented-control'
21
21
  import { SliderControl } from '../controls/slider-control'
22
22
  import {
@@ -42,7 +42,7 @@ function wallFollowsLevelPatch(wall: WallNode, nodes: Record<string, AnyNode>):
42
42
  }
43
43
 
44
44
  function effectiveHeight(node: AnyNode, nodes: Record<string, AnyNode>): number {
45
- if (node.type === 'wall') return getWallEffectiveHeightForNodes(node, nodes)
45
+ if (node.type === 'wall') return getWallEffectiveHeightForNodes(node, nodes, 0)
46
46
  if (node.type === 'ceiling') return resolveCeilingHeight(node, nodes)
47
47
  const height = (node as { height?: number }).height
48
48
  return typeof height === 'number' ? height : 0
@@ -72,7 +72,7 @@ export function applyMultiHeightMode(
72
72
  if (isCustom) return {}
73
73
  if (node.type === 'ceiling') return { height: ceilingCustomHeight(node, nodes) }
74
74
  if (node.type === 'wall') {
75
- return { height: Math.max(0.1, getWallEffectiveHeightForNodes(node, nodes)) }
75
+ return { height: Math.max(0.1, getWallEffectiveHeightForNodes(node, nodes, 0)) }
76
76
  }
77
77
  return {}
78
78
  }
@@ -99,6 +99,7 @@ export function MultiHeightModeField({
99
99
  max?: number
100
100
  step?: number
101
101
  }) {
102
+ const i18n = usePascalI18n()
102
103
  const unit = useViewer((s) => s.unit)
103
104
  const metricNotation = useViewer((s) => s.metricNotation)
104
105
  const mode = useScene(useShallow((s) => reduceHeightBoundMode(nodeIds, s.nodes)))
@@ -151,29 +152,29 @@ export function MultiHeightModeField({
151
152
  const sliderMixed = storedHeight.kind === 'mixed' || mixedMode
152
153
  const currentLabel = Number.isFinite(liveHeight)
153
154
  ? formatLinearMeasurement(liveHeight, unit, metricNotation)
154
- : 'Mixed'
155
+ : i18n._({ id: 'editor.panels.mixed', message: 'Mixed' })
155
156
 
156
157
  return (
157
158
  <>
158
159
  {nodeType === 'wall' && (
159
160
  <div className="px-1 font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
160
- Top
161
+ {i18n._({ id: 'editor.panels.multiHeightMode.top', message: 'Top' })}
161
162
  </div>
162
163
  )}
163
164
  <SegmentedControl
164
165
  mixed={mixedMode}
165
166
  onChange={applyMode}
166
167
  options={[
167
- { label: 'Follows level', value: 'storey' },
168
- { label: 'Custom height', value: 'custom' },
168
+ { label: i18n._({ id: 'editor.panels.multiHeightMode.followsLevel', message: 'Follows level' }), value: 'storey' },
169
+ { label: i18n._({ id: 'editor.panels.multiHeightMode.customHeight', message: 'Custom height' }), value: 'custom' },
169
170
  ]}
170
171
  value={mode.kind === 'same' ? mode.value : 'storey'}
171
172
  />
172
173
  {isFollows ? (
173
- <div className="px-1 text-[11px] text-muted-foreground">Currently {currentLabel}</div>
174
+ <div className="px-1 text-[11px] text-muted-foreground">{i18n._({ id: 'editor.panels.multiHeightMode.currentlyCurrentlabel', message: 'Currently {currentLabel}', values: { currentLabel } })}</div>
174
175
  ) : isCustom ? (
175
176
  <SliderControl
176
- label="Height"
177
+ label={i18n._({ id: 'editor.panels.multiHeightMode.height', message: 'Height' })}
177
178
  max={max}
178
179
  min={min}
179
180
  mixed={sliderMixed}
@@ -6,20 +6,22 @@ import {
6
6
  nodeRegistry,
7
7
  type ParamField,
8
8
  type ParametricDescriptor,
9
- useScene,
9
+ useScene, usePascalI18n,
10
10
  } from '@pascal-app/core'
11
11
  import { useViewer } from '@pascal-app/viewer'
12
12
  import { useCallback, useMemo } from 'react'
13
13
  import { useShallow } from 'zustand/react/shallow'
14
- import { getNodePanelModel } from '../../../lib/panel-rows'
14
+ import { getNodePanelModel, type MultiPanelControl } from '../../../lib/panel-rows'
15
15
  import { selectionMatchesSessionGroup } from '../../../lib/session-groups'
16
16
  import useSessionGroups from '../../../store/use-session-groups'
17
17
  import { PanelSection } from '../controls/panel-section'
18
18
  import { SegmentedControl } from '../controls/segmented-control'
19
+ import { SelectControl } from '../controls/select-control'
19
20
  import { SliderControl } from '../controls/slider-control'
20
21
  import { resolveUniqueSelectionIds } from './homogeneous-selection'
21
22
  import {
22
23
  commitMultiNodeFields,
24
+ fieldTargetIds,
23
25
  fieldVisibleForAll,
24
26
  firstNumericFieldValue,
25
27
  firstVec3FieldValue,
@@ -32,8 +34,10 @@ import { getTypeDisplay } from './node-display'
32
34
  import { ParametricFieldControl } from './parametric-field-control'
33
35
  import { PanelWrapper } from './panel-wrapper'
34
36
  import { formatSelectionBreakdown } from './selection-breakdown'
37
+ import { inspectorGroupDescriptor } from '../../../i18n/node-messages'
35
38
 
36
39
  export function MultiParametricInspector({ footer }: { footer?: React.ReactNode }) {
40
+ const i18n = usePascalI18n()
37
41
  const selectedIds = useViewer((s) => s.selection.selectedIds)
38
42
  const setSelection = useViewer((s) => s.setSelection)
39
43
  const nodeIds = useScene(
@@ -62,16 +66,14 @@ export function MultiParametricInspector({ footer }: { footer?: React.ReactNode
62
66
 
63
67
  if (nodeIds.length < 2 || !nodeType || !parametrics) return null
64
68
 
65
- const display = getTypeDisplay(nodeType)
69
+ const display = getTypeDisplay(nodeType, i18n)
66
70
  const title = matchedGroup ? `${matchedGroup.label} · ${breakdown}` : breakdown || display.label
67
71
 
68
72
  return (
69
73
  <PanelWrapper footer={footer} icon={display.icon} onClose={handleClose} title={title} width={320}>
70
74
  {matchedGroup && (
71
75
  <div className="border-border/50 border-b px-3 py-2 text-muted-foreground text-xs">
72
- {matchedGroup.label} (session only). Plain click reselects all members. Not saved with the
73
- project.
74
- </div>
76
+ {i18n._({ id: 'editor.panels.multiParametricInspector.labelSessionOnlyPlainClickReselects', message: '{label} (session only). Plain click reselects all members. Not saved with the project.', values: { label: matchedGroup.label } })}</div>
75
77
  )}
76
78
  {parametrics.groups.map((group, gi) => (
77
79
  <MultiGroupFields
@@ -81,13 +83,20 @@ export function MultiParametricInspector({ footer }: { footer?: React.ReactNode
81
83
  nodeIds={nodeIds}
82
84
  nodeType={nodeType}
83
85
  parametrics={parametrics}
84
- title={group.label}
86
+ title={i18n._(inspectorGroupDescriptor(group.label))}
85
87
  >
86
88
  {hasKindControls ? (
87
89
  <MultiKindControls nodeIds={nodeIds} nodeType={nodeType} section={group.label} />
88
90
  ) : null}
89
91
  </MultiGroupFields>
90
92
  ))}
93
+ {hasKindControls ? (
94
+ <MultiKindSections
95
+ groupTitles={parametrics.groups.map((group) => group.label)}
96
+ nodeIds={nodeIds}
97
+ nodeType={nodeType}
98
+ />
99
+ ) : null}
91
100
  <div className="border-border/50 border-t p-3">
92
101
  <MultiSelectionActions />
93
102
  </div>
@@ -95,10 +104,20 @@ export function MultiParametricInspector({ footer }: { footer?: React.ReactNode
95
104
  )
96
105
  }
97
106
 
107
+ function useMultiKindControls(nodeIds: AnyNodeId[], nodeType: AnyNode['type']) {
108
+ const nodes = useScene((s) => s.nodes)
109
+ return useMemo(() => {
110
+ const model = getNodePanelModel(nodeRegistry.get(nodeType))
111
+ if (!model?.multiControls) return []
112
+ const selection = nodeIds.flatMap((id) => (nodes[id] ? [nodes[id]!] : []))
113
+ return model.multiControls({ selection, nodes })
114
+ }, [nodeIds, nodeType, nodes])
115
+ }
116
+
98
117
  /**
99
118
  * The kind's own multi-selection controls for one section (the wall's
100
- * Reference), from its panel model — the same segmented control the single
101
- * panel shows, mixed when the selection disagrees.
119
+ * Reference), from its panel model — the same control the single panel
120
+ * shows, mixed when the selection disagrees.
102
121
  */
103
122
  function MultiKindControls({
104
123
  nodeIds,
@@ -109,32 +128,84 @@ function MultiKindControls({
109
128
  nodeType: AnyNode['type']
110
129
  section: string
111
130
  }) {
112
- const nodes = useScene((s) => s.nodes)
113
- const controls = useMemo(() => {
114
- const model = getNodePanelModel(nodeRegistry.get(nodeType))
115
- if (!model?.multiControls) return []
116
- const selection = nodeIds.flatMap((id) => (nodes[id] ? [nodes[id]!] : []))
117
- return model.multiControls({ selection, nodes }).filter((control) => control.section === section)
118
- }, [nodeIds, nodeType, nodes, section])
131
+ const controls = useMultiKindControls(nodeIds, nodeType).filter(
132
+ (control) => control.section === section,
133
+ )
119
134
  return (
120
135
  <>
121
136
  {controls.map((control) => (
122
- <div className="flex flex-col gap-1" key={control.id}>
123
- <div className="px-1 font-medium text-[10px] text-muted-foreground/80">
124
- {control.label}
125
- </div>
126
- <SegmentedControl
127
- mixed={control.value === null}
128
- onChange={control.onChange}
129
- options={control.options.map((option) => ({ ...option }))}
130
- value={control.value ?? ''}
131
- />
132
- </div>
137
+ <MultiKindControlView control={control} key={control.id} />
138
+ ))}
139
+ </>
140
+ )
141
+ }
142
+
143
+ /** The kind's controls whose section is none of the parametrics groups, each section its own. */
144
+ function MultiKindSections({
145
+ groupTitles,
146
+ nodeIds,
147
+ nodeType,
148
+ }: {
149
+ groupTitles: readonly string[]
150
+ nodeIds: AnyNodeId[]
151
+ nodeType: AnyNode['type']
152
+ }) {
153
+ const controls = useMultiKindControls(nodeIds, nodeType).filter(
154
+ (control) => !groupTitles.includes(control.section),
155
+ )
156
+ const sections = [...new Set(controls.map((control) => control.section))]
157
+ return (
158
+ <>
159
+ {sections.map((section) => (
160
+ <PanelSection key={section} title={section}>
161
+ {controls
162
+ .filter((control) => control.section === section)
163
+ .map((control) => (
164
+ <MultiKindControlView control={control} key={control.id} />
165
+ ))}
166
+ </PanelSection>
133
167
  ))}
134
168
  </>
135
169
  )
136
170
  }
137
171
 
172
+ function MultiKindControlView({ control }: { control: MultiPanelControl }) {
173
+ if (control.kind === 'readout') {
174
+ return (
175
+ <div className="flex items-center justify-between gap-2 px-2 py-1.5">
176
+ <span className="font-medium text-[10px] text-muted-foreground uppercase tracking-wider">
177
+ {control.label}
178
+ </span>
179
+ <span className="truncate text-[11px] text-muted-foreground tabular-nums">
180
+ {control.detail ? `${control.value} · ${control.detail}` : control.value}
181
+ </span>
182
+ </div>
183
+ )
184
+ }
185
+ if (control.kind === 'select') {
186
+ return (
187
+ <SelectControl
188
+ label={control.label}
189
+ mixed={control.value === null}
190
+ onChange={control.onChange}
191
+ options={control.options.map((option) => ({ ...option }))}
192
+ value={control.value ?? ''}
193
+ />
194
+ )
195
+ }
196
+ return (
197
+ <div className="flex flex-col gap-1">
198
+ <div className="px-1 font-medium text-[10px] text-muted-foreground/80">{control.label}</div>
199
+ <SegmentedControl
200
+ mixed={control.value === null}
201
+ onChange={control.onChange}
202
+ options={control.options.map((option) => ({ ...option }))}
203
+ value={control.value ?? ''}
204
+ />
205
+ </div>
206
+ )
207
+ }
208
+
138
209
  function MultiGroupFields({
139
210
  defaultExpanded,
140
211
  title,
@@ -154,9 +225,16 @@ function MultiGroupFields({
154
225
  }) {
155
226
  const genericFields = fields.filter((field) => field.kind !== 'custom')
156
227
  const anyVisible = useScene((s) =>
157
- genericFields.some((field) =>
158
- fieldVisibleForAll(nodeIds, (field as { visibleIf?: (n: AnyNode) => boolean }).visibleIf, s.nodes),
159
- ),
228
+ genericFields.some((field) => {
229
+ const { visibleIf, appliesTo } = field as {
230
+ visibleIf?: (n: AnyNode) => boolean
231
+ appliesTo?: (n: AnyNode) => boolean
232
+ }
233
+ return (
234
+ fieldVisibleForAll(nodeIds, visibleIf, s.nodes) &&
235
+ fieldTargetIds(nodeIds, appliesTo, s.nodes).length > 0
236
+ )
237
+ }),
160
238
  )
161
239
  if ((genericFields.length === 0 || !anyVisible) && !children) return null
162
240
  return (
@@ -195,7 +273,7 @@ function MultiGroupFields({
195
273
 
196
274
  function MultiFieldRenderer({
197
275
  field,
198
- nodeIds,
276
+ nodeIds: selectedIds,
199
277
  parametrics,
200
278
  }: {
201
279
  field: ParamField<AnyNode>
@@ -203,11 +281,21 @@ function MultiFieldRenderer({
203
281
  parametrics: ParametricDescriptor<AnyNode>
204
282
  }) {
205
283
  const key = String(field.key)
284
+ // A field that names the nodes it `appliesTo` reads and edits only those.
285
+ const appliesTo = (field as { appliesTo?: (n: AnyNode) => boolean }).appliesTo
286
+ const nodeIds = useScene(
287
+ useShallow((s) => fieldTargetIds(selectedIds, appliesTo, s.nodes)),
288
+ )
206
289
  const visible = useScene((s) =>
207
290
  fieldVisibleForAll(nodeIds, (field as { visibleIf?: (n: AnyNode) => boolean }).visibleIf, s.nodes),
208
291
  )
209
- const reduced = useScene(useShallow((s) => reduceFieldValue(nodeIds, key, s.nodes)))
210
- const numericOrigin = useScene((s) => firstNumericFieldValue(nodeIds, key, s.nodes))
292
+ const fallback = field.kind === 'number' ? field.default : undefined
293
+ const reduced = useScene(
294
+ useShallow((s) => reduceFieldValue(nodeIds, key, s.nodes, fallback)),
295
+ )
296
+ const numericOrigin = useScene((s) =>
297
+ firstNumericFieldValue(nodeIds, key, s.nodes, fallback ?? 0),
298
+ )
211
299
  const vecOrigin = useScene(useShallow((s) => firstVec3FieldValue(nodeIds, key, s.nodes)))
212
300
 
213
301
  const preview = useCallback(
@@ -223,7 +311,7 @@ function MultiFieldRenderer({
223
311
  [nodeIds, parametrics],
224
312
  )
225
313
 
226
- if (!visible) return null
314
+ if (!visible || nodeIds.length === 0) return null
227
315
 
228
316
  if (field.kind === 'vec3') {
229
317
  return (