@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,5 @@
1
1
  import { beforeEach, describe, expect, test } from 'bun:test'
2
- import { type AnyNodeId, type SiteNode, useScene } from '@pascal-app/core'
2
+ import { type AnyNodeId, generateId, SiteNode, useScene } from '@pascal-app/core'
3
3
  import { dropInLot } from './drop-in'
4
4
  import type { ParcelResolveData } from './lot-patch'
5
5
  import type { ParcelProvider } from './parcel-provider'
@@ -245,6 +245,64 @@ describe('dropInLot onto a site that already has a lot', () => {
245
245
  useScene.setState({ nodes: {}, rootNodeIds: [], dirtyNodes: new Set() } as never)
246
246
  })
247
247
 
248
+ test('a terrain response applies the parcel to the hydrated site and preserves its current settings', async () => {
249
+ let finishTerrain!: () => void
250
+ let startedTerrain!: () => void
251
+ const waiting = new Promise<void>((resolve) => {
252
+ finishTerrain = resolve
253
+ })
254
+ const started = new Promise<void>((resolve) => {
255
+ startedTerrain = resolve
256
+ })
257
+ const read = fakeProvider(null, 'sloped')
258
+ const provider: ParcelProvider = async (endpoint, request) => {
259
+ if (endpoint === 'elevation') {
260
+ startedTerrain()
261
+ await waiting
262
+ }
263
+ return read(endpoint, request)
264
+ }
265
+ const dropping = dropInLot(
266
+ { address: GAINESVILLE },
267
+ {
268
+ dossier: false,
269
+ roads: false,
270
+ provider,
271
+ },
272
+ )
273
+ await started
274
+ expect(useScene.getState().rootNodeIds).toEqual([])
275
+ const hydrated = SiteNode.parse({
276
+ id: generateId('site'),
277
+ type: 'site',
278
+ name: 'My site',
279
+ polygon: { type: 'polygon', points: rect(30, 30) },
280
+ setbacks: { front: 8, side: 3, rear: 6 },
281
+ zone: 'Owner verified zone',
282
+ metadata: { ownerNote: 'Keep the driveway' },
283
+ })
284
+ useScene.setState({ nodes: { [hydrated.id]: hydrated }, rootNodeIds: [hydrated.id] } as never)
285
+ finishTerrain()
286
+
287
+ const result = await dropping
288
+ expect(result.ok).toBe(true)
289
+ expect(result.siteId).toBe(hydrated.id)
290
+ expect(useScene.getState().rootNodeIds).toEqual([hydrated.id])
291
+ const current = site(hydrated.id)
292
+ const points = current.polygon!.points
293
+ expect(Math.max(...points.map((p) => p[0])) - Math.min(...points.map((p) => p[0]))).toBe(25)
294
+ expect(Math.max(...points.map((p) => p[1])) - Math.min(...points.map((p) => p[1]))).toBe(32)
295
+ expect(current.parcel?.apn).toBe('06075-030-062')
296
+ expect(current.address?.city).toBe('Gainesville')
297
+ expect(current.terrain).toBeDefined()
298
+ expect(current.setbacks).toEqual(hydrated.setbacks)
299
+ expect(current.zone).toBe(hydrated.zone)
300
+ expect(current.metadata).toMatchObject({
301
+ ownerNote: 'Keep the driveway',
302
+ terrainSample: result.terrain,
303
+ })
304
+ })
305
+
248
306
  test('the second lot replaces the first lot’s address, APN and county', async () => {
249
307
  const provider = fakeProvider(null)
250
308
  const first = await dropInLot({ address: LAKELAND }, { ...offline, provider })
@@ -9,12 +9,12 @@
9
9
  * 3. `sitePatchFromParcel` — the site node patch: ring, address,
10
10
  * provenance, the street-facing front edge, planning-default setbacks
11
11
  * when the site has none, north up.
12
- * 4. The scene's site node is updated (created at the root when the scene
13
- * has none) and a building that fell outside the new ring is
14
- * re-centred on it — `building.position` only, never the walls.
15
- * 5. `elevation` — USGS ground over the lot into the site's
12
+ * 4. `elevation` — USGS ground over the lot into the site's
16
13
  * heightfield (`site.terrain`, terrain.ts; fail-soft, flat lots write
17
14
  * nothing), so a foundation can read the hill.
15
+ * 5. The parcel and terrain are applied to the current site together
16
+ * (created at the root when the scene has none). A building outside
17
+ * the new ring is re-centred — `building.position` only, never the walls.
18
18
  *
19
19
  * Used by the Lot rail panel, the Generate panel (drop in, then generate)
20
20
  * and the Site inspector's "Find parcel", so every path behaves the same.
@@ -271,71 +271,63 @@ export async function dropInLot(
271
271
  roadsFailure = 'skipped'
272
272
  }
273
273
 
274
- const scene = useScene.getState()
275
- let site = findSiteNode(options.siteId)
276
- if (!site) {
277
- site = SiteNode.parse({ id: generateId('site'), type: 'site', name: 'Site' })
278
- scene.createNodes([{ node: site as AnyNode }])
279
- }
280
- const computed = sitePatchFromParcel(site, input, data, roads, undefined, extras)
281
- if (!computed)
274
+ const geometry = sitePatchFromParcel(null, input, data, roads, undefined, extras)
275
+ if (!geometry)
282
276
  return {
283
277
  ok: false,
284
278
  error: 'parcel geometry was unusable',
285
279
  message: 'The parcel geometry was unusable.',
286
280
  }
287
- // the surveyed contour lines ride the site with the patch (the site plan
288
- // draws them over the heightfield's own); absent without 3DEP lines
289
- const contours = dossier && dossierOrigin ? contourLinesFromDossier(dossier, dossierOrigin) : null
290
- useScene
291
- .getState()
292
- .updateNode(
293
- site.id as AnyNodeId,
294
- { ...computed.patch, terrainContours: contours ?? undefined } as Partial<AnyNode>,
295
- )
296
-
297
281
  // The ground over the lot — fail-soft. A sloping lot writes the
298
282
  // heightfield; a flat one (or a failed read) clears any terrain the
299
283
  // previous lot left behind so the site never shows another parcel's hill.
300
284
  let terrain: TerrainSampleSummary | null = null
301
285
  let terrainFailure = ''
286
+ let terrainRead: TerrainSampleResult | null = null
302
287
  if (options.terrain !== false) {
303
- const read: TerrainSampleResult = data.originLngLat
304
- ? await sampleLotTerrain(computed.patch.polygon?.points ?? [], data.originLngLat, {
288
+ terrainRead = data.originLngLat
289
+ ? await sampleLotTerrain(geometry.patch.polygon?.points ?? [], data.originLngLat, {
305
290
  provider,
306
291
  ...(options.terrainDeadlineMs ? { deadlineMs: options.terrainDeadlineMs } : {}),
307
292
  })
308
293
  : { ok: false, reason: 'no parcel origin' }
309
- const current = useScene.getState().nodes[site.id as AnyNodeId]
310
- const { terrainSample: _previous, ...meta } =
311
- current &&
312
- typeof current.metadata === 'object' &&
313
- current.metadata !== null &&
314
- !Array.isArray(current.metadata)
315
- ? (current.metadata as Record<string, unknown>)
316
- : {}
317
- if (read.ok && read.summary) {
318
- terrain = read.summary
319
- useScene.getState().updateNode(
320
- site.id as AnyNodeId,
321
- {
322
- terrain: read.terrain,
323
- metadata: { ...meta, terrainSample: read.summary },
324
- } as unknown as Partial<AnyNode>,
325
- )
326
- } else {
327
- terrainFailure = read.reason ?? 'no terrain'
328
- useScene
329
- .getState()
330
- .updateNode(
331
- site.id as AnyNodeId,
332
- { terrain: undefined, metadata: meta } as unknown as Partial<AnyNode>,
333
- )
334
- }
294
+ if (terrainRead.ok && terrainRead.summary) terrain = terrainRead.summary
295
+ else terrainFailure = terrainRead.reason ?? 'no terrain'
335
296
  } else {
336
297
  terrainFailure = 'skipped'
337
298
  }
338
299
 
300
+ // Hydration or a person's edit can replace the site while the providers run.
301
+ // Read it after every await, preserve its current settings, and commit the
302
+ // parcel with its terrain so a late read cannot leave the starter polygon.
303
+ const scene = useScene.getState()
304
+ let site = findSiteNode(options.siteId)
305
+ const computed = sitePatchFromParcel(site, input, data, roads, undefined, extras)
306
+ if (!computed)
307
+ return {
308
+ ok: false,
309
+ error: 'parcel geometry was unusable',
310
+ message: 'The parcel geometry was unusable.',
311
+ }
312
+ const contours = dossier && dossierOrigin ? contourLinesFromDossier(dossier, dossierOrigin) : null
313
+ const patch: Partial<SiteNode> = {
314
+ ...computed.patch,
315
+ terrainContours: contours ?? undefined,
316
+ }
317
+ if (terrainRead) {
318
+ const { terrainSample: _previous, ...metadata } = computed.patch.metadata as Record<
319
+ string,
320
+ unknown
321
+ >
322
+ patch.terrain = terrainRead.ok ? terrainRead.terrain : undefined
323
+ patch.metadata = terrain ? { ...metadata, terrainSample: terrain } : metadata
324
+ }
325
+ if (site) scene.updateNode(site.id as AnyNodeId, patch as Partial<AnyNode>)
326
+ else {
327
+ site = SiteNode.parse({ id: generateId('site'), type: 'site', name: 'Site', ...patch })
328
+ scene.createNodes([{ node: site as AnyNode }])
329
+ }
330
+
339
331
  // A house the new lot leaves past its lines stands centred on the buildable area again, facing
340
332
  // as it did.
341
333
  let recentred = false
@@ -0,0 +1,24 @@
1
+ import type { MaterialSwatch } from '@pascal-app/core'
2
+ import type { CSSProperties } from 'react'
3
+
4
+ /**
5
+ * The CSS that paints a material swatch: the image over its colour, and a tinted
6
+ * albedo multiplied by the colour, as the 3D material tints its map.
7
+ */
8
+ export function materialSwatchStyle({
9
+ color,
10
+ imageUrl,
11
+ tinted,
12
+ }: Pick<MaterialSwatch, 'color' | 'imageUrl'> & { tinted?: boolean }): CSSProperties {
13
+ return {
14
+ backgroundColor: color,
15
+ ...(imageUrl
16
+ ? {
17
+ backgroundImage: `url(${JSON.stringify(imageUrl)})`,
18
+ backgroundPosition: 'center',
19
+ backgroundSize: 'cover',
20
+ ...(tinted ? { backgroundBlendMode: 'multiply' } : {}),
21
+ }
22
+ : {}),
23
+ }
24
+ }
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
2
  import { measurementCentroid, pointInPolygon2D } from '@pascal-app/core'
3
- import { measurementPolygonLabelAnchor, triangulateMeasurementPolygon } from './measurement-label'
3
+ import { measurementPolygonLabelAnchor, triangulateMeasurementPolygon } from '@pascal-app/viewer'
4
4
 
5
5
  describe('measurementPolygonLabelAnchor', () => {
6
6
  test('keeps a concave polygon label inside its visible fill', () => {
@@ -1,6 +1,7 @@
1
1
  // Must precede the lingo import: lingo clones its kind table with
2
2
  // structuredClone while its module body evaluates. See the file's own comment.
3
3
  import './structured-clone-fallback'
4
+ import { normalizeDecimalInput } from '@pascal-app/core'
4
5
  import { type Kind, parseQuantity, quantity } from '@pascal-app/lingo'
5
6
 
6
7
  /**
@@ -56,6 +57,8 @@ export interface ParseMeasurementOptions {
56
57
  bareUnit?: string
57
58
  /** Disambiguates gal/ton/cup families; harmless for length/angle. */
58
59
  system?: 'metric' | 'us' | 'imperial'
60
+ /** Locale whose decimal separator is accepted in bare values and quantities. */
61
+ locale?: string
59
62
  }
60
63
 
61
64
  /**
@@ -68,7 +71,7 @@ export function parseMeasurement(
68
71
  spec: LingoUnitSpec,
69
72
  options: ParseMeasurementOptions = {},
70
73
  ): number | null {
71
- const result = parseQuantity(raw, {
74
+ const result = parseQuantity(normalizeDecimalInput(raw, options.locale), {
72
75
  kind: spec.kind,
73
76
  unit: options.bareUnit ?? spec.unitId,
74
77
  system: options.system,
@@ -105,9 +108,10 @@ export function measurementHint(
105
108
  options: MeasurementHintOptions = {},
106
109
  ): string | null {
107
110
  const trimmed = raw.trim()
108
- if (!trimmed || PLAIN_NUMBER.test(trimmed)) return null
111
+ const normalized = normalizeDecimalInput(trimmed, options.locale)
112
+ if (!normalized || PLAIN_NUMBER.test(normalized)) return null
109
113
 
110
- const parsed = parseMeasurement(trimmed, spec, options)
114
+ const parsed = parseMeasurement(normalized, spec, options)
111
115
  if (parsed === null) return null
112
116
  const stored = options.clamp ? options.clamp(parsed) : parsed
113
117
 
@@ -1,5 +1,4 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
- import { measurementHint, parseMeasurement } from '../index'
3
2
  import {
4
3
  buildMeasurementAngleArcPoints,
5
4
  cubicMetersToVolumeUnit,
@@ -11,15 +10,18 @@ import {
11
10
  getVolumeUnitLabel,
12
11
  linearControlValueToMeters,
13
12
  linearUnitToMeters,
13
+ metersToLinearUnit,
14
+ parseLinearMeasurementInput,
15
+ squareMetersToAreaUnit,
16
+ } from '@pascal-app/core'
17
+ import {
14
18
  MEASUREMENT_ACTIVE_COLOR,
15
19
  MEASUREMENT_DANGLING_COLOR,
16
- MEASUREMENT_FLOORPLAN_COLOR,
17
20
  MEASUREMENT_PERSISTENT_COLOR,
18
- measurementFloorplanPresentationColor,
19
21
  measurementPresentationColor,
20
- metersToLinearUnit,
21
- squareMetersToAreaUnit,
22
- } from './measurements'
22
+ } from '@pascal-app/viewer'
23
+ import { measurementHint, parseMeasurement } from '../index'
24
+ import { MEASUREMENT_FLOORPLAN_COLOR, measurementFloorplanPresentationColor } from './measurements'
23
25
 
24
26
  describe('measurement presentation', () => {
25
27
  test('uses black at rest, indigo while active, and red for dangling references', () => {
@@ -129,6 +131,11 @@ describe('linear measurements', () => {
129
131
  expect(linearControlValueToMeters(3.5, 'metric')).toBe(3.5)
130
132
  })
131
133
 
134
+ test('parses floor-plan measurement input with the scoped locale', () => {
135
+ expect(parseLinearMeasurementInput('2,75', 'metric', 'fr')).toBe(2.75)
136
+ expect(parseLinearMeasurementInput('2,75', 'metric', 'en')).toBeNull()
137
+ })
138
+
132
139
  test('clamps numeric control input after converting to meters', () => {
133
140
  expect(linearControlValueToMeters(0.1, 'imperial', { minMeters: 0.1 })).toBe(0.1)
134
141
  expect(linearControlValueToMeters(0.3, 'imperial', { minMeters: 0.1 })).toBe(0.1)
@@ -212,4 +219,15 @@ describe('typed measurement fields', () => {
212
219
  expect(measurementHint('180cm', length)).toBe('= 1.80 m')
213
220
  expect(measurementHint('6', length)).toBeNull()
214
221
  })
222
+
223
+ test('only treats the active locale decimal separator as a plain decimal', () => {
224
+ const length = { kind: 'length', unitId: 'm' } as const
225
+
226
+ expect(measurementHint('1,000', length, { locale: 'en' })).not.toBeNull()
227
+ expect(measurementHint('-1,000', length, { locale: 'en' })).not.toBeNull()
228
+ expect(measurementHint('1,5', length, { locale: 'en' })).not.toBeNull()
229
+
230
+ expect(measurementHint('1,5', length, { locale: 'fr' })).toBeNull()
231
+ expect(parseMeasurement('2,75 m', length, { locale: 'fr' })).toBe(2.75)
232
+ })
215
233
  })
@@ -1,210 +1,8 @@
1
- import type { MeasurementPoint } from '@pascal-app/core'
1
+ import { MEASUREMENT_ACTIVE_COLOR, MEASUREMENT_DANGLING_COLOR } from '@pascal-app/viewer'
2
2
 
3
- export type LinearUnit = 'metric' | 'imperial'
4
- export type MetricNotation = 'meters' | 'millimeters'
5
-
6
- export const MEASUREMENT_ACTIVE_COLOR = '#6366f1'
7
- export const MEASUREMENT_DANGLING_COLOR = '#dc2626'
8
3
  export const MEASUREMENT_FLOORPLAN_COLOR = '#4f46e5'
9
- export const MEASUREMENT_PERSISTENT_COLOR = '#111827'
10
-
11
- export function measurementPresentationColor(dangling: boolean, active: boolean): string {
12
- if (dangling) return MEASUREMENT_DANGLING_COLOR
13
- return active ? MEASUREMENT_ACTIVE_COLOR : MEASUREMENT_PERSISTENT_COLOR
14
- }
15
4
 
16
5
  export function measurementFloorplanPresentationColor(dangling: boolean, active: boolean): string {
17
6
  if (dangling) return MEASUREMENT_DANGLING_COLOR
18
7
  return active ? MEASUREMENT_ACTIVE_COLOR : MEASUREMENT_FLOORPLAN_COLOR
19
8
  }
20
-
21
- type MeasurementAngleArcOptions = {
22
- radius?: number
23
- sampleCount?: number
24
- }
25
-
26
- const ANGLE_ARC_EPSILON = 1e-9
27
-
28
- const subtractPoint = (point: MeasurementPoint, origin: MeasurementPoint): MeasurementPoint => [
29
- point[0] - origin[0],
30
- point[1] - origin[1],
31
- point[2] - origin[2],
32
- ]
33
-
34
- const pointLength = (point: MeasurementPoint): number => Math.hypot(...point)
35
-
36
- const scalePoint = (point: MeasurementPoint, scale: number): MeasurementPoint => [
37
- point[0] * scale,
38
- point[1] * scale,
39
- point[2] * scale,
40
- ]
41
-
42
- const crossPoint = (first: MeasurementPoint, second: MeasurementPoint): MeasurementPoint => [
43
- first[1] * second[2] - first[2] * second[1],
44
- first[2] * second[0] - first[0] * second[2],
45
- first[0] * second[1] - first[1] * second[0],
46
- ]
47
-
48
- const dotPoint = (first: MeasurementPoint, second: MeasurementPoint): number =>
49
- first[0] * second[0] + first[1] * second[1] + first[2] * second[2]
50
-
51
- export function buildMeasurementAngleArcPoints(
52
- start: MeasurementPoint,
53
- vertex: MeasurementPoint,
54
- end: MeasurementPoint,
55
- options: MeasurementAngleArcOptions = {},
56
- ): MeasurementPoint[] {
57
- if (![...start, ...vertex, ...end].every(Number.isFinite)) return []
58
-
59
- const startVector = subtractPoint(start, vertex)
60
- const endVector = subtractPoint(end, vertex)
61
- const startLength = pointLength(startVector)
62
- const endLength = pointLength(endVector)
63
- if (startLength <= ANGLE_ARC_EPSILON || endLength <= ANGLE_ARC_EPSILON) return []
64
-
65
- const startDirection = scalePoint(startVector, 1 / startLength)
66
- const endDirection = scalePoint(endVector, 1 / endLength)
67
- const cosine = Math.max(-1, Math.min(1, dotPoint(startDirection, endDirection)))
68
- const angle = Math.acos(cosine)
69
- if (angle <= 1e-4) return []
70
-
71
- let normal = crossPoint(startDirection, endDirection)
72
- let normalLength = pointLength(normal)
73
- if (normalLength <= ANGLE_ARC_EPSILON) {
74
- const reference: MeasurementPoint = Math.abs(startDirection[1]) < 0.9 ? [0, 1, 0] : [1, 0, 0]
75
- normal = crossPoint(startDirection, reference)
76
- normalLength = pointLength(normal)
77
- }
78
- if (normalLength <= ANGLE_ARC_EPSILON) return []
79
- normal = scalePoint(normal, 1 / normalLength)
80
- const tangent = crossPoint(normal, startDirection)
81
-
82
- const shortestSide = Math.min(startLength, endLength)
83
- const preferredRadius = options.radius ?? Math.min(Math.max(shortestSide * 0.28, 0.08), 0.75)
84
- const radius = Math.min(Math.max(preferredRadius, 0), shortestSide * 0.45)
85
- if (radius <= ANGLE_ARC_EPSILON) return []
86
-
87
- const sampleCount = Math.min(
88
- 64,
89
- Math.max(4, Math.round(options.sampleCount ?? Math.max(8, (angle / Math.PI) * 32))),
90
- )
91
- return Array.from({ length: sampleCount + 1 }, (_, index) => {
92
- const sampleAngle = angle * (index / sampleCount)
93
- const direction: MeasurementPoint = [
94
- startDirection[0] * Math.cos(sampleAngle) + tangent[0] * Math.sin(sampleAngle),
95
- startDirection[1] * Math.cos(sampleAngle) + tangent[1] * Math.sin(sampleAngle),
96
- startDirection[2] * Math.cos(sampleAngle) + tangent[2] * Math.sin(sampleAngle),
97
- ]
98
- return [
99
- vertex[0] + direction[0] * radius,
100
- vertex[1] + direction[1] * radius,
101
- vertex[2] + direction[2] * radius,
102
- ]
103
- })
104
- }
105
-
106
- const METERS_PER_FOOT = 0.3048
107
- const FEET_PER_METER = 1 / METERS_PER_FOOT
108
-
109
- type LinearControlValueOptions = {
110
- minMeters?: number
111
- maxMeters?: number
112
- }
113
-
114
- export function metersToLinearUnit(meters: number, unit: LinearUnit): number {
115
- return unit === 'imperial' ? meters * FEET_PER_METER : meters
116
- }
117
-
118
- export function linearUnitToMeters(value: number, unit: LinearUnit): number {
119
- return unit === 'imperial' ? value * METERS_PER_FOOT : value
120
- }
121
-
122
- export function linearControlValueToMeters(
123
- value: number,
124
- unit: LinearUnit,
125
- options: LinearControlValueOptions = {},
126
- ): number {
127
- const meters = linearUnitToMeters(value, unit)
128
- const minMeters = options.minMeters ?? Number.NEGATIVE_INFINITY
129
- const maxMeters = options.maxMeters ?? Number.POSITIVE_INFINITY
130
-
131
- return Math.min(Math.max(meters, minMeters), maxMeters)
132
- }
133
-
134
- export function getLinearUnitLabel(unit: LinearUnit): string {
135
- return unit === 'imperial' ? 'ft' : 'm'
136
- }
137
-
138
- const SQUARE_FEET_PER_SQUARE_METER = FEET_PER_METER * FEET_PER_METER
139
- const CUBIC_FEET_PER_CUBIC_METER = SQUARE_FEET_PER_SQUARE_METER * FEET_PER_METER
140
-
141
- export function squareMetersToAreaUnit(squareMeters: number, unit: LinearUnit): number {
142
- return unit === 'imperial' ? squareMeters * SQUARE_FEET_PER_SQUARE_METER : squareMeters
143
- }
144
-
145
- export function getAreaUnitLabel(unit: LinearUnit): string {
146
- return unit === 'imperial' ? 'ft²' : 'm²'
147
- }
148
-
149
- export function formatAreaLabel(
150
- squareMeters: number,
151
- unit: LinearUnit,
152
- fractionDigits = 1,
153
- ): string {
154
- if (!Number.isFinite(squareMeters)) return '--'
155
-
156
- return `${squareMetersToAreaUnit(squareMeters, unit).toFixed(fractionDigits)}${getAreaUnitLabel(unit)}`
157
- }
158
-
159
- export function cubicMetersToVolumeUnit(cubicMeters: number, unit: LinearUnit): number {
160
- return unit === 'imperial' ? cubicMeters * CUBIC_FEET_PER_CUBIC_METER : cubicMeters
161
- }
162
-
163
- export function getVolumeUnitLabel(unit: LinearUnit): string {
164
- return unit === 'imperial' ? 'ft³' : 'm³'
165
- }
166
-
167
- export function formatVolumeLabel(
168
- cubicMeters: number,
169
- unit: LinearUnit,
170
- fractionDigits = 1,
171
- ): string {
172
- if (!Number.isFinite(cubicMeters)) return '--'
173
-
174
- return `${cubicMetersToVolumeUnit(cubicMeters, unit).toFixed(fractionDigits)}${getVolumeUnitLabel(unit)}`
175
- }
176
-
177
- export function formatLinearMeasurement(
178
- meters: number,
179
- unit: LinearUnit,
180
- metricNotation: MetricNotation = 'meters',
181
- ): string {
182
- if (!Number.isFinite(meters)) return '--'
183
-
184
- const absoluteMeters = Math.abs(meters)
185
-
186
- if (unit === 'imperial') {
187
- const feet = metersToLinearUnit(absoluteMeters, unit)
188
- let wholeFeet = Math.floor(feet)
189
- let inches = Math.round((feet - wholeFeet) * 12)
190
- if (inches === 12) {
191
- wholeFeet += 1
192
- inches = 0
193
- }
194
-
195
- const sign = meters < 0 && (wholeFeet !== 0 || inches !== 0) ? '-' : ''
196
-
197
- return `${sign}${wholeFeet}'${inches}"`
198
- }
199
-
200
- if (metricNotation === 'millimeters') {
201
- const roundedMillimeters = Math.round(absoluteMeters * 1000)
202
- const sign = meters < 0 && roundedMillimeters !== 0 ? '-' : ''
203
- return `${sign}${roundedMillimeters}mm`
204
- }
205
-
206
- const roundedMeters = Number.parseFloat(absoluteMeters.toFixed(2))
207
- const sign = meters < 0 && roundedMeters !== 0 ? '-' : ''
208
-
209
- return `${sign}${roundedMeters}m`
210
- }
@@ -169,7 +169,9 @@ describe('mezzanine selection', () => {
169
169
  expect(gesture(hostId, ROOM_MOVE_DRAG_LABEL)).toBeNull()
170
170
  expect(gesture(mezzanineId, 'height')).toBeNull()
171
171
  expect(mezzanineGesture(null, nodes())).toBeNull()
172
- expect(MEZZANINE_GESTURE_HINTS.move[0]!.label).toBe('Place inside the room')
172
+ expect(MEZZANINE_GESTURE_HINTS.move[0]!.label).toMatchObject({
173
+ message: 'Place inside the room',
174
+ })
173
175
  })
174
176
 
175
177
  test('the delete dialog speaks of the mezzanine, and of its items only when it has some', () => {
@@ -0,0 +1,115 @@
1
+ import { beforeAll, expect, test } from 'bun:test'
2
+ import {
3
+ type AnyNode,
4
+ type AnyNodeDefinition,
5
+ type AnyNodeId,
6
+ isMoveLocked,
7
+ isNodeDuplicable,
8
+ nodeRegistry,
9
+ registerNode,
10
+ useScene,
11
+ } from '@pascal-app/core'
12
+ import { z } from 'zod'
13
+ import { duplicateNodeAndPickUp } from '../components/editor/duplicate-node'
14
+ import { classifyParticipant } from '../components/editor/group-transform-shared'
15
+ import useEditor from '../store/use-editor'
16
+ import useInteractionScope from '../store/use-interaction-scope'
17
+ import { canDirectMoveNode } from './direct-manipulation'
18
+ import { createEditorApi } from './editor-api'
19
+ import { registryMoveDisabled } from './node-action-movement'
20
+ import { copySelectedNodesToEditorClipboard } from './scene-clipboard'
21
+
22
+ // Mirrors a lean-to post: a kind-level 2D mover plus per-node move and duplicate locks.
23
+ const KIND = 'move-lock-test'
24
+
25
+ beforeAll(() => {
26
+ if (nodeRegistry.has(KIND)) return
27
+ registerNode({
28
+ kind: KIND,
29
+ schemaVersion: 1,
30
+ schema: z.object({ type: z.literal(KIND) }) as never,
31
+ category: 'structure',
32
+ defaults: () => ({}),
33
+ capabilities: {
34
+ movable: {
35
+ axes: ['x', 'z'],
36
+ gridSnap: true,
37
+ override: ({ node }) =>
38
+ (node as { metadata?: { locked?: boolean } }).metadata?.locked
39
+ ? { axes: [], gridSnap: false }
40
+ : null,
41
+ },
42
+ duplicable: {
43
+ allow: ({ node }) => !(node as { metadata?: { locked?: boolean } }).metadata?.locked,
44
+ },
45
+ },
46
+ floorplanMoveTarget: () => ({
47
+ affectedIds: [],
48
+ apply() {},
49
+ canCommit: () => false,
50
+ commit() {},
51
+ }),
52
+ renderer: { kind: 'parametric', module: async () => ({ default: () => null }) },
53
+ } as AnyNodeDefinition)
54
+ })
55
+
56
+ function node(locked: boolean) {
57
+ return {
58
+ id: locked ? 'move_lock_locked' : 'move_lock_free',
59
+ type: KIND,
60
+ parentId: 'level_move_lock',
61
+ position: [1, 0, 2],
62
+ rotation: 0,
63
+ metadata: locked ? { locked: true } : {},
64
+ } as unknown as AnyNode
65
+ }
66
+
67
+ test('a per-node override lock outranks the kind-level 2D mover', () => {
68
+ expect(isMoveLocked(node(true))).toBe(true)
69
+ expect(isMoveLocked(node(false))).toBe(false)
70
+ expect(registryMoveDisabled(node(true))).toBe(true)
71
+ expect(registryMoveDisabled(node(false))).toBe(false)
72
+ expect(canDirectMoveNode(node(true))).toBe(false)
73
+ })
74
+
75
+ test('group pick-up leaves a locked node behind', () => {
76
+ const locked = node(true)
77
+ const free = node(false)
78
+ const nodes = { [locked.id]: locked, [free.id]: free }
79
+ expect(classifyParticipant(locked, 'level_move_lock', nodes)).toBeNull()
80
+ expect(classifyParticipant(free, 'level_move_lock', nodes)).toBe('scalar')
81
+ })
82
+
83
+ test('every moving-node entry refuses a locked node', () => {
84
+ const api = createEditorApi()
85
+ useEditor.getState().setMovingNode(node(true))
86
+ expect(useInteractionScope.getState().scope.kind).toBe('idle')
87
+ api.engageMoveDrag(node(true))
88
+ expect(useInteractionScope.getState().scope.kind).toBe('idle')
89
+ expect(useEditor.getState().placementDragMode).toBe(false)
90
+ api.engageMove(node(false))
91
+ expect(useInteractionScope.getState().scope.kind).toBe('moving')
92
+ useEditor.getState().setMovingNode(null)
93
+ })
94
+
95
+ test('a locked node cannot be duplicated or copied', () => {
96
+ const level = {
97
+ id: 'level_move_lock',
98
+ type: 'level',
99
+ parentId: null,
100
+ children: ['move_lock_locked', 'move_lock_free'],
101
+ } as unknown as AnyNode
102
+ const locked = node(true)
103
+ const free = node(false)
104
+ useScene.setState({
105
+ nodes: { [level.id]: level, [locked.id]: locked, [free.id]: free } as never,
106
+ rootNodeIds: [level.id as AnyNodeId],
107
+ })
108
+ expect(isNodeDuplicable(locked)).toBe(false)
109
+ expect(isNodeDuplicable(free)).toBe(true)
110
+ duplicateNodeAndPickUp(locked)
111
+ expect(Object.keys(useScene.getState().nodes)).toHaveLength(3)
112
+ expect(useInteractionScope.getState().scope.kind).toBe('idle')
113
+ expect(copySelectedNodesToEditorClipboard([locked.id as AnyNodeId])).toBe(false)
114
+ expect(copySelectedNodesToEditorClipboard([free.id as AnyNodeId])).toBe(true)
115
+ })
@@ -4,12 +4,14 @@ import {
4
4
  cloneNodesInto,
5
5
  collectSubtree,
6
6
  isMovable,
7
+ isMoveLocked,
7
8
  nodeRegistry,
8
9
  useScene,
9
10
  } from '@pascal-app/core'
10
11
  import { copyCreateOps } from './fresh-planar-placement'
11
12
 
12
13
  export function registryMoveDisabled(node: AnyNode): boolean {
14
+ if (isMoveLocked(node)) return true
13
15
  const def = nodeRegistry.get(node.type)
14
16
  return Boolean(
15
17
  def?.capabilities.movable &&