@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
@@ -1,5 +1,10 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
- import { CONTINUATION_PROFILES, continuationContextOf, nextContinuation } from './continuation'
2
+ import {
3
+ CONTINUATION_PROFILES,
4
+ continuationContextOf,
5
+ keyCyclableContinuationContext,
6
+ nextContinuation,
7
+ } from './continuation'
3
8
 
4
9
  describe('canopy continuation', () => {
5
10
  test('maps the canopy tool to its own single and continuous profile', () => {
@@ -9,3 +14,16 @@ describe('canopy continuation', () => {
9
14
  expect(nextContinuation('canopy', 'continuous')).toBe('single')
10
15
  })
11
16
  })
17
+
18
+ describe('wall drawing mode', () => {
19
+ test('is picked in the Build panel, never cycled by C or a HUD chip', () => {
20
+ expect(CONTINUATION_PROFILES.wall.options).toEqual(['room', 'single', 'rectangle'])
21
+ expect(keyCyclableContinuationContext('wall')).toBeNull()
22
+ })
23
+
24
+ test('other contexts still cycle from the keyboard', () => {
25
+ expect(keyCyclableContinuationContext('fence')).toBe('fence')
26
+ expect(keyCyclableContinuationContext('point')).toBe('point')
27
+ expect(keyCyclableContinuationContext(null)).toBeNull()
28
+ })
29
+ })
@@ -8,26 +8,37 @@ export const CONTINUATION_PROFILES: Record<
8
8
  default: ContinuationMode
9
9
  labels: Record<string, string>
10
10
  icons: Record<string, string>
11
+ /**
12
+ * Picked once from the Build panel before drawing (`wall-draw-variant.ts`),
13
+ * never cycled by C or a HUD chip mid-draw.
14
+ */
15
+ chosenInPanel?: boolean
11
16
  }
12
17
  > = {
18
+ // The wall tool's drawing mode: a chain that closes into a room ('room', the
19
+ // Build panel's Polygon), one wall per two clicks ('single', Walls), or a
20
+ // two-corner box of four walls ('rectangle', Rectangle).
13
21
  wall: {
14
- options: ['room', 'single'],
22
+ options: ['room', 'single', 'rectangle'],
15
23
  default: 'room',
16
- labels: { room: 'Room (auto-close)', single: 'Single wall' },
17
- icons: { room: 'lucide:square', single: 'lucide:minus' },
24
+ labels: { room: 'Polygon room', single: 'Walls', rectangle: 'Rectangle room' },
25
+ icons: { room: 'lucide:pentagon', single: 'lucide:minus', rectangle: 'lucide:square' },
26
+ chosenInPanel: true,
18
27
  },
19
28
  fence: {
20
- options: ['single', 'continuous', 'curved'],
29
+ options: ['single', 'continuous', 'curved', 'freehand'],
21
30
  default: 'continuous',
22
31
  labels: {
23
32
  continuous: 'Continuous',
24
33
  single: 'Single fence',
25
34
  curved: 'Curved fence',
35
+ freehand: 'Freehand fence',
26
36
  },
27
37
  icons: {
28
38
  continuous: 'lucide:waypoints',
29
39
  single: 'lucide:minus',
30
40
  curved: 'lucide:spline',
41
+ freehand: 'lucide:scribble',
31
42
  },
32
43
  },
33
44
  point: {
@@ -62,6 +73,16 @@ export function nextContinuation(
62
73
  return profile.options[(index + 1) % profile.options.length] ?? profile.default
63
74
  }
64
75
 
76
+ /**
77
+ * The context C and the HUD chip may cycle — null when there is none, or when
78
+ * its mode is chosen in the Build panel instead.
79
+ */
80
+ export function keyCyclableContinuationContext(
81
+ context: ContinuationContext | null,
82
+ ): ContinuationContext | null {
83
+ return context && !CONTINUATION_PROFILES[context].chosenInPanel ? context : null
84
+ }
85
+
65
86
  export function continuationContextOf(kind: string): ContinuationContext | null {
66
87
  if (kind === 'wall') return 'wall'
67
88
  if (kind === 'fence') return 'fence'
@@ -86,3 +86,17 @@ export function closeDoorOpenState(doorId: AnyNodeId, options?: DoorOpenAnimatio
86
86
  const currentSwingAngle = getDisplayedDoorValue(doorId, 'swingAngle', node.swingAngle)
87
87
  startDoorOpenAnimation(doorId, 'swingAngle', currentSwingAngle, 0, options)
88
88
  }
89
+
90
+ export function openDoorOpenState(doorId: AnyNodeId, options?: DoorOpenAnimationOptions) {
91
+ const node = useScene.getState().nodes[doorId]
92
+ if (node?.type !== 'door' || node.openingKind === 'opening') return
93
+
94
+ if (isOperationDoorType(node.doorType)) {
95
+ const currentOpenAmount = getDisplayedDoorValue(doorId, 'operationState', node.operationState)
96
+ startDoorOpenAnimation(doorId, 'operationState', currentOpenAmount, 1, options)
97
+ return
98
+ }
99
+
100
+ const currentSwingAngle = getDisplayedDoorValue(doorId, 'swingAngle', node.swingAngle)
101
+ startDoorOpenAnimation(doorId, 'swingAngle', currentSwingAngle, DOOR_SWING_OPEN_ANGLE, options)
102
+ }
@@ -0,0 +1,96 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+ import { BuildingNode, LevelNode, useScene } from '@pascal-app/core'
3
+ import { useViewer } from '@pascal-app/viewer'
4
+ import useEditor, { type CaptureMode } from '../store/use-editor'
5
+ import { showsWholeBuilding } from './editor-level-display'
6
+
7
+ const building = BuildingNode.parse({ id: 'building_capture', children: [] })
8
+ const levels = [0, 1, 2].map((level) =>
9
+ LevelNode.parse({ id: `level_capture_${level}`, level, parentId: building.id }),
10
+ )
11
+ const [ground, first, second] = levels.map((level) => level.id) as [
12
+ LevelNode['id'],
13
+ LevelNode['id'],
14
+ LevelNode['id'],
15
+ ]
16
+
17
+ const wholeBuilding = () => {
18
+ const state = useEditor.getState()
19
+ return showsWholeBuilding({
20
+ isPreviewMode: state.isPreviewMode,
21
+ isFirstPersonMode: state.isFirstPersonMode,
22
+ captureMode: state.captureMode,
23
+ captureLevelId: state.captureLevelId,
24
+ })
25
+ }
26
+ const activeLevel = () => useViewer.getState().selection.levelId
27
+
28
+ beforeEach(() => {
29
+ useScene.setState({
30
+ nodes: Object.fromEntries(
31
+ [{ ...building, children: levels.map((level) => level.id) }, ...levels].map((node) => [
32
+ node.id,
33
+ node,
34
+ ]),
35
+ ),
36
+ rootNodeIds: [building.id],
37
+ } as never)
38
+ useViewer.getState().setSelection({ buildingId: building.id, levelId: second })
39
+ })
40
+ afterEach(() => useEditor.getState().setCaptureMode(false))
41
+
42
+ describe('capture level picker', () => {
43
+ test('editing hides the levels above; capture opens on the whole building', () => {
44
+ expect(wholeBuilding()).toBe(false)
45
+ useEditor.getState().setCaptureMode(true)
46
+ expect(useEditor.getState().captureLevelId).toBeNull()
47
+ expect(wholeBuilding()).toBe(true)
48
+ expect(activeLevel()).toBe(second)
49
+ })
50
+
51
+ test('picking a level shows it and the levels below, as while editing', () => {
52
+ useEditor.getState().setCaptureMode(true)
53
+ useEditor.getState().setCaptureLevel(first)
54
+ expect(wholeBuilding()).toBe(false)
55
+ expect(activeLevel()).toBe(first)
56
+ useEditor.getState().setCaptureLevel(null)
57
+ expect(wholeBuilding()).toBe(true)
58
+ })
59
+
60
+ test("leaving capture restores the editor's active level and the editing rule", () => {
61
+ useEditor.getState().setCaptureMode(true)
62
+ useEditor.getState().setCaptureLevel(ground)
63
+ useEditor.getState().setCaptureMode(false)
64
+ expect(activeLevel()).toBe(second)
65
+ expect(useEditor.getState().captureLevelId).toBeNull()
66
+ expect(wholeBuilding()).toBe(false)
67
+ // The next capture opens on the whole building again.
68
+ useEditor.getState().setCaptureMode({ mode: 'standard', crop: 'area' })
69
+ expect(wholeBuilding()).toBe(true)
70
+ })
71
+
72
+ test('a level picked outside capture is ignored', () => {
73
+ useEditor.getState().setCaptureLevel(ground)
74
+ expect(activeLevel()).toBe(second)
75
+ expect(useEditor.getState().captureLevelId).toBeNull()
76
+ })
77
+
78
+ test('preset captures and the walkthrough keep their own rules', () => {
79
+ const preset: CaptureMode = { mode: 'preset', isolated: [] }
80
+ const base = { isPreviewMode: false, isFirstPersonMode: false, captureLevelId: null }
81
+ expect(showsWholeBuilding({ ...base, captureMode: preset })).toBe(false)
82
+ expect(showsWholeBuilding({ ...base, captureMode: { mode: 'idle' } })).toBe(false)
83
+ expect(
84
+ showsWholeBuilding({ ...base, isFirstPersonMode: true, captureMode: { mode: 'idle' } }),
85
+ ).toBe(true)
86
+ // A walk capture on a picked level still shows only that level and below.
87
+ expect(
88
+ showsWholeBuilding({
89
+ ...base,
90
+ isFirstPersonMode: true,
91
+ captureMode: { mode: 'standard' },
92
+ captureLevelId: first,
93
+ }),
94
+ ).toBe(false)
95
+ })
96
+ })
@@ -0,0 +1,48 @@
1
+ import { useViewer } from '@pascal-app/viewer'
2
+ import { useEffect } from 'react'
3
+ import type { CaptureMode } from '../store/use-editor'
4
+
5
+ /**
6
+ * Whether the editor shows every level. Preview and the walkthrough show the
7
+ * whole building; a scene capture shows what its level picker says (the
8
+ * whole building, or a level and the ones below it). A preset capture frames
9
+ * its isolated item and keeps the editing rule.
10
+ */
11
+ export function showsWholeBuilding(state: {
12
+ isPreviewMode: boolean
13
+ isFirstPersonMode: boolean
14
+ captureMode: CaptureMode
15
+ captureLevelId: string | null
16
+ }): boolean {
17
+ if (state.captureMode.mode === 'standard') return state.captureLevelId === null
18
+ return state.isPreviewMode || state.isFirstPersonMode
19
+ }
20
+
21
+ /**
22
+ * The editor's one level display: stacked, with every level above the
23
+ * selected one hidden — the selected level and the ones below stay, so the
24
+ * storey being worked on is never covered. There is no Stack / Exploded /
25
+ * Solo choice while editing; a mode left over from the viewer (or persisted
26
+ * by an older build) is set back to stacked, and kept there.
27
+ *
28
+ * The walkthrough shows the whole building, so the upper levels come back
29
+ * while it is on; Preview hands the display to the viewer's own floor rule
30
+ * (`useViewerFloorDisplay`).
31
+ */
32
+ export function useEditorLevelDisplay(showWholeBuilding: boolean) {
33
+ useEffect(() => {
34
+ if (showWholeBuilding) return
35
+ const keepStacked = (mode: string) => {
36
+ if (mode !== 'stacked') useViewer.getState().setLevelMode('stacked')
37
+ }
38
+ useViewer.setState({ hideLevelsAboveSelection: true })
39
+ keepStacked(useViewer.getState().levelMode)
40
+ const unsubscribe = useViewer.subscribe((state, previous) => {
41
+ if (state.levelMode !== previous.levelMode) keepStacked(state.levelMode)
42
+ })
43
+ return () => {
44
+ unsubscribe()
45
+ useViewer.setState({ hideLevelsAboveSelection: false })
46
+ }
47
+ }, [showWholeBuilding])
48
+ }
@@ -0,0 +1,144 @@
1
+ import type { IconRef, LazyComponent } from '@pascal-app/core'
2
+ import useEditor from '../store/use-editor'
3
+ import { VIEW_2D, VIEW_3D } from '../store/view-layout'
4
+
5
+ /**
6
+ * A view fills a pane of the stage, beside or instead of the 3D scene and the
7
+ * 2D plan. Hosts and plugins register views the way they register rail panels;
8
+ * a plugin registers as many as it needs, each tagged with its `pluginId`.
9
+ */
10
+ export type EditorHostView = {
11
+ id: string
12
+ label: string
13
+ icon: IconRef
14
+ component: LazyComponent
15
+ /** The view exists in a project only while this plugin is installed there. */
16
+ pluginId?: string
17
+ /** Workspaces the view is offered in. Default `['edit']`, like panels. */
18
+ workspaces?: readonly string[]
19
+ description?: string
20
+ /** Shown in the view bar until the user unpins it. Default false: the "More views" menu only. */
21
+ defaultPinned?: boolean
22
+ }
23
+
24
+ /** A view as the stage needs it. The scene views have no component: the stage owns their canvases. */
25
+ export type EditorViewDescriptor = Omit<EditorHostView, 'component'> & {
26
+ component?: LazyComponent
27
+ /** 3D and 2D: the views the level selector, tool dock and scene overlays belong to. */
28
+ scene?: boolean
29
+ }
30
+
31
+ export const SCENE_VIEWS: readonly EditorViewDescriptor[] = [
32
+ {
33
+ id: VIEW_3D,
34
+ label: '3D',
35
+ icon: { kind: 'url', src: '/icons/building.webp' },
36
+ scene: true,
37
+ defaultPinned: true,
38
+ },
39
+ {
40
+ id: VIEW_2D,
41
+ label: '2D',
42
+ icon: { kind: 'url', src: '/icons/blueprint.webp' },
43
+ scene: true,
44
+ defaultPinned: true,
45
+ },
46
+ ]
47
+
48
+ export function isSceneView(viewId: string): boolean {
49
+ return viewId === VIEW_3D || viewId === VIEW_2D
50
+ }
51
+
52
+ function isDevMode(): boolean {
53
+ if (typeof process !== 'undefined' && process.env?.NODE_ENV) {
54
+ return process.env.NODE_ENV !== 'production'
55
+ }
56
+ return false
57
+ }
58
+
59
+ class EditorHostViewRegistryImpl {
60
+ private readonly views = new Map<string, EditorHostView>()
61
+ private readonly listeners = new Set<() => void>()
62
+ private cached: EditorHostView[] = []
63
+
64
+ subscribe = (onChange: () => void): (() => void) => {
65
+ this.listeners.add(onChange)
66
+ return () => {
67
+ this.listeners.delete(onChange)
68
+ }
69
+ }
70
+
71
+ getSnapshot = (): EditorHostView[] => this.cached
72
+
73
+ reset(): void {
74
+ this.views.clear()
75
+ this.emit()
76
+ }
77
+
78
+ registerView(view: EditorHostView): void {
79
+ if (typeof view.id !== 'string' || view.id.length === 0) {
80
+ throw new Error('[editor:host-views] view id must be a non-empty string')
81
+ }
82
+ if (isSceneView(view.id)) {
83
+ throw new Error(`[editor:host-views] "${view.id}" is a built-in view`)
84
+ }
85
+ if (this.views.has(view.id)) {
86
+ if (isDevMode()) {
87
+ console.warn(`[editor:host-views] re-registering view "${view.id}" (HMR)`)
88
+ } else {
89
+ throw new Error(`[editor:host-views] duplicate view id: "${view.id}" already registered`)
90
+ }
91
+ }
92
+ this.views.set(view.id, view)
93
+ this.emit()
94
+ }
95
+
96
+ private emit(): void {
97
+ this.cached = Array.from(this.views.values())
98
+ for (const listener of this.listeners) listener()
99
+ }
100
+ }
101
+
102
+ export const editorHostViewRegistry = new EditorHostViewRegistryImpl()
103
+
104
+ export function registerEditorHostView(view: EditorHostView): void {
105
+ editorHostViewRegistry.registerView(view)
106
+ }
107
+
108
+ /**
109
+ * Show a view: focus it if it is on screen, else put it in the focused pane —
110
+ * or, with `beside`, split and open it next to what is there.
111
+ */
112
+ export function openEditorView(viewId: string, options?: { beside?: boolean }): void {
113
+ useEditor.getState().showView(viewId, options)
114
+ }
115
+
116
+ /**
117
+ * The views a project can show in a workspace: the scene views, then every
118
+ * registered view offered there whose plugin (if any) is installed.
119
+ */
120
+ export function availableViews({
121
+ registered,
122
+ workspaceMode,
123
+ installedPlugins,
124
+ }: {
125
+ registered: readonly EditorViewDescriptor[]
126
+ workspaceMode: string
127
+ installedPlugins: readonly string[]
128
+ }): EditorViewDescriptor[] {
129
+ return [
130
+ ...SCENE_VIEWS,
131
+ ...registered.filter(
132
+ (view) =>
133
+ (view.workspaces ?? ['edit']).includes(workspaceMode) &&
134
+ (!view.pluginId || installedPlugins.includes(view.pluginId)),
135
+ ),
136
+ ]
137
+ }
138
+
139
+ export function isViewPinned(
140
+ view: EditorViewDescriptor,
141
+ pinnedViews: Readonly<Record<string, boolean>>,
142
+ ): boolean {
143
+ return pinnedViews[view.id] ?? view.defaultPinned ?? false
144
+ }
@@ -0,0 +1,90 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+ import {
3
+ type AnyNode,
4
+ type AnyNodeDefinition,
5
+ DoorNode,
6
+ nodeRegistry,
7
+ registerNode,
8
+ sceneRegistry,
9
+ } from '@pascal-app/core'
10
+ import * as THREE from 'three'
11
+ import { nodesAwaitingExportGeometry, waitForExportGeometry } from './glb-export'
12
+
13
+ const node = (id: string, type: string, fields: Record<string, unknown> = {}) =>
14
+ ({
15
+ object: 'node',
16
+ id,
17
+ type,
18
+ parentId: 'level_a',
19
+ visible: true,
20
+ metadata: {},
21
+ ...fields,
22
+ }) as unknown as AnyNode
23
+
24
+ const definition = (kind: string, extra: Record<string, unknown> = {}) =>
25
+ ({
26
+ kind,
27
+ schemaVersion: 1,
28
+ schema: DoorNode,
29
+ category: 'utility',
30
+ defaults: () => ({}) as never,
31
+ capabilities: {},
32
+ bake: 'replace',
33
+ ...extra,
34
+ }) as AnyNodeDefinition
35
+
36
+ let restoreRegistry: () => void
37
+
38
+ beforeEach(() => {
39
+ restoreRegistry = nodeRegistry._snapshot()
40
+ registerNode(definition('test:plant'))
41
+ registerNode(definition('test:baked', { bakeGeometry: () => new THREE.Group() }))
42
+ })
43
+
44
+ afterEach(() => {
45
+ restoreRegistry()
46
+ sceneRegistry.clear()
47
+ })
48
+
49
+ // Live objects as the editor registers them: still empty, i.e. the export
50
+ // geometry has not mounted.
51
+ function mount(ids: string[]) {
52
+ for (const id of ids) sceneRegistry.nodes.set(id, new THREE.Group())
53
+ }
54
+
55
+ describe('waitForExportGeometry', () => {
56
+ const nodes: Record<string, AnyNode> = {
57
+ level_a: node('level_a', 'level', { parentId: null }),
58
+ plant_a: node('plant_a', 'test:plant'),
59
+ plant_hidden: node('plant_hidden', 'test:plant', { visible: false }),
60
+ baked_a: node('baked_a', 'test:baked'),
61
+ }
62
+
63
+ test('waits only for included collective kinds that mount their own geometry', () => {
64
+ mount(['plant_a', 'plant_hidden', 'baked_a'])
65
+ expect(nodesAwaitingExportGeometry(nodes)).toEqual(['plant_a'])
66
+ expect(nodesAwaitingExportGeometry(nodes, { onlyVisible: false })).toEqual([
67
+ 'plant_a',
68
+ 'plant_hidden',
69
+ ])
70
+ expect(nodesAwaitingExportGeometry(nodes, { excludedNodeTypes: ['test:plant'] })).toEqual([])
71
+ sceneRegistry.nodes.get('plant_a')!.add(new THREE.Mesh())
72
+ expect(nodesAwaitingExportGeometry(nodes)).toEqual([])
73
+ })
74
+
75
+ test('times out on its own timer when geometry never mounts and frames never run', async () => {
76
+ mount(['plant_a'])
77
+ const started = Date.now()
78
+ await waitForExportGeometry(nodes, {}, 300)
79
+ const elapsed = Date.now() - started
80
+ expect(elapsed).toBeGreaterThanOrEqual(280)
81
+ expect(elapsed).toBeLessThan(1500)
82
+ })
83
+
84
+ test('returns as soon as nothing is pending', async () => {
85
+ mount(['plant_a', 'baked_a'])
86
+ const started = Date.now()
87
+ await waitForExportGeometry(nodes, { excludedNodeTypes: ['test:plant'] }, 5_000)
88
+ expect(Date.now() - started).toBeLessThan(1_000)
89
+ })
90
+ })
@@ -0,0 +1,153 @@
1
+ import {
2
+ type AnyNode,
3
+ area,
4
+ emitter,
5
+ type FloorOpeningNode,
6
+ intersection,
7
+ openingsForSurface,
8
+ removeFloorOpening,
9
+ structureChangeBatch,
10
+ useScene,
11
+ } from '@pascal-app/core'
12
+ import { useViewer } from '@pascal-app/viewer'
13
+ import { create } from 'zustand'
14
+ import { markToolCancelConsumed } from '../hooks/use-keyboard'
15
+ import useEditor from '../store/use-editor'
16
+ import { cancelOpeningDraft } from './floor-opening-draft'
17
+ import { completeElementAction } from './room-zone-routing'
18
+
19
+ // "Edit floor": the room's floor openings, outlined in 3D and on the plan,
20
+ // each a click away, with "Cut opening" to draw another. Like "Edit ceiling"
21
+ // it is a standing mode rather than an interaction scope, so the scene stays
22
+ // clickable; it ends with Done or Escape (back to the room), when the room is
23
+ // deselected, or when another room is picked.
24
+
25
+ export type FloorEditSession = { zoneId: string; levelId: string }
26
+
27
+ export const useFloorEditSession = create<{ session: FloorEditSession | null }>(() => ({
28
+ session: null,
29
+ }))
30
+
31
+ type Nodes = Readonly<Record<string, AnyNode>>
32
+
33
+ /**
34
+ * The openings through one surface of a room: those whose cut reaches that
35
+ * surface on the room's level (a floor opening here, a ceiling opening from
36
+ * the storey below) and overlap the room's outline.
37
+ */
38
+ export function roomSurfaceOpenings(
39
+ nodes: Nodes,
40
+ zoneId: string,
41
+ surface: 'floor' | 'ceiling',
42
+ ): FloorOpeningNode[] {
43
+ const zone = nodes[zoneId]
44
+ if (zone?.type !== 'zone' || !zone.parentId) return []
45
+ const outline = { outer: zone.polygon, holes: zone.holes ?? [] }
46
+ const mezzanine = zone.floor?.support === 'open'
47
+ return openingsForSurface(nodes, zone.parentId, surface).filter((opening) =>
48
+ mezzanine
49
+ ? opening.hostZoneId === zoneId
50
+ : !opening.hostZoneId && area(intersection(opening.polygon, outline)) > 1e-6,
51
+ )
52
+ }
53
+
54
+ let stop: (() => void) | null = null
55
+
56
+ export function startFloorEdit(zoneId: string): boolean {
57
+ const zone =
58
+ useScene.getState().nodes[zoneId as keyof ReturnType<typeof useScene.getState>['nodes']]
59
+ if (zone?.type !== 'zone' || !zone.parentId) return false
60
+ endFloorEdit()
61
+ useFloorEditSession.setState({ session: { zoneId, levelId: zone.parentId } })
62
+ stop = watch(zoneId)
63
+ return true
64
+ }
65
+
66
+ export function endFloorEdit() {
67
+ stop?.()
68
+ stop = null
69
+ if (useFloorEditSession.getState().session) useFloorEditSession.setState({ session: null })
70
+ }
71
+
72
+ /** Done / Escape: ends the session on the room it edited. */
73
+ export function finishFloorEdit() {
74
+ const session = useFloorEditSession.getState().session
75
+ if (!session) return false
76
+ endFloorEdit()
77
+ completeElementAction({ room: session, point: null })
78
+ return true
79
+ }
80
+
81
+ function watch(zoneId: string) {
82
+ const stopEditor = useEditor.subscribe((state) => {
83
+ if (state.room?.zoneId !== zoneId && !openingSelectedIn(zoneId)) endFloorEdit()
84
+ })
85
+ const stopScene = useScene.subscribe((state) => {
86
+ if (state.nodes[zoneId as keyof typeof state.nodes]?.type !== 'zone') endFloorEdit()
87
+ })
88
+ const onToolCancel = () => {
89
+ if (useViewer.getState().selection.selectedIds.length) return
90
+ if (finishFloorEdit()) markToolCancelConsumed()
91
+ }
92
+ emitter.on('tool:cancel', onToolCancel)
93
+ return () => {
94
+ stopEditor()
95
+ stopScene()
96
+ emitter.off('tool:cancel', onToolCancel)
97
+ }
98
+ }
99
+
100
+ function openingSelectedIn(zoneId: string) {
101
+ const nodes = useScene.getState().nodes as Nodes
102
+ const [id] = useViewer.getState().selection.selectedIds
103
+ return (
104
+ !!id &&
105
+ nodes[id]?.type === 'floor-opening' &&
106
+ roomSurfaceOpenings(nodes, zoneId, 'floor').some((opening) => opening.id === id)
107
+ )
108
+ }
109
+
110
+ /** The room an opening belongs to: the selected room, else the room it overlaps most. */
111
+ export function openingRoom(nodes: Nodes, opening: FloorOpeningNode): string | null {
112
+ if (opening.hostZoneId) return opening.hostZoneId
113
+ const selected = useEditor.getState().room
114
+ if (selected && selected.levelId === opening.parentId) return selected.zoneId
115
+ let best: { id: string; overlap: number } | null = null
116
+ for (const node of Object.values(nodes)) {
117
+ if (node.type !== 'zone' || node.parentId !== opening.parentId || node.spaceRole !== 'room')
118
+ continue
119
+ const overlap = area(
120
+ intersection(opening.polygon, { outer: node.polygon, holes: node.holes ?? [] }),
121
+ )
122
+ if (overlap > (best?.overlap ?? 1e-6)) best = { id: node.id, overlap }
123
+ }
124
+ return best?.id ?? null
125
+ }
126
+
127
+ /** Deletes an opening (one undo step) and lands on its room. */
128
+ export function deleteFloorOpening(openingId: string) {
129
+ const nodes = useScene.getState().nodes as Nodes
130
+ const opening = nodes[openingId]
131
+ if (opening?.type !== 'floor-opening' || useScene.getState().readOnly) return false
132
+ if (opening.source === 'stair' || opening.source === 'elevator') return false
133
+ const zoneId = openingRoom(nodes, opening)
134
+ cancelOpeningDraft()
135
+ const plan = removeFloorOpening(nodes, openingId)
136
+ useScene.getState().applyNodeChanges(structureChangeBatch(plan.changes))
137
+ useViewer.getState().setSelection({ selectedIds: [] })
138
+ if (zoneId && opening.parentId)
139
+ completeElementAction({ room: { levelId: opening.parentId, zoneId }, point: null })
140
+ return true
141
+ }
142
+
143
+ /** Done in the opening's panel: back to its room. */
144
+ export function finishFloorOpening(openingId: string) {
145
+ const nodes = useScene.getState().nodes as Nodes
146
+ const opening = nodes[openingId]
147
+ if (opening?.type !== 'floor-opening') return false
148
+ const zoneId = openingRoom(nodes, opening)
149
+ useViewer.getState().setSelection({ selectedIds: [] })
150
+ if (zoneId && opening.parentId)
151
+ completeElementAction({ room: { levelId: opening.parentId, zoneId }, point: null })
152
+ return true
153
+ }