@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,213 @@
1
+ import {
2
+ type AnyNode,
3
+ type AnyNodeId,
4
+ area,
5
+ createZone,
6
+ generateId,
7
+ type MultiPolygon,
8
+ type StructurePlan,
9
+ type ToolHint,
10
+ useScene,
11
+ } from '@pascal-app/core'
12
+ import { useViewer } from '@pascal-app/viewer'
13
+ import { create } from 'zustand'
14
+ import useEditor from '../store/use-editor'
15
+ import { CROSSING_MESSAGE, floorRegionSelfIntersects } from './floor-region-geometry'
16
+ import {
17
+ cancelFloorRegion,
18
+ type FloorDraftCommit,
19
+ type FloorDraftPurpose,
20
+ type FloorRegionRoom,
21
+ useFloorRegionDraft,
22
+ } from './floor-region-session'
23
+ import { type FloorRegionPoint, floorRegionSnapTargets } from './floor-region-snap'
24
+ import { beginGesture, type GestureHandle } from './gesture-lifecycle'
25
+ import { applyRoomPlan } from './room-structure-commands'
26
+ import { sfxEmitter } from './sfx-bus'
27
+
28
+ // "Terrace" (Build panel → Outdoor): draws an outdoor room on the level's
29
+ // ground, rectangle or polygon, snapped to the rooms already there and the
30
+ // grid. The terrace is a room like any other — bounded by separators (and by
31
+ // the walls it shares), no walls of its own and no ceiling — so it gets
32
+ // everything rooms have: finish paint, height arrows, Divide, Move, Delete.
33
+ // The tool is one lifecycle gesture from `startTerraceDraft` until cancelled;
34
+ // each outline commits as one undo step and the tool stays armed, like the
35
+ // room tools.
36
+
37
+ export const TERRACE_DRAFT_HANDLE = 'terrace-draft'
38
+ export const TERRACE_NAME = 'Terrace'
39
+ export const TERRACE_TOO_SMALL_MESSAGE = 'Too small for a terrace.'
40
+ export const TERRACE_FAILED_MESSAGE = "Can't place a terrace here."
41
+
42
+ export type TerraceShape = 'rectangle' | 'polygon'
43
+
44
+ export const useTerraceDraft = create<{
45
+ host: FloorRegionRoom | null
46
+ shape: TerraceShape
47
+ }>(() => ({ host: null, shape: 'rectangle' }))
48
+
49
+ /** How far the terrace tool reaches from the origin when the site has no outline. */
50
+ const OPEN_GROUND = 500
51
+
52
+ /**
53
+ * Why a terrace outline cannot be built, or its plan: an outdoor room of the
54
+ * outline, closed with separators where no wall runs, without a ceiling.
55
+ * Core refuses an outline over a room (the same rule MCP's outdoor
56
+ * `create_room` follows) and names the room.
57
+ */
58
+ export function planTerrace(
59
+ nodes: Record<string, AnyNode>,
60
+ levelId: string,
61
+ polygon: readonly FloorRegionPoint[],
62
+ ): { plan: StructurePlan & { zoneId: string } } | { message: string } {
63
+ const outline = polygon.map(([x, z]): [number, number] => [x, z])
64
+ if (floorRegionSelfIntersects(outline)) return { message: CROSSING_MESSAGE }
65
+ if (area([{ outer: outline, holes: [] }]) < 1) return { message: TERRACE_TOO_SMALL_MESSAGE }
66
+ try {
67
+ const plan = createZone(nodes, {
68
+ levelId,
69
+ polygon: outline,
70
+ name: TERRACE_NAME,
71
+ enclose: false,
72
+ intent: { hasCeiling: false },
73
+ mintId: generateId,
74
+ })
75
+ const conflict = plan.conflicts?.[0]
76
+ return conflict ? { message: conflict.message || TERRACE_FAILED_MESSAGE } : { plan }
77
+ } catch {
78
+ return { message: TERRACE_FAILED_MESSAGE }
79
+ }
80
+ }
81
+
82
+ let tool: GestureHandle | null = null
83
+
84
+ function reset() {
85
+ if (useTerraceDraft.getState().host) useTerraceDraft.setState({ host: null })
86
+ const { draft } = useFloorRegionDraft.getState()
87
+ if (draft?.room.purpose === TERRACE) useFloorRegionDraft.setState({ draft: null, hover: null })
88
+ }
89
+
90
+ function commitTerrace(
91
+ host: FloorRegionRoom,
92
+ polygon: readonly FloorRegionPoint[],
93
+ ): FloorDraftCommit {
94
+ const result = planTerrace(useScene.getState().nodes, host.levelId, polygon)
95
+ if ('message' in result) return { ok: false, message: result.message }
96
+ applyRoomPlan(result.plan)
97
+ sfxEmitter.emit('sfx:structure-build')
98
+ // The rooms (and this terrace) now snap the next outline.
99
+ useTerraceDraft.setState({ host: describeTerraceHost(useScene.getState().nodes, host.levelId) })
100
+ return { ok: true }
101
+ }
102
+
103
+ function beginTool(host: FloorRegionRoom) {
104
+ const handle = beginGesture({
105
+ kind: 'terrace-draft',
106
+ scope: { kind: 'handle-drag', nodeId: host.levelId, handle: TERRACE_DRAFT_HANDLE },
107
+ stale: () =>
108
+ useScene.getState().readOnly || !useScene.getState().nodes[host.levelId as AnyNodeId],
109
+ onCancel: () => {
110
+ if (tool === handle) tool = null
111
+ reset()
112
+ },
113
+ })
114
+ tool = handle
115
+ useTerraceDraft.setState({ host })
116
+ return handle
117
+ }
118
+
119
+ const TERRACE: FloorDraftPurpose = {
120
+ persistent: true,
121
+ own: (room) => (tool?.active ? tool : beginTool(room)),
122
+ commit: (room, polygon) => {
123
+ const current = useTerraceDraft.getState().host ?? room
124
+ return commitTerrace(current, polygon)
125
+ },
126
+ fill: '#818cf8',
127
+ }
128
+
129
+ /** The level's ground as a draft room: the site (or open ground), snapping to the rooms on it. */
130
+ export function describeTerraceHost(
131
+ nodes: Record<string, AnyNode>,
132
+ levelId: string,
133
+ ): FloorRegionRoom | null {
134
+ const level = nodes[levelId]
135
+ if (level?.type !== 'level') return null
136
+ const site = Object.values(nodes).find((node) => node.type === 'site')
137
+ const outline =
138
+ site?.type === 'site' && site.polygon?.points?.length && site.polygon.points.length >= 3
139
+ ? site.polygon.points.map(([x, z]): [number, number] => [x, z])
140
+ : ([
141
+ [-OPEN_GROUND, -OPEN_GROUND],
142
+ [OPEN_GROUND, -OPEN_GROUND],
143
+ [OPEN_GROUND, OPEN_GROUND],
144
+ [-OPEN_GROUND, OPEN_GROUND],
145
+ ] as [number, number][])
146
+ const clear: MultiPolygon = [{ outer: outline, holes: [] }]
147
+ const rooms = Object.values(nodes).flatMap((node) =>
148
+ node.type === 'zone' && node.parentId === levelId && node.spaceRole === 'room'
149
+ ? [node.polygon]
150
+ : [],
151
+ )
152
+ return {
153
+ zoneId: '',
154
+ levelId,
155
+ clear,
156
+ elevation: 0,
157
+ angle: 0,
158
+ targets: floorRegionSnapTargets([], rooms),
159
+ purpose: TERRACE,
160
+ }
161
+ }
162
+
163
+ /** Arms the terrace tool on the current level (select mode, so nothing else draws). */
164
+ export function startTerraceDraft(levelId = useViewer.getState().selection.levelId): boolean {
165
+ if (!levelId || useScene.getState().readOnly) return false
166
+ const host = describeTerraceHost(useScene.getState().nodes, levelId)
167
+ if (!host) return false
168
+ cancelTerraceDraft()
169
+ cancelFloorRegion()
170
+ const editor = useEditor.getState()
171
+ if (editor.phase === 'site') editor.setPhase('building')
172
+ if (editor.mode !== 'select') editor.setMode('select')
173
+ if (editor.room) editor.clearRoom()
174
+ useViewer.getState().setSelection({ selectedIds: [] })
175
+ beginTool(host)
176
+ return true
177
+ }
178
+
179
+ export function cancelTerraceDraft() {
180
+ const current = tool
181
+ tool = null
182
+ if (current?.active) current.cancel()
183
+ else reset()
184
+ }
185
+
186
+ export function terraceDraftActive() {
187
+ return !!useTerraceDraft.getState().host
188
+ }
189
+
190
+ export function setTerraceShape(shape: TerraceShape) {
191
+ if (useTerraceDraft.getState().shape === shape) return
192
+ const { draft } = useFloorRegionDraft.getState()
193
+ if (draft?.room.purpose === TERRACE) useFloorRegionDraft.setState({ draft: null })
194
+ useTerraceDraft.setState({ shape })
195
+ }
196
+
197
+ export function toggleTerraceShape() {
198
+ setTerraceShape(useTerraceDraft.getState().shape === 'rectangle' ? 'polygon' : 'rectangle')
199
+ }
200
+
201
+ /** The HUD's shape chip (T). */
202
+ export const TERRACE_SHAPE_HINT: ToolHint = {
203
+ key: 'T',
204
+ label: 'Shape',
205
+ chip: {
206
+ subscribe: (onChange) => useTerraceDraft.subscribe(onChange),
207
+ value: () => useTerraceDraft.getState().shape,
208
+ cycle: toggleTerraceShape,
209
+ labels: { rectangle: 'Shape: Rectangle', polygon: 'Shape: Polygon' },
210
+ icons: { rectangle: 'lucide:square', polygon: 'lucide:pentagon' },
211
+ tooltip: 'Outline shape — click or press T to switch',
212
+ },
213
+ }
@@ -0,0 +1,16 @@
1
+ import { expect, test } from 'bun:test'
2
+ import { wallCursorIcon } from './tool-cursor-icon'
3
+ import { getWallDrawVariantInfo, WALL_DRAW_VARIANTS } from './wall-draw-variant'
4
+
5
+ test('the wall cursor bubble shows the Rooms tile icon of the variant in hand', () => {
6
+ for (const variant of WALL_DRAW_VARIANTS) {
7
+ expect(wallCursorIcon(variant.mode)).toEqual({
8
+ label: getWallDrawVariantInfo(variant.id).title,
9
+ iconSrc: variant.iconSrc,
10
+ })
11
+ }
12
+ expect(
13
+ new Set(WALL_DRAW_VARIANTS.map((variant) => wallCursorIcon(variant.mode).iconSrc)).size,
14
+ ).toBe(3)
15
+ expect(getWallDrawVariantInfo('walls').iconSrc).toBe('/icons/wall.webp')
16
+ })
@@ -0,0 +1,11 @@
1
+ import type { ContinuationMode } from './continuation'
2
+ import { getWallDrawVariantInfo, wallDrawVariantOf } from './wall-draw-variant'
3
+
4
+ /**
5
+ * The cursor bubble's icon for the wall tool: the Rooms variant in hand
6
+ * (Rectangle / Polygon / Walls), the same art as the Build panel tile.
7
+ */
8
+ export function wallCursorIcon(mode: ContinuationMode): { label: string; iconSrc: string } {
9
+ const variant = getWallDrawVariantInfo(wallDrawVariantOf(mode))
10
+ return { label: variant.title, iconSrc: variant.iconSrc }
11
+ }
@@ -0,0 +1,9 @@
1
+ // Keys typed into a field (the room-name combobox, any input, an open dialog)
2
+ // belong to that field, never to a canvas gesture.
3
+ const TYPING_TARGET =
4
+ 'input, textarea, select, [contenteditable]:not([contenteditable="false"]), [role="combobox"], [role="textbox"], [role="dialog"], [role="alertdialog"]'
5
+
6
+ export function isTypingTarget(target: EventTarget | null) {
7
+ const element = target as { closest?: (selector: string) => unknown; isContentEditable?: boolean }
8
+ return !!element && (element.isContentEditable === true || !!element.closest?.(TYPING_TARGET))
9
+ }
package/src/lib/units.ts CHANGED
@@ -4,7 +4,8 @@ import {
4
4
  type BuildingNode,
5
5
  type LevelNode,
6
6
  pointInPolygon2D,
7
- resolveAutoZonePolygon,
7
+ runAsSingleSceneHistoryStep,
8
+ sceneRegistry,
8
9
  snapWorldXZToBuildingLocal,
9
10
  UnitNode,
10
11
  useScene,
@@ -12,14 +13,13 @@ import {
12
13
  } from '@pascal-app/core'
13
14
  import { useViewer } from '@pascal-app/viewer'
14
15
  import { useEffect } from 'react'
15
- import useEditor, { type StructureLayer } from '../store/use-editor'
16
+ import useEditor from '../store/use-editor'
16
17
  import { getActiveBuildingPose } from './world-grid-snap'
17
18
 
18
19
  type Nodes = Readonly<Record<AnyNodeId, AnyNode>>
19
20
 
20
21
  // Unit focus is a session gesture: the layer the user was on when focus began
21
22
  // comes back when focus ends, unless they switched layers by hand meanwhile.
22
- let restoreLayer: StructureLayer | null = null
23
23
  let entering = false
24
24
  let leaving = false
25
25
 
@@ -49,11 +49,7 @@ export function enterUnitFocus(unitId: UnitNode['id']): void {
49
49
  const unit = nodes[unitId]
50
50
  if (unit?.type !== 'unit') return
51
51
  const editor = useEditor.getState()
52
- if (restoreLayer === null) restoreLayer = editor.structureLayer
53
- if (editor.phase !== 'structure') editor.setPhase('structure')
54
- if (useEditor.getState().structureLayer !== 'zones') {
55
- useEditor.getState().setStructureLayer('zones')
56
- }
52
+ if (editor.phase === 'site') editor.setPhase('building')
57
53
  const viewer = useViewer.getState()
58
54
  if (viewer.focusedUnitId !== unitId) viewer.setFocusedUnit(unitId)
59
55
 
@@ -72,19 +68,7 @@ export function enterUnitFocus(unitId: UnitNode['id']): void {
72
68
  }
73
69
  }
74
70
 
75
- function finishLeave(unitId: UnitNode['id'], keepLayer: boolean) {
76
- const layer = restoreLayer
77
- restoreLayer = null
78
- const editor = useEditor.getState()
79
- if (
80
- !keepLayer &&
81
- layer &&
82
- layer !== 'zones' &&
83
- editor.phase === 'structure' &&
84
- editor.structureLayer === 'zones'
85
- ) {
86
- editor.setStructureLayer(layer)
87
- }
71
+ function finishLeave(unitId: UnitNode['id']) {
88
72
  const { selection, setSelection } = useViewer.getState()
89
73
  if (selection.selectedIds.length === 1 && selection.selectedIds[0] === unitId) {
90
74
  setSelection({ selectedIds: [] })
@@ -92,7 +76,7 @@ function finishLeave(unitId: UnitNode['id'], keepLayer: boolean) {
92
76
  }
93
77
 
94
78
  /** Ends unit focus. Returns false when no unit was focused. */
95
- export function leaveUnitFocus(options?: { keepLayer?: boolean }): boolean {
79
+ export function leaveUnitFocus(): boolean {
96
80
  const viewer = useViewer.getState()
97
81
  const unitId = viewer.focusedUnitId
98
82
  if (!unitId) return false
@@ -102,7 +86,7 @@ export function leaveUnitFocus(options?: { keepLayer?: boolean }): boolean {
102
86
  } finally {
103
87
  leaving = false
104
88
  }
105
- finishLeave(unitId, options?.keepLayer ?? false)
89
+ finishLeave(unitId)
106
90
  return true
107
91
  }
108
92
 
@@ -124,28 +108,75 @@ export function assignZoneToUnit(zoneId: ZoneNode['id'], unitId: UnitNode['id']
124
108
  if (updates.length > 0) updateNodes(updates)
125
109
  }
126
110
 
111
+ /** The units of the building a zone's level belongs to, and the one listing the zone. */
112
+ export function zoneUnits(
113
+ nodes: Nodes,
114
+ zoneId: string,
115
+ ): { buildingId: BuildingNode['id'] | null; units: UnitNode[]; current: UnitNode | null } {
116
+ const zone = nodes[zoneId as AnyNodeId]
117
+ const level = zone?.parentId ? nodes[zone.parentId as AnyNodeId] : undefined
118
+ const building = level?.parentId ? nodes[level.parentId as AnyNodeId] : undefined
119
+ if (building?.type !== 'building') return { buildingId: null, units: [], current: null }
120
+ const units = building.children.flatMap((id) => {
121
+ const child = nodes[id as AnyNodeId]
122
+ return child?.type === 'unit' ? [child] : []
123
+ })
124
+ return {
125
+ buildingId: building.id,
126
+ units,
127
+ current: units.find((unit) => unit.members.includes(zoneId as ZoneNode['id'])) ?? null,
128
+ }
129
+ }
130
+
131
+ /** Puts a zone in a new unit of its building (named "Unit N"), in one undo step, without focusing it. */
132
+ export function addZoneToNewUnit(zoneId: ZoneNode['id']): UnitNode['id'] | null {
133
+ const { buildingId, units } = zoneUnits(useScene.getState().nodes, zoneId)
134
+ if (!buildingId) return null
135
+ const unit = UnitNode.parse({ name: `Unit ${units.length + 1}` })
136
+ runAsSingleSceneHistoryStep(useScene, () => {
137
+ useScene.getState().createNode(unit, buildingId)
138
+ assignZoneToUnit(zoneId, unit.id)
139
+ })
140
+ return unit.id
141
+ }
142
+
127
143
  export function toggleZoneMembership(unitId: UnitNode['id'], zoneId: ZoneNode['id']): void {
128
144
  const unit = useScene.getState().nodes[unitId]
129
145
  if (unit?.type !== 'unit') return
130
146
  assignZoneToUnit(zoneId, unit.members.includes(zoneId) ? null : unitId)
131
147
  }
132
148
 
149
+ /** How far (m) below a mezzanine's walking surface a hit still counts as on it (its plate and underside). */
150
+ const MEZZANINE_HIT_TOLERANCE = 0.05
151
+
133
152
  /**
134
153
  * The zone under a point on the current level, in building-local XZ. A 3D
135
154
  * click lands on whatever surface is closest (a floor slab sits above the
136
155
  * zone fill), so painting resolves the zone from the hit point instead of the
137
- * hit mesh. Nested zones resolve to the smallest one.
156
+ * hit mesh. Nested zones resolve to the smallest one, except that a mezzanine
157
+ * the hit may be on always wins over its host. With the hit's height (`y`,
158
+ * level-local) a mezzanine counts only when the hit is on or above its plate —
159
+ * the host floor under it is the host's; without one (the plan, seen from
160
+ * above) the mezzanine on top wins.
138
161
  */
139
- export function zoneAtLevelPoint(x: number, z: number): ZoneNode | null {
162
+ export function zoneAtLevelPoint(x: number, z: number, y?: number): ZoneNode | null {
140
163
  const levelId = useViewer.getState().selection.levelId
141
164
  if (!levelId) return null
142
165
  const nodes = useScene.getState().nodes
143
- const resolve = (id: AnyNodeId) => nodes[id]
144
166
  let best: ZoneNode | null = null
145
167
  let bestArea = Number.POSITIVE_INFINITY
168
+ let bestOnTop = false
146
169
  for (const node of Object.values(nodes)) {
147
170
  if (node.type !== 'zone' || node.parentId !== levelId) continue
148
- const polygon = resolveAutoZonePolygon(node, resolve)
171
+ // An eligible mezzanine is the surface on top: it beats its host whatever
172
+ // their areas (a mezzanine may cover its whole host).
173
+ const onTop = node.floor?.support === 'open'
174
+ if (y !== undefined && onTop) {
175
+ const underside = (node.floor!.elevation ?? 0) - (node.floor!.thickness ?? 0.2)
176
+ if (y < underside - MEZZANINE_HIT_TOLERANCE) continue
177
+ }
178
+ if (bestOnTop && !onTop) continue
179
+ const polygon = node.polygon
149
180
  if (!pointInPolygon2D([x, z], polygon)) continue
150
181
  let area = 0
151
182
  for (let i = 0; i < polygon.length; i++) {
@@ -154,20 +185,30 @@ export function zoneAtLevelPoint(x: number, z: number): ZoneNode | null {
154
185
  area += a[0] * b[1] - b[0] * a[1]
155
186
  }
156
187
  area = Math.abs(area) / 2
157
- if (area < bestArea) {
188
+ if (area < bestArea || (onTop && !bestOnTop)) {
158
189
  best = node
159
190
  bestArea = area
191
+ bestOnTop = onTop
160
192
  }
161
193
  }
162
194
  return best
163
195
  }
164
196
 
165
- export function zoneAtWorldPoint(worldX: number, worldZ: number): ZoneNode | null {
197
+ /** `worldY` (optional): the hit's height, so a click under a mezzanine picks the host. */
198
+ export function zoneAtWorldPoint(worldX: number, worldZ: number, worldY?: number): ZoneNode | null {
166
199
  const pose = getActiveBuildingPose()
167
200
  const [x, z] = pose
168
201
  ? snapWorldXZToBuildingLocal(worldX, worldZ, pose.position, pose.rotationY, 0).local
169
202
  : [worldX, worldZ]
170
- return zoneAtLevelPoint(x, z)
203
+ return zoneAtLevelPoint(x, z, worldY === undefined ? undefined : levelLocalY(worldY))
204
+ }
205
+
206
+ function levelLocalY(worldY: number): number {
207
+ const levelId = useViewer.getState().selection.levelId
208
+ const level = levelId ? sceneRegistry.nodes.get(levelId) : undefined
209
+ if (!level) return worldY
210
+ level.updateWorldMatrix(true, false)
211
+ return worldY - level.matrixWorld.elements[13]!
171
212
  }
172
213
 
173
214
  export const ZONE_PAINT_DELAY_MS = 250
@@ -211,32 +252,29 @@ export function createUnitInBuilding(buildingId: BuildingNode['id']): UnitNode['
211
252
  /**
212
253
  * Keeps unit focus coherent with the rest of the editor: a focus set from
213
254
  * anywhere (the unit inspector calls the viewer store directly) gets the full
214
- * enter treatment, and focus ends when the user switches layer or phase by
215
- * hand, selects anything other than the focused unit, or the unit is deleted.
255
+ * enter treatment, and focus ends when the user goes to site, selects
256
+ * anything other than the focused unit, or the unit is deleted.
216
257
  */
217
258
  export function useUnitFocusRules(): void {
218
259
  useEffect(() => {
219
260
  const unsubscribeViewer = useViewer.subscribe((state, prev) => {
220
261
  if (state.focusedUnitId !== prev.focusedUnitId) {
221
262
  if (state.focusedUnitId) enterUnitFocus(state.focusedUnitId)
222
- else if (prev.focusedUnitId && !leaving) finishLeave(prev.focusedUnitId, false)
263
+ else if (prev.focusedUnitId && !leaving) finishLeave(prev.focusedUnitId)
223
264
  return
224
265
  }
225
266
  const unitId = state.focusedUnitId
226
267
  if (!unitId || state.selection === prev.selection) return
227
268
  // Selecting a zone keeps focus: renaming or recolouring a zone row is
228
- // part of arranging the unit, and zones live on the layer focus uses.
269
+ // part of arranging the unit.
229
270
  const { selectedIds } = state.selection
230
271
  const selectsOther =
231
272
  selectedIds.length > 0 && !(selectedIds.length === 1 && selectedIds[0] === unitId)
232
273
  if (selectsOther) leaveUnitFocus()
233
274
  })
234
275
  const unsubscribeEditor = useEditor.subscribe((state, prev) => {
235
- if (state.structureLayer === prev.structureLayer && state.phase === prev.phase) return
236
- if (!useViewer.getState().focusedUnitId) return
237
- if (state.phase !== 'structure' || state.structureLayer !== 'zones') {
238
- leaveUnitFocus({ keepLayer: true })
239
- }
276
+ if (state.phase === prev.phase || state.phase === 'building') return
277
+ if (useViewer.getState().focusedUnitId) leaveUnitFocus()
240
278
  })
241
279
  const unsubscribeScene = useScene.subscribe((state, prev) => {
242
280
  if (state.nodes === prev.nodes) return
@@ -0,0 +1,58 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import * as THREE from 'three'
3
+ import { candidateEdges, hideUndrawnMeshes, materialShows } from './vector-edges'
4
+
5
+ /** A unit box's feature edges: 12, six floats each. */
6
+ const BOX_EDGE_FLOATS = 12 * 6
7
+
8
+ function sceneWithProxy() {
9
+ const scene = new THREE.Scene()
10
+ const house = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshBasicMaterial())
11
+ // a utility-pole pick proxy: a tall box that draws nothing
12
+ const proxy = new THREE.Mesh(
13
+ new THREE.BoxGeometry(0.5, 10.7, 0.5),
14
+ new THREE.MeshBasicMaterial({ colorWrite: false, depthWrite: false }),
15
+ )
16
+ proxy.position.set(3, 0, 0)
17
+ scene.add(house, proxy)
18
+ scene.updateMatrixWorld(true)
19
+ return { scene, house, proxy }
20
+ }
21
+
22
+ describe('vector edges skip what draws nothing', () => {
23
+ test('a pick proxy (colorWrite false) has no lines', () => {
24
+ const { scene } = sceneWithProxy()
25
+ const camera = new THREE.OrthographicCamera(-10, 10, 10, -10, 0.1, 100)
26
+ const world = candidateEdges(scene, camera, 20, 400_000)
27
+ expect(world).toHaveLength(BOX_EDGE_FLOATS)
28
+ // every edge is the 1 m box's, none the 10.7 m proxy's
29
+ for (let i = 1; i < world.length; i += 3)
30
+ expect(Math.abs(world[i] as number)).toBeLessThanOrEqual(0.5)
31
+ })
32
+
33
+ test('hidden materials, see-through hit boxes and colour-masked colliders all count as undrawn', () => {
34
+ expect(materialShows(new THREE.MeshBasicMaterial())).toBe(true)
35
+ expect(materialShows(new THREE.MeshBasicMaterial({ colorWrite: false }))).toBe(false)
36
+ expect(materialShows(new THREE.MeshBasicMaterial({ visible: false }))).toBe(false)
37
+ expect(materialShows(new THREE.MeshBasicMaterial({ transparent: true, opacity: 0 }))).toBe(
38
+ false,
39
+ )
40
+ expect(
41
+ materialShows([
42
+ new THREE.MeshBasicMaterial({ colorWrite: false }),
43
+ new THREE.MeshBasicMaterial(),
44
+ ]),
45
+ ).toBe(true)
46
+ })
47
+
48
+ test('the depth and normal passes see no pick proxy, and it comes back after', () => {
49
+ const { scene, house, proxy } = sceneWithProxy()
50
+ const show = hideUndrawnMeshes(scene)
51
+ // under the passes' override material the proxy would write depth and
52
+ // hide the real edges behind it
53
+ expect(proxy.visible).toBe(false)
54
+ expect(house.visible).toBe(true)
55
+ show()
56
+ expect(proxy.visible).toBe(true)
57
+ })
58
+ })