@pascal-app/editor 1.0.3 → 1.0.4

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 (440) hide show
  1. package/package.json +11 -9
  2. package/src/components/editor/action-menu-button.tsx +83 -0
  3. package/src/components/editor/bake-thumbnail.tsx +315 -4
  4. package/src/components/editor/capture-level-picker.tsx +72 -0
  5. package/src/components/editor/custom-camera-controls.tsx +202 -57
  6. package/src/components/editor/delete-confirmation-dialog.tsx +297 -7
  7. package/src/components/editor/duplicate-node.ts +244 -0
  8. package/src/components/editor/editor-layout-mobile.tsx +36 -25
  9. package/src/components/editor/editor-layout-v2.tsx +131 -58
  10. package/src/components/editor/export-manager.tsx +34 -8
  11. package/src/components/editor/first-person/build-collider-world.test.ts +51 -0
  12. package/src/components/editor/first-person/build-collider-world.ts +6 -2
  13. package/src/components/editor/first-person/mechanism-targets.test.ts +75 -0
  14. package/src/components/editor/first-person/mechanism-targets.ts +36 -0
  15. package/src/components/editor/first-person-controls.tsx +205 -2
  16. package/src/components/editor/floating-action-menu.tsx +63 -207
  17. package/src/components/editor/floor-aware-camera-controls.test.ts +442 -0
  18. package/src/components/editor/floor-aware-camera-controls.ts +324 -0
  19. package/src/components/editor/floor-openings-overlay.tsx +243 -0
  20. package/src/components/editor/floor-region-controls.tsx +846 -0
  21. package/src/components/editor/floor-region-webgpu-materials.test.ts +12 -0
  22. package/src/components/editor/floor-surface.test.ts +165 -0
  23. package/src/components/editor/floor-surface.ts +182 -0
  24. package/src/components/editor/floorplan-background-selection.test.ts +11 -2
  25. package/src/components/editor/floorplan-background-selection.ts +1 -3
  26. package/src/components/editor/floorplan-panel.tsx +344 -342
  27. package/src/components/editor/footprint-height-handle.tsx +204 -0
  28. package/src/components/editor/group-actions.ts +54 -2
  29. package/src/components/editor/handles/handle-hit-priority.test.tsx +63 -0
  30. package/src/components/editor/handles/handle-hit-priority.tsx +37 -0
  31. package/src/components/editor/handles/swallow-next-click.test.ts +28 -0
  32. package/src/components/editor/handles/use-handle-drag.ts +269 -197
  33. package/src/components/editor/index.tsx +294 -169
  34. package/src/components/editor/join-walls-pill.tsx +44 -0
  35. package/src/components/editor/node-action-menu.tsx +68 -70
  36. package/src/components/editor/node-arrow-handles.tsx +25 -6
  37. package/src/components/editor/open-wall-ends-3d-layer.tsx +273 -0
  38. package/src/components/editor/open-wall-ends-hint.tsx +89 -0
  39. package/src/components/editor/quick-measurement-hud.tsx +3 -2
  40. package/src/components/editor/registry-action-contributions.test.tsx +63 -0
  41. package/src/components/editor/registry-action-contributions.tsx +80 -1
  42. package/src/components/editor/room-controls.tsx +847 -0
  43. package/src/components/editor/room-floor-highlight.tsx +128 -0
  44. package/src/components/editor/room-handles-2d.tsx +354 -0
  45. package/src/components/editor/room-handles.tsx +675 -0
  46. package/src/components/editor/room-highlight.tsx +225 -0
  47. package/src/components/editor/room-labels.test.ts +357 -0
  48. package/src/components/editor/room-labels.tsx +990 -0
  49. package/src/components/editor/room-transform-ghost.tsx +256 -0
  50. package/src/components/editor/room-webgpu-materials.test.ts +27 -0
  51. package/src/components/editor/selection-manager.tsx +451 -317
  52. package/src/components/editor/slab-hole-highlights.tsx +3 -0
  53. package/src/components/editor/snapshot-capture-overlay.tsx +4 -0
  54. package/src/components/editor/snapshot-capture.test.ts +52 -4
  55. package/src/components/editor/snapshot-capture.ts +30 -5
  56. package/src/components/editor/thumbnail-generator.test.ts +308 -0
  57. package/src/components/editor/thumbnail-generator.tsx +1035 -229
  58. package/src/components/editor/use-floorplan-background-placement.ts +31 -11
  59. package/src/components/editor/vector-edge-extractor.tsx +121 -0
  60. package/src/components/editor/views/host-view.tsx +61 -0
  61. package/src/components/editor/views/plugins-view.tsx +670 -0
  62. package/src/components/editor/views/scene-region.tsx +59 -0
  63. package/src/components/editor/views/use-editor-views.ts +57 -0
  64. package/src/components/editor/views/view-bar.tsx +220 -0
  65. package/src/components/editor/wall-justification.test.ts +140 -0
  66. package/src/components/editor/wall-move-side-handles.tsx +43 -142
  67. package/src/components/editor/wall-push-arrow.tsx +92 -0
  68. package/src/components/editor/wall-region-handles.tsx +509 -0
  69. package/src/components/editor/wall-snap-beacon-layer.tsx +3 -0
  70. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +8 -4
  71. package/src/components/editor-2d/floorplan-open-wall-ends-layer.test.tsx +86 -0
  72. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +290 -0
  73. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +11 -1
  74. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +113 -102
  75. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +156 -0
  76. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +278 -0
  77. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +77 -0
  78. package/src/components/editor-2d/renderers/{floorplan-draft-wall-measurement.tsx → floorplan-draft-measurement.tsx} +8 -8
  79. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +4 -1
  80. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +38 -8
  81. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +291 -227
  82. package/src/components/editor-2d/svg-paths.ts +12 -15
  83. package/src/components/procedural-items/inspector.tsx +40 -15
  84. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +97 -14
  85. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +18 -18
  86. package/src/components/systems/zone/zone-system.test.ts +47 -0
  87. package/src/components/systems/zone/zone-system.tsx +29 -68
  88. package/src/components/tools/item/placement-history.test.tsx +474 -0
  89. package/src/components/tools/item/placement-strategies.ts +8 -4
  90. package/src/components/tools/item/use-draft-node.test.tsx +432 -1
  91. package/src/components/tools/item/use-draft-node.ts +166 -95
  92. package/src/components/tools/item/use-placement-coordinator.tsx +93 -46
  93. package/src/components/tools/paint-region/use-paint-tint.ts +37 -0
  94. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +466 -0
  95. package/src/components/tools/registry/move-registry-node-tool.tsx +3 -1
  96. package/src/components/tools/select/box-select-tool.tsx +2 -12
  97. package/src/components/tools/select/plane-box-select-tool.test.tsx +79 -0
  98. package/src/components/tools/select/plane-box-select-tool.tsx +35 -22
  99. package/src/components/tools/select/select-candidates.test.ts +37 -4
  100. package/src/components/tools/select/select-candidates.ts +15 -11
  101. package/src/components/tools/shared/cursor-sphere.tsx +36 -12
  102. package/src/components/tools/site/site-boundary-editor.tsx +1 -2
  103. package/src/components/tools/stair/stair-defaults.ts +0 -3
  104. package/src/components/tools/stair/stair-tool.tsx +90 -42
  105. package/src/components/tools/tool-manager.tsx +46 -20
  106. package/src/components/tools/wall/wall-drafting.test.ts +19 -4
  107. package/src/components/tools/zone/zone-tool.tsx +4 -12
  108. package/src/components/ui/action-menu/action-button.tsx +3 -0
  109. package/src/components/ui/action-menu/camera-actions.tsx +2 -1
  110. package/src/components/ui/action-menu/control-modes.tsx +2 -35
  111. package/src/components/ui/action-menu/measurement-control.tsx +3 -10
  112. package/src/components/ui/build-panel/build-panel.tsx +235 -0
  113. package/src/components/ui/command-palette/editor-commands.tsx +4 -30
  114. package/src/components/ui/command-palette/index.tsx +5 -24
  115. package/src/components/ui/controls/finish-swatch.tsx +110 -0
  116. package/src/components/ui/controls/material-paint-panel.tsx +97 -26
  117. package/src/components/ui/controls/material-picker.tsx +4 -1
  118. package/src/components/ui/controls/scene-material-list.tsx +4 -32
  119. package/src/components/ui/controls/select-control.tsx +65 -0
  120. package/src/components/ui/floating-level-selector.tsx +9 -14
  121. package/src/components/ui/helpers/building-helper.tsx +4 -1
  122. package/src/components/ui/helpers/contextual-helper-panel.tsx +154 -70
  123. package/src/components/ui/helpers/helper-manager.test.tsx +69 -0
  124. package/src/components/ui/helpers/helper-manager.tsx +325 -14
  125. package/src/components/ui/helpers/item-helper.tsx +8 -0
  126. package/src/components/ui/helpers/registered-tool-helper.tsx +8 -1
  127. package/src/components/ui/icon-ref.tsx +45 -1
  128. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  129. package/src/components/ui/node-icon-image.tsx +16 -0
  130. package/src/components/ui/panels/collections/collections-popover.tsx +20 -14
  131. package/src/components/ui/panels/floor-foundation-panel.tsx +551 -0
  132. package/src/components/ui/panels/floor-opening-panel.tsx +143 -0
  133. package/src/components/ui/panels/mobile-panel-sheet.tsx +2 -2
  134. package/src/components/ui/panels/mobile-selection-bar.tsx +2 -2
  135. package/src/components/ui/panels/multi-field-value.test.ts +26 -3
  136. package/src/components/ui/panels/multi-field-value.ts +6 -3
  137. package/src/components/ui/panels/multi-parametric-inspector.tsx +52 -2
  138. package/src/components/ui/panels/multi-selection-panel.tsx +43 -4
  139. package/src/components/ui/panels/node-display.ts +8 -3
  140. package/src/components/ui/panels/panel-manager.tsx +103 -68
  141. package/src/components/ui/panels/panel-wrapper.tsx +78 -36
  142. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  143. package/src/components/ui/panels/parametric-inspector.tsx +4 -4
  144. package/src/components/ui/panels/reference-panel.tsx +2 -3
  145. package/src/components/ui/panels/room-construction-rows.tsx +774 -0
  146. package/src/components/ui/panels/room-name-options.test.ts +55 -0
  147. package/src/components/ui/panels/room-name-options.ts +47 -0
  148. package/src/components/ui/panels/room-openings-field.tsx +110 -0
  149. package/src/components/ui/panels/room-panel.tsx +725 -0
  150. package/src/components/ui/panels/shape-choice.tsx +58 -0
  151. package/src/components/ui/panels/wall-region-list.tsx +109 -0
  152. package/src/components/ui/primitives/color-dot.tsx +36 -24
  153. package/src/components/ui/primitives/sidebar.tsx +2 -0
  154. package/src/components/ui/right-stack.test.ts +29 -0
  155. package/src/components/ui/right-stack.tsx +145 -0
  156. package/src/components/ui/sidebar/icon-rail.tsx +5 -0
  157. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +31 -4
  158. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +5 -5
  159. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -4
  160. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +1 -5
  161. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +197 -0
  162. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -5
  163. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +1 -5
  164. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +1 -5
  165. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -5
  166. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +24 -11
  167. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +1 -5
  168. package/src/components/ui/sidebar/panels/site-panel/index.tsx +176 -211
  169. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +30 -8
  170. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +1 -3
  171. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +4 -1
  172. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +1 -3
  173. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +1 -4
  174. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +1 -3
  175. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +21 -13
  176. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +1 -5
  177. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -5
  178. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +1 -5
  179. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +0 -27
  180. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +2 -3
  181. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -5
  182. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +1 -5
  183. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +9 -4
  184. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +0 -2
  185. package/src/components/ui/sidebar/tab-bar.tsx +10 -0
  186. package/src/components/ui/sidebar/use-plugin-panels.tsx +15 -5
  187. package/src/components/viewer/floorplan-compass-button.tsx +9 -1
  188. package/src/components/viewer/floorplan-preview.tsx +5 -6
  189. package/src/components/viewer/viewer-hover-label.tsx +34 -0
  190. package/src/components/viewer/viewer-rooms.tsx +175 -0
  191. package/src/components/viewer/viewer-scene-header.tsx +181 -145
  192. package/src/components/viewer/viewer-selection-manager.tsx +124 -0
  193. package/src/components/viewer/viewer-stage.tsx +2 -2
  194. package/src/components/viewer-zone-system.tsx +3 -8
  195. package/src/hooks/use-auto-save.integration.test.tsx +123 -9
  196. package/src/hooks/use-auto-save.ts +34 -8
  197. package/src/hooks/use-keyboard.test.ts +178 -3
  198. package/src/hooks/use-keyboard.ts +89 -71
  199. package/src/hooks/use-open-wall-ends.test.ts +99 -0
  200. package/src/hooks/use-open-wall-ends.ts +238 -0
  201. package/src/hooks/use-placement-notice.test.ts +48 -0
  202. package/src/hooks/use-placement-notice.ts +42 -0
  203. package/src/hooks/use-selected-room.ts +143 -0
  204. package/src/index.tsx +246 -39
  205. package/src/lib/ceiling-edit-session.test.ts +321 -0
  206. package/src/lib/ceiling-edit-session.ts +206 -0
  207. package/src/lib/ceiling-region-session.test.ts +179 -0
  208. package/src/lib/continuation.test.ts +19 -1
  209. package/src/lib/continuation.ts +25 -4
  210. package/src/lib/door-interaction.ts +14 -0
  211. package/src/lib/editor-level-display.test.ts +96 -0
  212. package/src/lib/editor-level-display.ts +48 -0
  213. package/src/lib/editor-views.ts +144 -0
  214. package/src/lib/export-geometry-wait.test.ts +90 -0
  215. package/src/lib/floor-edit-session.ts +153 -0
  216. package/src/lib/floor-footprints.test.ts +563 -0
  217. package/src/lib/floor-footprints.ts +395 -0
  218. package/src/lib/floor-foundation-support.test.ts +283 -0
  219. package/src/lib/floor-opening-draft.ts +218 -0
  220. package/src/lib/floor-openings.test.ts +216 -0
  221. package/src/lib/floor-region-geometry.ts +191 -0
  222. package/src/lib/floor-region-session.test.ts +485 -0
  223. package/src/lib/floor-region-session.ts +589 -0
  224. package/src/lib/floor-region-snap.ts +101 -0
  225. package/src/lib/floor-step-hover.test.ts +32 -0
  226. package/src/lib/floorplan/annotation-visibility.ts +17 -0
  227. package/src/lib/floorplan/dimension-drive.test.ts +360 -0
  228. package/src/lib/floorplan/dimension-drive.ts +479 -0
  229. package/src/lib/floorplan/floorplan-export.test.ts +12 -8
  230. package/src/lib/floorplan/floorplan-export.tsx +32 -8
  231. package/src/lib/floorplan/floorplan-extension.ts +32 -0
  232. package/src/lib/floorplan/floorplan-mode.test.ts +4 -0
  233. package/src/lib/floorplan/floorplan-mode.ts +2 -0
  234. package/src/lib/floorplan/floorplan-pdfkit-document.ts +339 -18
  235. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +138 -8
  236. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +56 -25
  237. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +138 -0
  238. package/src/lib/floorplan/floorplan-readonly.ts +6 -0
  239. package/src/lib/floorplan/geometry.ts +13 -6
  240. package/src/lib/floorplan/index.ts +0 -9
  241. package/src/lib/floorplan/items.ts +5 -2
  242. package/src/lib/floorplan/marks.test.ts +213 -0
  243. package/src/lib/floorplan/marks.ts +238 -0
  244. package/src/lib/floorplan/open-wall-ends.test.ts +268 -0
  245. package/src/lib/floorplan/open-wall-ends.ts +162 -0
  246. package/src/lib/floorplan/pdf-fonts/GeistMono-Bold.ttf +0 -0
  247. package/src/lib/floorplan/pdf-fonts/GeistMono-Regular.ttf +0 -0
  248. package/src/lib/floorplan/pdf-fonts/LICENSE-GeistMono.txt +92 -0
  249. package/src/lib/floorplan/pdf-fonts/LICENSE-LiberationSans.txt +102 -0
  250. package/src/lib/floorplan/pdf-fonts/LiberationSans-Bold.ttf +0 -0
  251. package/src/lib/floorplan/pdf-fonts/LiberationSans-Regular.ttf +0 -0
  252. package/src/lib/floorplan/schedules.test.ts +189 -0
  253. package/src/lib/floorplan/schedules.ts +339 -0
  254. package/src/lib/floorplan/sheet-export.test.ts +100 -0
  255. package/src/lib/floorplan/sheet-export.ts +318 -0
  256. package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +779 -0
  257. package/src/lib/floorplan/site-plan/contours.test.ts +43 -0
  258. package/src/lib/floorplan/site-plan/contributors.ts +97 -0
  259. package/src/lib/floorplan/site-plan/coverage.ts +232 -0
  260. package/src/lib/floorplan/site-plan/drawing-type-switch.test.ts +15 -0
  261. package/src/lib/floorplan/site-plan/drawing-type-switch.tsx +86 -0
  262. package/src/lib/floorplan/site-plan/front-edge.test.ts +103 -0
  263. package/src/lib/floorplan/site-plan/front-edge.ts +156 -0
  264. package/src/lib/floorplan/site-plan/geometry.test.ts +275 -0
  265. package/src/lib/floorplan/site-plan/geometry.ts +557 -0
  266. package/src/lib/floorplan/site-plan/index.ts +71 -0
  267. package/src/lib/floorplan/site-plan/site-annotations.test.ts +366 -0
  268. package/src/lib/floorplan/site-plan/site-annotations.ts +1083 -0
  269. package/src/lib/floorplan/site-plan/site-parts.ts +379 -0
  270. package/src/lib/floorplan/site-plan/site-plan-layer.tsx +138 -0
  271. package/src/lib/floorplan/site-plan/street-name.test.ts +69 -0
  272. package/src/lib/floorplan/site-plan/street-name.ts +302 -0
  273. package/src/lib/floorplan/site-plan/yards.test.ts +69 -0
  274. package/src/lib/floorplan/types.ts +1 -27
  275. package/src/lib/fresh-planar-placement.test.ts +36 -0
  276. package/src/lib/fresh-planar-placement.ts +59 -36
  277. package/src/lib/geometry-script/author.ts +152 -0
  278. package/src/lib/geometry-script/client.ts +152 -0
  279. package/src/lib/geometry-script/protocol.ts +12 -0
  280. package/src/lib/gesture-lifecycle.test.ts +595 -0
  281. package/src/lib/gesture-lifecycle.ts +290 -0
  282. package/src/lib/glb-export-zone-holes.test.ts +41 -0
  283. package/src/lib/glb-export.test.ts +485 -0
  284. package/src/lib/glb-export.ts +237 -119
  285. package/src/lib/history.test.ts +6 -0
  286. package/src/lib/history.ts +7 -0
  287. package/src/lib/hud-title.ts +109 -0
  288. package/src/lib/hydration-gated-storage.ts +49 -0
  289. package/src/lib/ifc-export.test.ts +174 -0
  290. package/src/lib/ifc-export.ts +201 -0
  291. package/src/lib/ifc-parts.ts +15 -0
  292. package/src/lib/inspector-expanded.test.ts +44 -0
  293. package/src/lib/inspector-expanded.ts +36 -0
  294. package/src/lib/interaction/scope.ts +18 -0
  295. package/src/lib/keyboard-pan.test.ts +129 -7
  296. package/src/lib/keyboard-pan.ts +161 -12
  297. package/src/lib/live-gesture-round-trip.test.ts +360 -0
  298. package/src/lib/lot/__fixtures__/map-dossier-miami-shores.json +1 -0
  299. package/src/lib/lot/__fixtures__/map-dossier-stpete.json +1 -0
  300. package/src/lib/lot/clean-ring.test.ts +186 -0
  301. package/src/lib/lot/clean-ring.ts +217 -0
  302. package/src/lib/lot/dossier.test.ts +202 -0
  303. package/src/lib/lot/dossier.ts +498 -0
  304. package/src/lib/lot/drop-in.test.ts +326 -0
  305. package/src/lib/lot/drop-in.ts +407 -0
  306. package/src/lib/lot/index.ts +43 -0
  307. package/src/lib/lot/lot-patch.test.ts +340 -0
  308. package/src/lib/lot/lot-patch.ts +394 -0
  309. package/src/lib/lot/parcel-provider.ts +43 -0
  310. package/src/lib/lot/terrain.test.ts +161 -0
  311. package/src/lib/lot/terrain.ts +266 -0
  312. package/src/lib/material-catalog-model.ts +3 -1
  313. package/src/lib/material-paint-panel-model.ts +9 -5
  314. package/src/lib/material-paint.ts +4 -0
  315. package/src/lib/mezzanine-actions.test.ts +342 -0
  316. package/src/lib/mezzanine-draft.test.ts +501 -0
  317. package/src/lib/mezzanine-draft.ts +278 -0
  318. package/src/lib/mezzanine-messages.ts +26 -0
  319. package/src/lib/mezzanine-selection.test.ts +207 -0
  320. package/src/lib/mezzanine-stairs.ts +46 -0
  321. package/src/lib/model-export.ts +3 -1
  322. package/src/lib/node-action-movement.ts +17 -6
  323. package/src/lib/node-display-label.ts +37 -0
  324. package/src/lib/orbit-floor-clearance.test.ts +165 -0
  325. package/src/lib/orbit-floor-clearance.ts +106 -0
  326. package/src/lib/paint-commit-route.test.ts +37 -0
  327. package/src/lib/paint-commit-route.ts +17 -0
  328. package/src/lib/paint-eyedropper.test.tsx +165 -0
  329. package/src/lib/paint-eyedropper.ts +99 -0
  330. package/src/lib/paint-pass-through.test.ts +111 -0
  331. package/src/lib/paint-pass-through.ts +76 -0
  332. package/src/lib/paint-region-hover.ts +25 -0
  333. package/src/lib/paint-region-mode.ts +154 -0
  334. package/src/lib/paint-regions.test.ts +166 -0
  335. package/src/lib/paint-regions.ts +192 -0
  336. package/src/lib/paint-scope.test.ts +317 -35
  337. package/src/lib/paint-scope.ts +266 -25
  338. package/src/lib/panel-rows.ts +21 -0
  339. package/src/lib/parametric-node-update.ts +1 -1
  340. package/src/lib/plate-paint-affected.test.ts +300 -0
  341. package/src/lib/plate-paint-affected.ts +177 -0
  342. package/src/lib/plugin-chat.ts +50 -0
  343. package/src/lib/plugin-panels.test.ts +25 -0
  344. package/src/lib/plugin-panels.ts +94 -0
  345. package/src/lib/print-shell-compiler.ts +1 -0
  346. package/src/lib/registered-draft-snap.ts +12 -0
  347. package/src/lib/roof-duplication.ts +10 -2
  348. package/src/lib/room-assembly-overlay.test.ts +275 -0
  349. package/src/lib/room-assembly-overlay.ts +266 -0
  350. package/src/lib/room-built-on.test.ts +379 -0
  351. package/src/lib/room-built-on.ts +138 -0
  352. package/src/lib/room-construction-commands.ts +712 -0
  353. package/src/lib/room-construction.test.ts +746 -0
  354. package/src/lib/room-construction.ts +385 -0
  355. package/src/lib/room-controls-phase6.test.ts +630 -0
  356. package/src/lib/room-divide-session.ts +523 -0
  357. package/src/lib/room-handle-drag.ts +1068 -0
  358. package/src/lib/room-name-catalog.test.ts +90 -0
  359. package/src/lib/room-name-catalog.ts +168 -0
  360. package/src/lib/room-push-dimensions.test.ts +311 -0
  361. package/src/lib/room-push-dimensions.ts +251 -0
  362. package/src/lib/room-selection-commands.ts +62 -0
  363. package/src/lib/room-selection.test.ts +973 -0
  364. package/src/lib/room-selection.ts +417 -0
  365. package/src/lib/room-structure-commands.ts +94 -0
  366. package/src/lib/room-structure.test.ts +921 -0
  367. package/src/lib/room-transform-session.ts +570 -0
  368. package/src/lib/room-zone-routing.test.ts +294 -0
  369. package/src/lib/room-zone-routing.ts +197 -0
  370. package/src/lib/scene-clipboard.test.ts +364 -0
  371. package/src/lib/scene-clipboard.ts +229 -48
  372. package/src/lib/scene-material-refs.test.ts +79 -0
  373. package/src/lib/scene-material-refs.ts +79 -0
  374. package/src/lib/scene.test.ts +24 -36
  375. package/src/lib/scene.ts +33 -57
  376. package/src/lib/selection-routing.test.ts +129 -45
  377. package/src/lib/selection-routing.ts +53 -74
  378. package/src/lib/session-writes.test.ts +120 -0
  379. package/src/lib/session-writes.ts +205 -0
  380. package/src/lib/sidebar-panel.ts +62 -0
  381. package/src/lib/site-boundary.test.ts +4 -6
  382. package/src/lib/site-boundary.ts +1 -1
  383. package/src/lib/snapping-mode.test.ts +4 -0
  384. package/src/lib/snapping-mode.ts +8 -1
  385. package/src/lib/terrace-draft.test.ts +145 -0
  386. package/src/lib/terrace-draft.ts +213 -0
  387. package/src/lib/tool-cursor-icon.test.ts +16 -0
  388. package/src/lib/tool-cursor-icon.ts +11 -0
  389. package/src/lib/typing-target.ts +9 -0
  390. package/src/lib/units.ts +77 -39
  391. package/src/lib/vector-edges.test.ts +58 -0
  392. package/src/lib/vector-edges.ts +400 -0
  393. package/src/lib/viewer-selection.test.ts +577 -0
  394. package/src/lib/viewer-selection.ts +569 -0
  395. package/src/lib/wall-draw-variant.test.ts +71 -0
  396. package/src/lib/wall-draw-variant.ts +91 -0
  397. package/src/lib/wall-paint-region-session.ts +246 -0
  398. package/src/lib/wall-paint-region.test.ts +373 -0
  399. package/src/lib/wall-polygon-draft.test.ts +262 -0
  400. package/src/lib/wall-polygon-draft.ts +286 -0
  401. package/src/lib/wall-push.test.ts +453 -0
  402. package/src/lib/wall-region-face.ts +352 -0
  403. package/src/lib/wall-region-handles.test.ts +304 -0
  404. package/src/lib/wall-region-handles.ts +368 -0
  405. package/src/lib/wall-region-list.test.ts +119 -0
  406. package/src/lib/wall-region-list.ts +136 -0
  407. package/src/lib/wall-region-snap.ts +110 -0
  408. package/src/lib/window-interaction.ts +14 -0
  409. package/src/lib/zone-content.ts +4 -1
  410. package/src/lib/zone-name.test.ts +17 -0
  411. package/src/lib/zone-name.ts +17 -0
  412. package/src/store/opens-in-select-mode.test.ts +223 -0
  413. package/src/store/paint-mode.test.ts +188 -0
  414. package/src/store/terrain-sculpt-mode.test.ts +19 -21
  415. package/src/store/tool-mode.test.ts +137 -6
  416. package/src/store/use-delete-confirmation.ts +28 -1
  417. package/src/store/use-drawing-view.test.ts +24 -3
  418. package/src/store/use-drawing-view.ts +29 -1
  419. package/src/store/use-editor.tsx +432 -321
  420. package/src/store/use-floorplan-draft-preview.ts +13 -0
  421. package/src/store/use-floorplan-marquee.ts +12 -2
  422. package/src/store/use-quick-measurement-hud.ts +3 -3
  423. package/src/store/use-stair-build-preview.ts +13 -5
  424. package/src/store/use-wall-move-ghosts.ts +4 -2
  425. package/src/store/use-wall-region-handles-selection.test.ts +222 -0
  426. package/src/store/use-wall-region-handles-selection.ts +139 -0
  427. package/src/store/view-layout.test.ts +175 -0
  428. package/src/store/view-layout.ts +196 -0
  429. package/src/test-utils/immediate-animation-frames.ts +16 -0
  430. package/src/test-utils/live-gesture-round-trip.ts +224 -0
  431. package/src/test-utils/selection-harness.ts +82 -0
  432. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +0 -499
  433. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -333
  434. package/src/components/tools/fence/fence-drafting.test.ts +0 -100
  435. package/src/components/tools/fence/fence-drafting.ts +0 -266
  436. package/src/components/ui/sidebar/panels/plugins-panel.tsx +0 -218
  437. package/src/components/viewer/viewer-units-panel.tsx +0 -116
  438. package/src/lib/floorplan/stairs.ts +0 -489
  439. package/src/lib/level-duplication.test.ts +0 -144
  440. package/src/lib/level-duplication.ts +0 -195
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Lot terrain from USGS elevations: an N×N grid of points over the lot's
3
+ * bounding box (padded a touch so the ground runs past the lines) goes to
4
+ * the parcel provider's `elevation` (USGS EPQS, feet), the readings come
5
+ * back relative to a DATUM — the ground at the lot's centre, so the site
6
+ * plane y = 0 is the ground where the house will stand — and are written
7
+ * into the site's heightfield (`site.terrain`, the same field the sculpt
8
+ * tool edits and every placement / raycast / drape reads).
9
+ *
10
+ * Honesty: a web-service DEM is preliminary — the sample record on the
11
+ * site (`metadata.terrainSample`: source, grid, holes, datum, relief) says
12
+ * what was read; a lot flatter than `MIN_RELIEF_M` writes NO terrain and
13
+ * keeps the flat-ground fast path; every failure returns a reason and
14
+ * writes nothing.
15
+ */
16
+ import {
17
+ commitTerrainField,
18
+ createTerrainField,
19
+ quantize,
20
+ type TerrainData,
21
+ type TerrainField,
22
+ } from '@pascal-app/core'
23
+ import { getParcelProvider, NO_PARCEL_SERVICE, type ParcelProvider } from './parcel-provider'
24
+
25
+ export type Pt = readonly [number, number]
26
+
27
+ /** apps/editor/lib/parcel/project.ts — the plan frame's degree scale. */
28
+ export const FEET_PER_DEG_LAT = 364000
29
+ const METRES_PER_FOOT = 0.3048
30
+ const DEG2RAD = Math.PI / 180
31
+ /** Grid points per side (81 points — well under the route's 256 cap). */
32
+ export const DEFAULT_GRID_N = 9
33
+ /** The lot bbox is padded by this fraction so the mesh runs past the lines. */
34
+ export const PAD_FRAC = 0.08
35
+ /** A lot with less fall than this across its samples is flat: no terrain written. */
36
+ export const MIN_RELIEF_M = 0.15
37
+ /** Heightfield resolution: the bbox split into ~64 cells per side, clamped. */
38
+ const FIELD_CELLS = 64
39
+ const MIN_SPACING_M = 0.5
40
+ const MAX_SPACING_M = 4
41
+
42
+ export interface TerrainSampleSummary {
43
+ source: 'USGS EPQS'
44
+ /** Grid side (n × n points). */
45
+ grid: number
46
+ /** Points with a reading / without one. */
47
+ sampled: number
48
+ holes: number
49
+ /** Absolute elevation of the site plane (the datum), feet. */
50
+ datumFt: number
51
+ /** Highest − lowest reading, feet. */
52
+ reliefFt: number
53
+ /** True when the lot read flat and no heightfield was written. */
54
+ flat: boolean
55
+ at: string
56
+ }
57
+
58
+ export interface TerrainSampleResult {
59
+ ok: boolean
60
+ reason?: string
61
+ /** The heightfield to write to `site.terrain` (absent when flat). */
62
+ terrain?: TerrainData
63
+ summary?: TerrainSampleSummary
64
+ }
65
+
66
+ export interface SampleGrid {
67
+ n: number
68
+ x0: number
69
+ z0: number
70
+ x1: number
71
+ z1: number
72
+ /** Row-major, `r * n + c`. */
73
+ points: Pt[]
74
+ }
75
+
76
+ /** Local plan metres (the site frame) → `{ lat, lng }`, the inverse of `ringsToPlanFeet`. */
77
+ export function localMetresToLngLat(
78
+ p: Pt,
79
+ originLngLat: readonly [number, number],
80
+ ): { lat: number; lng: number } {
81
+ const [oLng, oLat] = originLngLat
82
+ const ftPerDegLng = FEET_PER_DEG_LAT * Math.cos(oLat * DEG2RAD)
83
+ const xFt = p[0] / METRES_PER_FOOT
84
+ const zFt = p[1] / METRES_PER_FOOT
85
+ return { lng: oLng + xFt / ftPerDegLng, lat: oLat - zFt / FEET_PER_DEG_LAT }
86
+ }
87
+
88
+ /** The n × n sample grid over the ring's padded bounding box. */
89
+ export function gridOver(ring: readonly Pt[], n = DEFAULT_GRID_N, pad = PAD_FRAC): SampleGrid {
90
+ let minX = Number.POSITIVE_INFINITY
91
+ let minZ = Number.POSITIVE_INFINITY
92
+ let maxX = Number.NEGATIVE_INFINITY
93
+ let maxZ = Number.NEGATIVE_INFINITY
94
+ for (const p of ring) {
95
+ minX = Math.min(minX, p[0])
96
+ maxX = Math.max(maxX, p[0])
97
+ minZ = Math.min(minZ, p[1])
98
+ maxZ = Math.max(maxZ, p[1])
99
+ }
100
+ const padX = (maxX - minX) * pad
101
+ const padZ = (maxZ - minZ) * pad
102
+ const x0 = minX - padX
103
+ const x1 = maxX + padX
104
+ const z0 = minZ - padZ
105
+ const z1 = maxZ + padZ
106
+ const side = Math.max(2, Math.floor(n))
107
+ const points: Pt[] = []
108
+ for (let r = 0; r < side; r++) {
109
+ const z = z0 + ((z1 - z0) * r) / (side - 1)
110
+ for (let c = 0; c < side; c++) points.push([x0 + ((x1 - x0) * c) / (side - 1), z])
111
+ }
112
+ return { n: side, x0, z0, x1, z1, points }
113
+ }
114
+
115
+ /**
116
+ * Bilinear read of the coarse grid at a plan point; holes (null readings)
117
+ * read as `fill`. Clamped to the grid — the field never extrapolates.
118
+ */
119
+ export function coarseHeightAt(
120
+ grid: SampleGrid,
121
+ elev: readonly (number | null)[],
122
+ x: number,
123
+ z: number,
124
+ fill: number,
125
+ ): number {
126
+ const n = grid.n
127
+ const fx = grid.x1 === grid.x0 ? 0 : ((x - grid.x0) / (grid.x1 - grid.x0)) * (n - 1)
128
+ const fz = grid.z1 === grid.z0 ? 0 : ((z - grid.z0) / (grid.z1 - grid.z0)) * (n - 1)
129
+ const gx = Math.max(0, Math.min(n - 1, fx))
130
+ const gz = Math.max(0, Math.min(n - 1, fz))
131
+ const c0 = Math.min(n - 2, Math.floor(gx))
132
+ const r0 = Math.min(n - 2, Math.floor(gz))
133
+ const tx = gx - c0
134
+ const tz = gz - r0
135
+ const at = (r: number, c: number): number => {
136
+ const v = elev[r * n + c]
137
+ return v === null || v === undefined || !Number.isFinite(v) ? fill : v
138
+ }
139
+ const top = at(r0, c0) * (1 - tx) + at(r0, c0 + 1) * tx
140
+ const bottom = at(r0 + 1, c0) * (1 - tx) + at(r0 + 1, c0 + 1) * tx
141
+ return top * (1 - tz) + bottom * tz
142
+ }
143
+
144
+ /** A heightfield over the grid's extent, metres above the datum, bilinear from the coarse samples. */
145
+ export function fieldFromSamples(
146
+ grid: SampleGrid,
147
+ elevM: readonly (number | null)[],
148
+ datumM: number,
149
+ fillM: number,
150
+ ): TerrainField {
151
+ const spanX = grid.x1 - grid.x0
152
+ const spanZ = grid.z1 - grid.z0
153
+ const spacing = Math.min(
154
+ MAX_SPACING_M,
155
+ Math.max(MIN_SPACING_M, Math.max(spanX, spanZ) / FIELD_CELLS),
156
+ )
157
+ const cols = Math.max(2, Math.ceil(spanX / spacing) + 1)
158
+ const rows = Math.max(2, Math.ceil(spanZ / spacing) + 1)
159
+ const field = createTerrainField({ origin: [grid.x0, grid.z0], spacing, cols, rows })
160
+ for (let r = 0; r < rows; r++) {
161
+ for (let c = 0; c < cols; c++) {
162
+ const x = grid.x0 + c * spacing
163
+ const z = grid.z0 + r * spacing
164
+ field.heights[r * cols + c] = quantize(
165
+ field,
166
+ coarseHeightAt(grid, elevM, x, z, fillM) - datumM,
167
+ )
168
+ }
169
+ }
170
+ return field
171
+ }
172
+
173
+ type ElevationResponse = {
174
+ ok?: boolean
175
+ error?: string
176
+ results?: { lat?: number; lng?: number; elevation?: number | null }[]
177
+ }
178
+
179
+ export interface SampleOptions {
180
+ /** Default = the provider the host set. */
181
+ provider?: ParcelProvider
182
+ /** Grid side, default 9. */
183
+ n?: number
184
+ /** Passed to the route (USGS is slow; the route caps it). */
185
+ deadlineMs?: number
186
+ /** Where the datum is read (site metres); default = the ring's centroid. */
187
+ datumAt?: Pt
188
+ now?: () => string
189
+ }
190
+
191
+ /**
192
+ * Sample the lot. Resolves `{ ok: false, reason }` on every failure path and
193
+ * never throws; `ok: true` with no `terrain` means the lot read flat.
194
+ */
195
+ export async function sampleLotTerrain(
196
+ ring: readonly Pt[],
197
+ originLngLat: readonly [number, number],
198
+ options: SampleOptions = {},
199
+ ): Promise<TerrainSampleResult> {
200
+ if (ring.length < 3) return { ok: false, reason: 'no lot ring' }
201
+ const provider = options.provider ?? getParcelProvider()
202
+ if (!provider) return { ok: false, reason: NO_PARCEL_SERVICE }
203
+ const grid = gridOver(ring, options.n ?? DEFAULT_GRID_N)
204
+ const points = grid.points.map((p) => localMetresToLngLat(p, originLngLat))
205
+ let body: ElevationResponse
206
+ try {
207
+ body = (await provider('elevation', {
208
+ points,
209
+ deadlineMs: options.deadlineMs ?? 12000,
210
+ })) as ElevationResponse
211
+ } catch (error) {
212
+ return { ok: false, reason: error instanceof Error ? error.message : 'elevation lookup failed' }
213
+ }
214
+ if (!body.ok || !Array.isArray(body.results))
215
+ return { ok: false, reason: body.error ?? 'no elevations' }
216
+ const elevFt: (number | null)[] = grid.points.map((_, i) => {
217
+ const e = body.results?.[i]?.elevation
218
+ return typeof e === 'number' && Number.isFinite(e) ? e : null
219
+ })
220
+ const valid = elevFt.filter((e): e is number => e !== null)
221
+ if (valid.length < 4)
222
+ return { ok: false, reason: `too few elevations (${valid.length} of ${elevFt.length})` }
223
+ const mean = valid.reduce((s, v) => s + v, 0) / valid.length
224
+ const datumAt = options.datumAt ?? centroidOf(ring)
225
+ const datumFt = coarseHeightAt(grid, elevFt, datumAt[0], datumAt[1], mean)
226
+ const reliefFt = Math.max(...valid) - Math.min(...valid)
227
+ const summary: TerrainSampleSummary = {
228
+ source: 'USGS EPQS',
229
+ grid: grid.n,
230
+ sampled: valid.length,
231
+ holes: elevFt.length - valid.length,
232
+ datumFt,
233
+ reliefFt,
234
+ flat: reliefFt * METRES_PER_FOOT < MIN_RELIEF_M,
235
+ at: (options.now ?? (() => new Date().toISOString()))(),
236
+ }
237
+ if (summary.flat) return { ok: true, summary }
238
+ const elevM = elevFt.map((e) => (e === null ? null : e * METRES_PER_FOOT))
239
+ const field = fieldFromSamples(grid, elevM, datumFt * METRES_PER_FOOT, mean * METRES_PER_FOOT)
240
+ return { ok: true, terrain: commitTerrainField(field), summary }
241
+ }
242
+
243
+ function centroidOf(ring: readonly Pt[]): Pt {
244
+ let x = 0
245
+ let z = 0
246
+ for (const p of ring) {
247
+ x += p[0]
248
+ z += p[1]
249
+ }
250
+ return [x / ring.length, z / ring.length]
251
+ }
252
+
253
+ /** One status fragment for the drop-in message. */
254
+ export function describeTerrainSample(
255
+ summary: TerrainSampleSummary | null,
256
+ failure: string,
257
+ ): string {
258
+ if (summary) {
259
+ if (summary.flat) return `ground flat within ${Math.round(summary.reliefFt * 12)}" (USGS)`
260
+ const holes = summary.holes > 0 ? `, ${summary.holes} unread` : ''
261
+ return `terrain: ${summary.reliefFt.toFixed(1)}' of fall across the lot (USGS, ${summary.sampled} pts${holes})`
262
+ }
263
+ if (failure && failure !== 'skipped')
264
+ return `terrain read failed (${failure}) — the ground is flat until it is read`
265
+ return ''
266
+ }
@@ -25,6 +25,8 @@ export function useMaterialCatalogModel(
25
25
  selectedMaterialPreset?: string,
26
26
  onSelectMaterialPreset?: (ref: string) => void,
27
27
  disabled = false,
28
+ /** Paint mode arms the tab's first material; a finish row only writes on a tile click. */
29
+ selectOnCategoryChange = true,
28
30
  ) {
29
31
  const [selectedCategory, setCategory] = useState<(typeof MATERIAL_CATEGORIES)[number]>(
30
32
  MATERIAL_CATEGORIES[0],
@@ -57,7 +59,7 @@ export function useMaterialCatalogModel(
57
59
  const setSelectedCategory = (category: typeof selectedCategory) => {
58
60
  setCategory(category)
59
61
  const first = itemsFor(category)[0]
60
- if (first) select(first.id)
62
+ if (first && selectOnCategoryChange) select(first.id)
61
63
  }
62
64
  return {
63
65
  selectedCategory,
@@ -10,14 +10,13 @@ import { useViewer } from '@pascal-app/viewer'
10
10
  import { useEffect } from 'react'
11
11
  import useEditor from '../store/use-editor'
12
12
  import { buildResetSurfaceMaterialUpdates, resolvePaintTargetFromSelection } from './material-paint'
13
+ import { usePaintRegionMode } from './paint-region-mode'
13
14
 
14
15
  export function useMaterialPaintPanelModel(enabled = true) {
15
16
  const activePaintMaterial = useEditor((state) => state.activePaintMaterial)
16
17
  const activePaintTarget = useEditor((state) => state.activePaintTarget)
17
18
  const setActivePaintMaterial = useEditor((state) => state.armMaterialPaint)
18
19
  const setActivePaintTarget = useEditor((state) => state.setActivePaintTarget)
19
- const paintEraser = useEditor((state) => state.paintEraser)
20
- const setPaintEraser = useEditor((state) => state.setPaintEraser)
21
20
  const selectedId = useViewer((state) =>
22
21
  state.selection.selectedIds.length === 1 ? (state.selection.selectedIds[0] ?? null) : null,
23
22
  )
@@ -25,15 +24,20 @@ export function useMaterialPaintPanelModel(enabled = true) {
25
24
  resolvePaintTargetFromSelection({ nodes: state.nodes, selectedId }),
26
25
  )
27
26
  const materials = useScene((state) => state.materials)
27
+ const paintEraser = usePaintRegionMode((state) => state.mode === 'erase')
28
+ const setPaintEraser = (on: boolean) => {
29
+ const paintMode = usePaintRegionMode.getState()
30
+ paintMode.setMode(on ? 'erase' : paintMode.drawMode)
31
+ }
28
32
  const canResetSelection = paintTarget !== null
29
- useEffect(() => {
30
- if (enabled && paintTarget) setActivePaintTarget(paintTarget)
31
- }, [enabled, paintTarget, setActivePaintTarget])
32
33
  const resetSelection = () => {
33
34
  const scene = useScene.getState()
34
35
  const node = selectedId ? scene.nodes[selectedId as AnyNodeId] : undefined
35
36
  if (node) scene.updateNodes(buildResetSurfaceMaterialUpdates(scene.nodes, node))
36
37
  }
38
+ useEffect(() => {
39
+ if (enabled && paintTarget) setActivePaintTarget(paintTarget)
40
+ }, [enabled, paintTarget, setActivePaintTarget])
37
41
  const createCustomMaterial = () => {
38
42
  const scene = useScene.getState()
39
43
  const id = generateSceneMaterialId()
@@ -21,6 +21,8 @@ import {
21
21
  type RoofSegmentNode,
22
22
  type RoofSegmentSurfaceMaterialRole,
23
23
  type RoofSurfaceMaterialRole,
24
+ type SceneMaterial,
25
+ type SceneMaterialId,
24
26
  type ShelfNode,
25
27
  type SlabNode,
26
28
  type StairNode,
@@ -243,6 +245,7 @@ export function getEffectiveChimneyMaterial(
243
245
  }
244
246
 
245
247
  export function resolveActivePaintMaterialFromSelection(params: {
248
+ materials?: Record<SceneMaterialId, SceneMaterial>
246
249
  nodes: Record<string, any>
247
250
  selectedId: string | null
248
251
  selectedMaterialTarget: {
@@ -274,6 +277,7 @@ export function resolveActivePaintMaterialFromSelection(params: {
274
277
  node: selectedNode,
275
278
  role: selectedMaterialTarget.role as string,
276
279
  nodes,
280
+ materials: params.materials,
277
281
  })
278
282
  if (surface) {
279
283
  const sourceTarget = (paintCap.materialTarget ?? selectedNode.type) as PaintableMaterialTarget
@@ -0,0 +1,342 @@
1
+ import { afterAll, afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+ import {
3
+ type AnyNode,
4
+ type AnyNodeId,
5
+ BuildingNode,
6
+ clearSceneHistory,
7
+ createMezzanine,
8
+ createZone,
9
+ generateId,
10
+ initSpaceDetectionSync,
11
+ LevelNode,
12
+ type Point,
13
+ useScene,
14
+ type ZoneNode,
15
+ } from '@pascal-app/core'
16
+ import { useViewer } from '@pascal-app/viewer'
17
+ import { roomTransformSource } from '../components/editor/room-controls'
18
+ import { runHistoryShortcut } from '../hooks/use-keyboard'
19
+ import { getRoomSelectionIndex } from '../hooks/use-selected-room'
20
+ import useEditor from '../store/use-editor'
21
+ import useInteractionScope from '../store/use-interaction-scope'
22
+ import { cancelGestures, hasLiveGestures } from './gesture-lifecycle'
23
+ import {
24
+ MEZZANINE_NO_STAIR_MESSAGE,
25
+ MEZZANINE_OUTSIDE_MESSAGE,
26
+ MEZZANINE_OVERLAP_MESSAGE,
27
+ } from './mezzanine-messages'
28
+ import { addMezzanineStairs } from './mezzanine-stairs'
29
+ import {
30
+ mezzanineEdgeHandles,
31
+ planMezzanineEdge,
32
+ runMezzanineEdgeDrag,
33
+ useRoomHandleDrag,
34
+ } from './room-handle-drag'
35
+ import { applyRoomPlan } from './room-structure-commands'
36
+ import {
37
+ cancelRoomTransform,
38
+ commitRoomTransform,
39
+ previewRoomTransform,
40
+ rotateRoom,
41
+ startRoomTransform,
42
+ useRoomTransform,
43
+ } from './room-transform-session'
44
+
45
+ // A mezzanine picked up, turned, copied, pushed at an edge and given stairs:
46
+ // core refuses what leaves its host or overlaps another mezzanine, and every
47
+ // accepted action is one undo step that keeps the host link.
48
+
49
+ const LEVEL = 'level_mezzanine_actions'
50
+ const saved = {
51
+ window: globalThis.window,
52
+ document: globalThis.document,
53
+ raf: globalThis.requestAnimationFrame,
54
+ caf: globalThis.cancelAnimationFrame,
55
+ }
56
+ let hostId = ''
57
+ let mezzanineId = ''
58
+ let stop = () => {}
59
+
60
+ const nodes = () => useScene.getState().nodes as Record<string, AnyNode>
61
+ const zone = (id: string) => nodes()[id as AnyNodeId] as ZoneNode
62
+ const mezzanines = () =>
63
+ Object.values(nodes()).filter(
64
+ (node): node is ZoneNode => node.type === 'zone' && node.floor?.support === 'open',
65
+ )
66
+ const history = () => useScene.temporal.getState().pastStates.length
67
+ const bounds = (polygon: readonly Point[]) => {
68
+ const xs = polygon.map(([x]) => x)
69
+ const zs = polygon.map(([, z]) => z)
70
+ return [Math.min(...xs), Math.min(...zs), Math.max(...xs), Math.max(...zs)]
71
+ }
72
+
73
+ function addMezzanine(polygon: Point[]) {
74
+ const plan = createMezzanine(nodes(), { hostZoneId: hostId, polygon, mintId: generateId })
75
+ expect(plan.conflicts ?? []).toEqual([])
76
+ applyRoomPlan(plan)
77
+ return plan.zoneId
78
+ }
79
+
80
+ function mezzanineRecord(id = mezzanineId) {
81
+ return getRoomSelectionIndex(LEVEL)
82
+ .update(nodes())
83
+ .find((room) => room.zoneId === id)!
84
+ }
85
+
86
+ beforeEach(() => {
87
+ cancelGestures('cancel')
88
+ globalThis.window = new EventTarget() as Window & typeof globalThis
89
+ globalThis.document = { body: { style: { cursor: '' } } } as unknown as Document
90
+ globalThis.requestAnimationFrame = ((callback: (time: number) => void) => {
91
+ callback(0)
92
+ return 0
93
+ }) as typeof requestAnimationFrame
94
+ globalThis.cancelAnimationFrame = (() => {}) as typeof cancelAnimationFrame
95
+ useInteractionScope.getState().end()
96
+ const building = BuildingNode.parse({ id: 'building_mezzanine_actions', children: [LEVEL] })
97
+ const level = LevelNode.parse({ id: LEVEL, parentId: building.id, height: 5 })
98
+ useScene.setState({
99
+ nodes: { [building.id]: building, [level.id]: level },
100
+ rootNodeIds: [building.id],
101
+ dirtyNodes: new Set(),
102
+ materials: {},
103
+ collections: {},
104
+ readOnly: false,
105
+ })
106
+ useScene.temporal.getState().resume()
107
+ stop = initSpaceDetectionSync(useScene, { getState: () => ({ spaces: {}, setSpaces: () => {} }) })
108
+ const host = createZone(nodes(), {
109
+ levelId: LEVEL,
110
+ polygon: [
111
+ [0, 0],
112
+ [8, 0],
113
+ [8, 6],
114
+ [0, 6],
115
+ ],
116
+ enclose: true,
117
+ mintId: generateId,
118
+ })
119
+ applyRoomPlan(host)
120
+ hostId = host.zoneId
121
+ mezzanineId = addMezzanine([
122
+ [1, 1],
123
+ [4, 1],
124
+ [4, 3],
125
+ [1, 3],
126
+ ])
127
+ useViewer.getState().setSelection({ buildingId: building.id, levelId: LEVEL, selectedIds: [] })
128
+ useEditor.setState({
129
+ phase: 'building',
130
+ mode: 'select',
131
+ room: { levelId: LEVEL, zoneId: mezzanineId },
132
+ gridSnapStep: 0.5,
133
+ })
134
+ clearSceneHistory()
135
+ })
136
+
137
+ afterEach(() => {
138
+ cancelRoomTransform()
139
+ cancelGestures('cancel')
140
+ stop()
141
+ })
142
+
143
+ afterAll(async () => {
144
+ await new Promise((resolve) => setTimeout(resolve, 350))
145
+ globalThis.window = saved.window
146
+ globalThis.document = saved.document
147
+ globalThis.requestAnimationFrame = saved.raf
148
+ globalThis.cancelAnimationFrame = saved.caf
149
+ })
150
+
151
+ describe('moving a mezzanine', () => {
152
+ test('inside its host it commits in one step, keeping the host link', () => {
153
+ expect(startRoomTransform('move', roomTransformSource(mezzanineRecord()))).toBe(true)
154
+ previewRoomTransform([2, 1], 0)
155
+ expect(useRoomTransform.getState().session).toMatchObject({ valid: true })
156
+ expect(commitRoomTransform()).toBe(true)
157
+ expect(bounds(zone(mezzanineId).polygon)).toEqual([3, 2, 6, 4])
158
+ expect(zone(mezzanineId).hostZoneId).toBe(hostId)
159
+ expect(history()).toBe(1)
160
+ })
161
+
162
+ test('outside the host or onto another mezzanine it turns red with the mezzanine label', () => {
163
+ addMezzanine([
164
+ [5, 3.5],
165
+ [7.5, 3.5],
166
+ [7.5, 5.5],
167
+ [5, 5.5],
168
+ ])
169
+ clearSceneHistory()
170
+ startRoomTransform('move', roomTransformSource(mezzanineRecord()))
171
+ previewRoomTransform([6, 0], 0)
172
+ expect(useRoomTransform.getState().session).toMatchObject({
173
+ valid: false,
174
+ message: MEZZANINE_OUTSIDE_MESSAGE,
175
+ })
176
+ expect(commitRoomTransform()).toBe(false)
177
+ previewRoomTransform([1.5, 1], 0)
178
+ expect(useRoomTransform.getState().session).toMatchObject({
179
+ valid: false,
180
+ message: MEZZANINE_OVERLAP_MESSAGE,
181
+ })
182
+ expect(commitRoomTransform()).toBe(false)
183
+ expect(history()).toBe(0)
184
+ })
185
+ })
186
+
187
+ describe('turning and copying a mezzanine', () => {
188
+ test('rotate left turns it about its centre in one step; a turn that leaves the room says why', () => {
189
+ expect(rotateRoom({ levelId: LEVEL, zoneId: mezzanineId }, 'left').ok).toBe(true)
190
+ const [minX, minZ, maxX, maxZ] = bounds(zone(mezzanineId).polygon)
191
+ expect(maxX! - minX!).toBeCloseTo(2)
192
+ expect(maxZ! - minZ!).toBeCloseTo(3)
193
+ expect(history()).toBe(1)
194
+ // A long thin mezzanine along the room cannot turn across it.
195
+ const long = addMezzanine([
196
+ [0.5, 4.2],
197
+ [7.5, 4.2],
198
+ [7.5, 5.5],
199
+ [0.5, 5.5],
200
+ ])
201
+ clearSceneHistory()
202
+ const refused = rotateRoom({ levelId: LEVEL, zoneId: long }, 'right')
203
+ expect(refused).toMatchObject({ ok: false })
204
+ expect(useRoomTransform.getState().notice?.zoneId).toBe(long)
205
+ expect(history()).toBe(0)
206
+ })
207
+
208
+ test('duplicate places a copy hosted by the same room in one step', () => {
209
+ startRoomTransform('duplicate', roomTransformSource(mezzanineRecord()))
210
+ previewRoomTransform([0, 0], 0)
211
+ expect(useRoomTransform.getState().session).toMatchObject({
212
+ valid: false,
213
+ message: MEZZANINE_OVERLAP_MESSAGE,
214
+ })
215
+ previewRoomTransform([3.5, 0], 0)
216
+ expect(useRoomTransform.getState().session).toMatchObject({ valid: true })
217
+ expect(commitRoomTransform()).toBe(true)
218
+ expect(mezzanines()).toHaveLength(2)
219
+ expect(mezzanines().every((node) => node.hostZoneId === hostId)).toBe(true)
220
+ expect(history()).toBe(1)
221
+ })
222
+ })
223
+
224
+ describe('pushing a mezzanine edge', () => {
225
+ test('one arrow per edge, pointing out of the mezzanine', () => {
226
+ const handles = mezzanineEdgeHandles(nodes(), mezzanineId)
227
+ expect(handles).toHaveLength(4)
228
+ for (const handle of handles) {
229
+ const plan = planMezzanineEdge(nodes(), mezzanineId, handle.edgeIndex, 0.4)
230
+ expect(plan.conflicts ?? []).toEqual([])
231
+ const update = plan.changes.find((change) => change.op === 'update')
232
+ const polygon = (update as { data: { polygon: Point[] } }).data.polygon
233
+ // Pushing outward grows the plate.
234
+ const [a, b, c, d] = bounds(polygon)
235
+ expect((c! - a!) * (d! - b!)).toBeGreaterThan(3 * 2)
236
+ }
237
+ })
238
+
239
+ test('a drag previews the outline, refuses outside the room and commits in one step', () => {
240
+ const east = mezzanineEdgeHandles(nodes(), mezzanineId).find(
241
+ (handle) => handle.outward[0] > 0.9,
242
+ )!
243
+ let at = 0
244
+ runMezzanineEdgeDrag({
245
+ handle: east,
246
+ zoneId: mezzanineId,
247
+ levelId: LEVEL,
248
+ from: 0,
249
+ along: () => at,
250
+ })
251
+ const move = (value: number) => {
252
+ at = value
253
+ window.dispatchEvent(Object.assign(new Event('pointermove'), { clientX: 1, clientY: 1 }))
254
+ }
255
+ move(10)
256
+ expect(useRoomHandleDrag.getState().drag).toMatchObject({
257
+ kind: 'mezzanine-edge',
258
+ message: MEZZANINE_OUTSIDE_MESSAGE,
259
+ })
260
+ move(1)
261
+ const drag = useRoomHandleDrag.getState().drag as { message?: string; outline: Point[] }
262
+ expect(drag.message).toBeUndefined()
263
+ expect(bounds(drag.outline)[2]).toBeCloseTo(5)
264
+ // Nothing is written until release.
265
+ expect(bounds(zone(mezzanineId).polygon)[2]).toBeCloseTo(4)
266
+ window.dispatchEvent(Object.assign(new Event('pointerup'), { clientX: 1, clientY: 1 }))
267
+ expect(bounds(zone(mezzanineId).polygon)[2]).toBeCloseTo(5)
268
+ expect(history()).toBe(1)
269
+ expect(hasLiveGestures()).toBe(false)
270
+ })
271
+
272
+ test('a refused release writes nothing', () => {
273
+ const east = mezzanineEdgeHandles(nodes(), mezzanineId).find(
274
+ (handle) => handle.outward[0] > 0.9,
275
+ )!
276
+ let at = 0
277
+ runMezzanineEdgeDrag({
278
+ handle: east,
279
+ zoneId: mezzanineId,
280
+ levelId: LEVEL,
281
+ from: 0,
282
+ along: () => at,
283
+ })
284
+ at = 10
285
+ window.dispatchEvent(Object.assign(new Event('pointermove'), { clientX: 1, clientY: 1 }))
286
+ window.dispatchEvent(Object.assign(new Event('pointerup'), { clientX: 1, clientY: 1 }))
287
+ expect(bounds(zone(mezzanineId).polygon)[2]).toBeCloseTo(4)
288
+ expect(history()).toBe(0)
289
+ })
290
+ })
291
+
292
+ describe('adding stairs', () => {
293
+ test('places one stair up to the mezzanine and selects it, in one step', () => {
294
+ const result = addMezzanineStairs(mezzanineId)
295
+ expect(result.ok).toBe(true)
296
+ const stairId = (result as { stairId: string }).stairId
297
+ expect(nodes()[stairId as AnyNodeId]?.type).toBe('stair')
298
+ expect(useViewer.getState().selection.selectedIds).toEqual([stairId])
299
+ expect(useEditor.getState().room).toBeNull()
300
+ expect(history()).toBe(1)
301
+ // The railing opens where the flight arrives, in the same step.
302
+ const deck = Object.values(nodes()).find(
303
+ (node) => node.type === 'slab' && node.support === 'open',
304
+ ) as { railing?: unknown[] }
305
+ expect(deck.railing).toHaveLength(5)
306
+ // Core emits the same numeric values that shared history records through JSON.
307
+ const hasNegativeZero = (value: unknown): boolean =>
308
+ Object.is(value, -0) ||
309
+ (Array.isArray(value)
310
+ ? value.some(hasNegativeZero)
311
+ : !!value && typeof value === 'object' && Object.values(value).some(hasNegativeZero))
312
+ const stair = nodes()[stairId as AnyNodeId]!
313
+ expect(hasNegativeZero(stair)).toBe(false)
314
+ expect(
315
+ Object.values(nodes()).some((node) => node.parentId === stairId && hasNegativeZero(node)),
316
+ ).toBe(false)
317
+ // One ⌘Z takes it away again.
318
+ expect(runHistoryShortcut('undo')).toBe(true)
319
+ expect(nodes()[stairId as AnyNodeId]).toBeUndefined()
320
+ expect(Object.values(nodes()).some((node) => node.type === 'stair')).toBe(false)
321
+ })
322
+
323
+ test('says "No room for stairs" when no edge has free floor for a flight', () => {
324
+ // The mezzanine fills the room: no host floor is left for a flight.
325
+ useScene.getState().updateNode(
326
+ mezzanineId as AnyNodeId,
327
+ {
328
+ polygon: [
329
+ [0.2, 0.2],
330
+ [7.8, 0.2],
331
+ [7.8, 5.8],
332
+ [0.2, 5.8],
333
+ ],
334
+ } as never,
335
+ )
336
+ clearSceneHistory()
337
+ const result = addMezzanineStairs(mezzanineId)
338
+ expect(result).toEqual({ ok: false, message: MEZZANINE_NO_STAIR_MESSAGE })
339
+ expect(Object.values(nodes()).some((node) => node.type === 'stair')).toBe(false)
340
+ expect(history()).toBe(0)
341
+ })
342
+ })