@pascal-app/editor 1.0.1 → 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 (498) hide show
  1. package/package.json +16 -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 +221 -128
  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 +37 -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 +536 -487
  27. package/src/components/editor/footprint-height-handle.tsx +204 -0
  28. package/src/components/editor/grid.tsx +31 -7
  29. package/src/components/editor/group-actions.ts +59 -11
  30. package/src/components/editor/group-floating-action-menu.tsx +19 -9
  31. package/src/components/editor/group-move-3d.ts +4 -6
  32. package/src/components/editor/group-rotate-handle.tsx +81 -35
  33. package/src/components/editor/group-selection-box-3d.tsx +25 -17
  34. package/src/components/editor/group-transform-shared.test.ts +126 -1
  35. package/src/components/editor/group-transform-shared.ts +79 -73
  36. package/src/components/editor/handles/handle-arrow.tsx +72 -2
  37. package/src/components/editor/handles/handle-hit-priority.test.tsx +63 -0
  38. package/src/components/editor/handles/handle-hit-priority.tsx +37 -0
  39. package/src/components/editor/handles/swallow-next-click.test.ts +28 -0
  40. package/src/components/editor/handles/use-handle-drag.ts +272 -131
  41. package/src/components/editor/index.tsx +316 -176
  42. package/src/components/editor/join-walls-pill.tsx +44 -0
  43. package/src/components/editor/node-action-menu.tsx +70 -70
  44. package/src/components/editor/node-arrow-handles.tsx +167 -14
  45. package/src/components/editor/open-wall-ends-3d-layer.tsx +273 -0
  46. package/src/components/editor/open-wall-ends-hint.tsx +89 -0
  47. package/src/components/editor/quick-measurement-hud.tsx +3 -2
  48. package/src/components/editor/registry-action-contributions.test.tsx +63 -0
  49. package/src/components/editor/registry-action-contributions.tsx +132 -0
  50. package/src/components/editor/room-controls.tsx +847 -0
  51. package/src/components/editor/room-floor-highlight.tsx +128 -0
  52. package/src/components/editor/room-handles-2d.tsx +354 -0
  53. package/src/components/editor/room-handles.tsx +675 -0
  54. package/src/components/editor/room-highlight.tsx +225 -0
  55. package/src/components/editor/room-labels.test.ts +357 -0
  56. package/src/components/editor/room-labels.tsx +990 -0
  57. package/src/components/editor/room-transform-ghost.tsx +256 -0
  58. package/src/components/editor/room-webgpu-materials.test.ts +27 -0
  59. package/src/components/editor/selection-manager.tsx +451 -317
  60. package/src/components/editor/slab-hole-highlights.tsx +3 -0
  61. package/src/components/editor/snapshot-capture-overlay.tsx +4 -0
  62. package/src/components/editor/snapshot-capture.test.ts +52 -4
  63. package/src/components/editor/snapshot-capture.ts +30 -5
  64. package/src/components/editor/thumbnail-generator.test.ts +308 -0
  65. package/src/components/editor/thumbnail-generator.tsx +1035 -229
  66. package/src/components/editor/use-floorplan-background-placement.ts +31 -11
  67. package/src/components/editor/vector-edge-extractor.tsx +121 -0
  68. package/src/components/editor/views/host-view.tsx +61 -0
  69. package/src/components/editor/views/plugins-view.tsx +670 -0
  70. package/src/components/editor/views/scene-region.tsx +59 -0
  71. package/src/components/editor/views/use-editor-views.ts +57 -0
  72. package/src/components/editor/views/view-bar.tsx +220 -0
  73. package/src/components/editor/wall-justification.test.ts +140 -0
  74. package/src/components/editor/wall-move-side-handles.tsx +114 -163
  75. package/src/components/editor/wall-opening-highlights.tsx +2 -3
  76. package/src/components/editor/wall-push-arrow.tsx +92 -0
  77. package/src/components/editor/wall-region-handles.tsx +509 -0
  78. package/src/components/editor/wall-snap-beacon-layer.tsx +3 -0
  79. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +8 -4
  80. package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
  81. package/src/components/editor-2d/floorplan-open-wall-ends-layer.test.tsx +86 -0
  82. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +290 -0
  83. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
  84. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +11 -1
  85. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +242 -126
  86. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +156 -0
  87. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +278 -0
  88. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +77 -0
  89. package/src/components/editor-2d/renderers/floorplan-draft-measurement.tsx +166 -0
  90. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +4 -1
  91. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +87 -8
  92. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +355 -245
  93. package/src/components/editor-2d/svg-paths.ts +12 -15
  94. package/src/components/procedural-items/inspector.tsx +40 -15
  95. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +97 -14
  96. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +18 -18
  97. package/src/components/systems/zone/zone-system.test.ts +47 -0
  98. package/src/components/systems/zone/zone-system.tsx +31 -66
  99. package/src/components/tools/__fixtures__/hosting.ts +10 -0
  100. package/src/components/tools/item/item-surface-placement.test.ts +3 -9
  101. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +3 -9
  102. package/src/components/tools/item/placement-history.test.tsx +474 -0
  103. package/src/components/tools/item/placement-strategies.ts +63 -47
  104. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +3 -3
  105. package/src/components/tools/item/use-draft-node.test.tsx +432 -1
  106. package/src/components/tools/item/use-draft-node.ts +207 -97
  107. package/src/components/tools/item/use-placement-coordinator.tsx +251 -111
  108. package/src/components/tools/paint-region/use-paint-tint.ts +37 -0
  109. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +466 -0
  110. package/src/components/tools/registry/item-surface-move.test.ts +10 -2
  111. package/src/components/tools/registry/item-surface-move.ts +46 -24
  112. package/src/components/tools/registry/item-surface-resolver.test.ts +2 -2
  113. package/src/components/tools/registry/move-registry-node-tool.tsx +26 -10
  114. package/src/components/tools/select/box-select-tool.tsx +2 -12
  115. package/src/components/tools/select/plane-box-select-tool.test.tsx +79 -0
  116. package/src/components/tools/select/plane-box-select-tool.tsx +35 -22
  117. package/src/components/tools/select/select-candidates.test.ts +37 -4
  118. package/src/components/tools/select/select-candidates.ts +15 -11
  119. package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
  120. package/src/components/tools/shared/affordance-dispatch.ts +13 -0
  121. package/src/components/tools/shared/cursor-sphere.tsx +36 -12
  122. package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
  123. package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
  124. package/src/components/tools/shared/pointer-support-cap.ts +17 -5
  125. package/src/components/tools/shared/surface-rejection.test.tsx +5 -4
  126. package/src/components/tools/shared/surface-rejection.tsx +14 -0
  127. package/src/components/tools/site/site-boundary-editor.tsx +1 -2
  128. package/src/components/tools/stair/stair-defaults.ts +0 -3
  129. package/src/components/tools/stair/stair-tool.tsx +90 -42
  130. package/src/components/tools/tool-manager.tsx +82 -23
  131. package/src/components/tools/wall/wall-drafting.test.ts +19 -4
  132. package/src/components/tools/zone/zone-tool.tsx +4 -12
  133. package/src/components/ui/action-menu/action-button.tsx +3 -0
  134. package/src/components/ui/action-menu/camera-actions.tsx +2 -1
  135. package/src/components/ui/action-menu/control-modes.tsx +2 -35
  136. package/src/components/ui/action-menu/measurement-control.tsx +3 -10
  137. package/src/components/ui/build-panel/build-panel.tsx +235 -0
  138. package/src/components/ui/command-palette/editor-commands.tsx +4 -30
  139. package/src/components/ui/command-palette/index.tsx +5 -24
  140. package/src/components/ui/controls/finish-swatch.tsx +110 -0
  141. package/src/components/ui/controls/material-paint-panel.tsx +101 -91
  142. package/src/components/ui/controls/material-picker.tsx +8 -67
  143. package/src/components/ui/controls/scene-material-list.tsx +4 -32
  144. package/src/components/ui/controls/select-control.tsx +65 -0
  145. package/src/components/ui/controls/slider-control.tsx +76 -31
  146. package/src/components/ui/floating-level-selector.tsx +9 -14
  147. package/src/components/ui/helpers/building-helper.tsx +4 -1
  148. package/src/components/ui/helpers/contextual-helper-panel.tsx +154 -70
  149. package/src/components/ui/helpers/helper-manager.test.tsx +69 -0
  150. package/src/components/ui/helpers/helper-manager.tsx +348 -19
  151. package/src/components/ui/helpers/item-helper.tsx +8 -0
  152. package/src/components/ui/helpers/registered-tool-helper.tsx +10 -33
  153. package/src/components/ui/icon-ref.tsx +45 -1
  154. package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
  155. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  156. package/src/components/ui/node-icon-image.tsx +16 -0
  157. package/src/components/ui/panels/collections/collections-popover.tsx +20 -14
  158. package/src/components/ui/panels/floor-foundation-panel.tsx +551 -0
  159. package/src/components/ui/panels/floor-opening-panel.tsx +143 -0
  160. package/src/components/ui/panels/mobile-panel-sheet.tsx +2 -2
  161. package/src/components/ui/panels/mobile-selection-bar.tsx +2 -2
  162. package/src/components/ui/panels/multi-field-value.test.ts +26 -3
  163. package/src/components/ui/panels/multi-field-value.ts +6 -3
  164. package/src/components/ui/panels/multi-height-mode.tsx +27 -21
  165. package/src/components/ui/panels/multi-parametric-inspector.tsx +52 -2
  166. package/src/components/ui/panels/multi-selection-panel.tsx +43 -4
  167. package/src/components/ui/panels/node-display.ts +8 -3
  168. package/src/components/ui/panels/panel-manager.tsx +103 -68
  169. package/src/components/ui/panels/panel-wrapper.tsx +78 -36
  170. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  171. package/src/components/ui/panels/parametric-inspector.tsx +7 -19
  172. package/src/components/ui/panels/reference-panel.tsx +2 -3
  173. package/src/components/ui/panels/room-construction-rows.tsx +774 -0
  174. package/src/components/ui/panels/room-name-options.test.ts +55 -0
  175. package/src/components/ui/panels/room-name-options.ts +47 -0
  176. package/src/components/ui/panels/room-openings-field.tsx +110 -0
  177. package/src/components/ui/panels/room-panel.tsx +725 -0
  178. package/src/components/ui/panels/shape-choice.tsx +58 -0
  179. package/src/components/ui/panels/wall-region-list.tsx +109 -0
  180. package/src/components/ui/primitives/color-dot.tsx +36 -24
  181. package/src/components/ui/primitives/shortcut-token.tsx +4 -0
  182. package/src/components/ui/primitives/sidebar.tsx +2 -0
  183. package/src/components/ui/right-stack.test.ts +29 -0
  184. package/src/components/ui/right-stack.tsx +145 -0
  185. package/src/components/ui/sidebar/icon-rail.tsx +5 -0
  186. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
  187. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +615 -372
  188. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +7 -7
  189. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +1 -1
  190. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -4
  191. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +1 -5
  192. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +197 -0
  193. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -5
  194. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +1 -5
  195. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +1 -5
  196. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -5
  197. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +24 -11
  198. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +1 -5
  199. package/src/components/ui/sidebar/panels/site-panel/index.tsx +176 -211
  200. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +30 -8
  201. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +1 -3
  202. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +4 -1
  203. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +1 -3
  204. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +1 -4
  205. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +1 -3
  206. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +21 -13
  207. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +1 -5
  208. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -5
  209. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +1 -5
  210. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +0 -27
  211. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +2 -3
  212. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -5
  213. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +1 -5
  214. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +9 -4
  215. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +0 -2
  216. package/src/components/ui/sidebar/tab-bar.tsx +10 -0
  217. package/src/components/ui/sidebar/use-plugin-panels.tsx +15 -5
  218. package/src/components/viewer/floorplan-compass-button.tsx +9 -1
  219. package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
  220. package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
  221. package/src/components/viewer/floorplan-preview.tsx +6 -18
  222. package/src/components/viewer/viewer-hover-label.tsx +34 -0
  223. package/src/components/viewer/viewer-rooms.tsx +175 -0
  224. package/src/components/viewer/viewer-scene-header.tsx +181 -145
  225. package/src/components/viewer/viewer-selection-manager.tsx +124 -0
  226. package/src/components/viewer/viewer-stage.tsx +2 -2
  227. package/src/components/viewer-zone-system.tsx +9 -9
  228. package/src/hooks/use-auto-save.integration.test.tsx +311 -0
  229. package/src/hooks/use-auto-save.ts +55 -14
  230. package/src/hooks/use-ceiling-events.test.ts +1 -0
  231. package/src/hooks/use-ceiling-events.ts +2 -0
  232. package/src/hooks/use-grid-events.test.ts +2 -0
  233. package/src/hooks/use-grid-events.ts +16 -11
  234. package/src/hooks/use-keyboard.test.ts +182 -0
  235. package/src/hooks/use-keyboard.ts +122 -91
  236. package/src/hooks/use-open-wall-ends.test.ts +99 -0
  237. package/src/hooks/use-open-wall-ends.ts +238 -0
  238. package/src/hooks/use-placement-notice.test.ts +48 -0
  239. package/src/hooks/use-placement-notice.ts +42 -0
  240. package/src/hooks/use-selected-room.ts +143 -0
  241. package/src/index.tsx +306 -39
  242. package/src/lib/catalog-panel-model.ts +39 -0
  243. package/src/lib/catalog-selection.test.ts +27 -0
  244. package/src/lib/catalog-selection.ts +8 -0
  245. package/src/lib/ceiling-edit-session.test.ts +321 -0
  246. package/src/lib/ceiling-edit-session.ts +206 -0
  247. package/src/lib/ceiling-region-session.test.ts +179 -0
  248. package/src/lib/contextual-help.test.ts +27 -1
  249. package/src/lib/contextual-help.ts +16 -9
  250. package/src/lib/continuation.test.ts +19 -1
  251. package/src/lib/continuation.ts +25 -4
  252. package/src/lib/door-interaction.ts +14 -0
  253. package/src/lib/editor-level-display.test.ts +96 -0
  254. package/src/lib/editor-level-display.ts +48 -0
  255. package/src/lib/editor-views.ts +144 -0
  256. package/src/lib/export-geometry-wait.test.ts +90 -0
  257. package/src/lib/floor-edit-session.ts +153 -0
  258. package/src/lib/floor-footprints.test.ts +563 -0
  259. package/src/lib/floor-footprints.ts +395 -0
  260. package/src/lib/floor-foundation-support.test.ts +283 -0
  261. package/src/lib/floor-opening-draft.ts +218 -0
  262. package/src/lib/floor-openings.test.ts +216 -0
  263. package/src/lib/floor-region-geometry.ts +191 -0
  264. package/src/lib/floor-region-session.test.ts +485 -0
  265. package/src/lib/floor-region-session.ts +589 -0
  266. package/src/lib/floor-region-snap.ts +101 -0
  267. package/src/lib/floor-step-hover.test.ts +32 -0
  268. package/src/lib/floorplan/annotation-visibility.ts +17 -0
  269. package/src/lib/floorplan/dimension-drive.test.ts +360 -0
  270. package/src/lib/floorplan/dimension-drive.ts +479 -0
  271. package/src/lib/floorplan/floorplan-export.test.ts +15 -9
  272. package/src/lib/floorplan/floorplan-export.tsx +32 -8
  273. package/src/lib/floorplan/floorplan-extension.ts +48 -0
  274. package/src/lib/floorplan/floorplan-mode.test.ts +4 -0
  275. package/src/lib/floorplan/floorplan-mode.ts +2 -0
  276. package/src/lib/floorplan/floorplan-pdfkit-document.ts +339 -18
  277. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +138 -8
  278. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +56 -25
  279. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +138 -0
  280. package/src/lib/floorplan/floorplan-readonly.ts +6 -0
  281. package/src/lib/floorplan/geometry.ts +13 -6
  282. package/src/lib/floorplan/index.ts +0 -9
  283. package/src/lib/floorplan/items.ts +5 -2
  284. package/src/lib/floorplan/marks.test.ts +213 -0
  285. package/src/lib/floorplan/marks.ts +238 -0
  286. package/src/lib/floorplan/open-wall-ends.test.ts +268 -0
  287. package/src/lib/floorplan/open-wall-ends.ts +162 -0
  288. package/src/lib/floorplan/pdf-fonts/GeistMono-Bold.ttf +0 -0
  289. package/src/lib/floorplan/pdf-fonts/GeistMono-Regular.ttf +0 -0
  290. package/src/lib/floorplan/pdf-fonts/LICENSE-GeistMono.txt +92 -0
  291. package/src/lib/floorplan/pdf-fonts/LICENSE-LiberationSans.txt +102 -0
  292. package/src/lib/floorplan/pdf-fonts/LiberationSans-Bold.ttf +0 -0
  293. package/src/lib/floorplan/pdf-fonts/LiberationSans-Regular.ttf +0 -0
  294. package/src/lib/floorplan/schedules.test.ts +189 -0
  295. package/src/lib/floorplan/schedules.ts +339 -0
  296. package/src/lib/floorplan/sheet-export.test.ts +100 -0
  297. package/src/lib/floorplan/sheet-export.ts +318 -0
  298. package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +779 -0
  299. package/src/lib/floorplan/site-plan/contours.test.ts +43 -0
  300. package/src/lib/floorplan/site-plan/contributors.ts +97 -0
  301. package/src/lib/floorplan/site-plan/coverage.ts +232 -0
  302. package/src/lib/floorplan/site-plan/drawing-type-switch.test.ts +15 -0
  303. package/src/lib/floorplan/site-plan/drawing-type-switch.tsx +86 -0
  304. package/src/lib/floorplan/site-plan/front-edge.test.ts +103 -0
  305. package/src/lib/floorplan/site-plan/front-edge.ts +156 -0
  306. package/src/lib/floorplan/site-plan/geometry.test.ts +275 -0
  307. package/src/lib/floorplan/site-plan/geometry.ts +557 -0
  308. package/src/lib/floorplan/site-plan/index.ts +71 -0
  309. package/src/lib/floorplan/site-plan/site-annotations.test.ts +366 -0
  310. package/src/lib/floorplan/site-plan/site-annotations.ts +1083 -0
  311. package/src/lib/floorplan/site-plan/site-parts.ts +379 -0
  312. package/src/lib/floorplan/site-plan/site-plan-layer.tsx +138 -0
  313. package/src/lib/floorplan/site-plan/street-name.test.ts +69 -0
  314. package/src/lib/floorplan/site-plan/street-name.ts +302 -0
  315. package/src/lib/floorplan/site-plan/yards.test.ts +69 -0
  316. package/src/lib/floorplan/types.ts +1 -27
  317. package/src/lib/fresh-planar-placement.test.ts +36 -0
  318. package/src/lib/fresh-planar-placement.ts +191 -36
  319. package/src/lib/geometry-script/author.ts +152 -0
  320. package/src/lib/geometry-script/client.ts +152 -0
  321. package/src/lib/geometry-script/protocol.ts +12 -0
  322. package/src/lib/gesture-lifecycle.test.ts +595 -0
  323. package/src/lib/gesture-lifecycle.ts +290 -0
  324. package/src/lib/glb-export-zone-holes.test.ts +41 -0
  325. package/src/lib/glb-export.test.ts +544 -16
  326. package/src/lib/glb-export.ts +256 -125
  327. package/src/lib/grid-frame.test.ts +19 -0
  328. package/src/lib/grid-frame.ts +18 -0
  329. package/src/lib/history.test.ts +6 -0
  330. package/src/lib/history.ts +7 -0
  331. package/src/lib/hud-title.ts +109 -0
  332. package/src/lib/hydration-gated-storage.ts +49 -0
  333. package/src/lib/ifc-export.test.ts +174 -0
  334. package/src/lib/ifc-export.ts +201 -0
  335. package/src/lib/ifc-parts.ts +15 -0
  336. package/src/lib/inspector-expanded.test.ts +44 -0
  337. package/src/lib/inspector-expanded.ts +36 -0
  338. package/src/lib/interaction/scope.ts +27 -1
  339. package/src/lib/keyboard-pan.test.ts +184 -0
  340. package/src/lib/keyboard-pan.ts +238 -0
  341. package/src/lib/live-gesture-round-trip.test.ts +360 -0
  342. package/src/lib/lot/__fixtures__/map-dossier-miami-shores.json +1 -0
  343. package/src/lib/lot/__fixtures__/map-dossier-stpete.json +1 -0
  344. package/src/lib/lot/clean-ring.test.ts +186 -0
  345. package/src/lib/lot/clean-ring.ts +217 -0
  346. package/src/lib/lot/dossier.test.ts +202 -0
  347. package/src/lib/lot/dossier.ts +498 -0
  348. package/src/lib/lot/drop-in.test.ts +326 -0
  349. package/src/lib/lot/drop-in.ts +407 -0
  350. package/src/lib/lot/index.ts +43 -0
  351. package/src/lib/lot/lot-patch.test.ts +340 -0
  352. package/src/lib/lot/lot-patch.ts +394 -0
  353. package/src/lib/lot/parcel-provider.ts +43 -0
  354. package/src/lib/lot/terrain.test.ts +161 -0
  355. package/src/lib/lot/terrain.ts +266 -0
  356. package/src/lib/material-catalog-model.ts +74 -0
  357. package/src/lib/material-paint-panel-model.ts +80 -0
  358. package/src/lib/material-paint.ts +4 -0
  359. package/src/lib/mezzanine-actions.test.ts +342 -0
  360. package/src/lib/mezzanine-draft.test.ts +501 -0
  361. package/src/lib/mezzanine-draft.ts +278 -0
  362. package/src/lib/mezzanine-messages.ts +26 -0
  363. package/src/lib/mezzanine-selection.test.ts +207 -0
  364. package/src/lib/mezzanine-stairs.ts +46 -0
  365. package/src/lib/model-export.ts +3 -1
  366. package/src/lib/node-action-movement.ts +17 -6
  367. package/src/lib/node-display-label.ts +37 -0
  368. package/src/lib/orbit-floor-clearance.test.ts +165 -0
  369. package/src/lib/orbit-floor-clearance.ts +106 -0
  370. package/src/lib/overlay-transform.test.ts +28 -0
  371. package/src/lib/overlay-transform.ts +12 -0
  372. package/src/lib/paint-commit-route.test.ts +37 -0
  373. package/src/lib/paint-commit-route.ts +17 -0
  374. package/src/lib/paint-eyedropper.test.tsx +165 -0
  375. package/src/lib/paint-eyedropper.ts +99 -0
  376. package/src/lib/paint-pass-through.test.ts +111 -0
  377. package/src/lib/paint-pass-through.ts +76 -0
  378. package/src/lib/paint-region-hover.ts +25 -0
  379. package/src/lib/paint-region-mode.ts +154 -0
  380. package/src/lib/paint-regions.test.ts +166 -0
  381. package/src/lib/paint-regions.ts +192 -0
  382. package/src/lib/paint-scope.test.ts +317 -35
  383. package/src/lib/paint-scope.ts +266 -25
  384. package/src/lib/panel-rows.ts +96 -0
  385. package/src/lib/panel-tool-options.test.ts +34 -0
  386. package/src/lib/panel-tool-options.ts +46 -0
  387. package/src/lib/parametric-node-update.ts +37 -0
  388. package/src/lib/plate-paint-affected.test.ts +300 -0
  389. package/src/lib/plate-paint-affected.ts +177 -0
  390. package/src/lib/plugin-chat.ts +50 -0
  391. package/src/lib/plugin-panels.test.ts +25 -0
  392. package/src/lib/plugin-panels.ts +94 -0
  393. package/src/lib/print-shell-compiler.ts +1 -0
  394. package/src/lib/registered-draft-snap.ts +12 -0
  395. package/src/lib/roof-duplication.ts +10 -2
  396. package/src/lib/room-assembly-overlay.test.ts +275 -0
  397. package/src/lib/room-assembly-overlay.ts +266 -0
  398. package/src/lib/room-built-on.test.ts +379 -0
  399. package/src/lib/room-built-on.ts +138 -0
  400. package/src/lib/room-construction-commands.ts +712 -0
  401. package/src/lib/room-construction.test.ts +746 -0
  402. package/src/lib/room-construction.ts +385 -0
  403. package/src/lib/room-controls-phase6.test.ts +630 -0
  404. package/src/lib/room-divide-session.ts +523 -0
  405. package/src/lib/room-handle-drag.ts +1068 -0
  406. package/src/lib/room-name-catalog.test.ts +90 -0
  407. package/src/lib/room-name-catalog.ts +168 -0
  408. package/src/lib/room-push-dimensions.test.ts +311 -0
  409. package/src/lib/room-push-dimensions.ts +251 -0
  410. package/src/lib/room-selection-commands.ts +62 -0
  411. package/src/lib/room-selection.test.ts +973 -0
  412. package/src/lib/room-selection.ts +417 -0
  413. package/src/lib/room-structure-commands.ts +94 -0
  414. package/src/lib/room-structure.test.ts +921 -0
  415. package/src/lib/room-transform-session.ts +570 -0
  416. package/src/lib/room-zone-routing.test.ts +294 -0
  417. package/src/lib/room-zone-routing.ts +197 -0
  418. package/src/lib/scene-clipboard.test.ts +364 -0
  419. package/src/lib/scene-clipboard.ts +229 -48
  420. package/src/lib/scene-material-refs.test.ts +79 -0
  421. package/src/lib/scene-material-refs.ts +79 -0
  422. package/src/lib/scene.test.ts +87 -0
  423. package/src/lib/scene.ts +39 -45
  424. package/src/lib/selection-routing.test.ts +129 -45
  425. package/src/lib/selection-routing.ts +53 -74
  426. package/src/lib/session-writes.test.ts +120 -0
  427. package/src/lib/session-writes.ts +205 -0
  428. package/src/lib/sidebar-panel.ts +62 -0
  429. package/src/lib/site-boundary.test.ts +4 -6
  430. package/src/lib/site-boundary.ts +1 -1
  431. package/src/lib/snapping-mode.test.ts +17 -2
  432. package/src/lib/snapping-mode.ts +19 -7
  433. package/src/lib/spatial-drag-plane.test.ts +53 -0
  434. package/src/lib/spatial-drag-plane.ts +19 -0
  435. package/src/lib/spatial-pointer-input.test.ts +72 -0
  436. package/src/lib/spatial-pointer-input.ts +62 -0
  437. package/src/lib/spatial-snap.test.ts +47 -0
  438. package/src/lib/terrace-draft.test.ts +145 -0
  439. package/src/lib/terrace-draft.ts +213 -0
  440. package/src/lib/terrain-panel-model.ts +117 -0
  441. package/src/lib/tool-cursor-icon.test.ts +16 -0
  442. package/src/lib/tool-cursor-icon.ts +11 -0
  443. package/src/lib/typing-target.ts +9 -0
  444. package/src/lib/units.ts +77 -39
  445. package/src/lib/vector-edges.test.ts +58 -0
  446. package/src/lib/vector-edges.ts +400 -0
  447. package/src/lib/viewer-selection.test.ts +577 -0
  448. package/src/lib/viewer-selection.ts +569 -0
  449. package/src/lib/wall-draw-variant.test.ts +71 -0
  450. package/src/lib/wall-draw-variant.ts +91 -0
  451. package/src/lib/wall-paint-region-session.ts +246 -0
  452. package/src/lib/wall-paint-region.test.ts +373 -0
  453. package/src/lib/wall-polygon-draft.test.ts +262 -0
  454. package/src/lib/wall-polygon-draft.ts +286 -0
  455. package/src/lib/wall-push.test.ts +453 -0
  456. package/src/lib/wall-region-face.ts +352 -0
  457. package/src/lib/wall-region-handles.test.ts +304 -0
  458. package/src/lib/wall-region-handles.ts +368 -0
  459. package/src/lib/wall-region-list.test.ts +119 -0
  460. package/src/lib/wall-region-list.ts +136 -0
  461. package/src/lib/wall-region-snap.ts +110 -0
  462. package/src/lib/window-interaction.ts +14 -0
  463. package/src/lib/world-grid-snap.test.ts +48 -0
  464. package/src/lib/world-grid-snap.ts +9 -5
  465. package/src/lib/zone-content.ts +4 -1
  466. package/src/lib/zone-name.test.ts +17 -0
  467. package/src/lib/zone-name.ts +17 -0
  468. package/src/store/opens-in-select-mode.test.ts +223 -0
  469. package/src/store/paint-mode.test.ts +188 -0
  470. package/src/store/rotation-snapping.test.ts +53 -0
  471. package/src/store/terrain-sculpt-mode.test.ts +19 -21
  472. package/src/store/tool-mode.test.ts +137 -6
  473. package/src/store/use-delete-confirmation.ts +28 -1
  474. package/src/store/use-drawing-view.test.ts +24 -3
  475. package/src/store/use-drawing-view.ts +29 -1
  476. package/src/store/use-editor.tsx +442 -323
  477. package/src/store/use-floorplan-draft-preview.ts +23 -1
  478. package/src/store/use-floorplan-marquee.ts +12 -2
  479. package/src/store/use-interaction-scope.ts +155 -6
  480. package/src/store/use-quick-measurement-hud.ts +3 -3
  481. package/src/store/use-stair-build-preview.ts +13 -5
  482. package/src/store/use-wall-move-ghosts.ts +4 -2
  483. package/src/store/use-wall-region-handles-selection.test.ts +222 -0
  484. package/src/store/use-wall-region-handles-selection.ts +139 -0
  485. package/src/store/view-layout.test.ts +175 -0
  486. package/src/store/view-layout.ts +196 -0
  487. package/src/test-utils/immediate-animation-frames.ts +16 -0
  488. package/src/test-utils/live-gesture-round-trip.ts +224 -0
  489. package/src/test-utils/selection-harness.ts +82 -0
  490. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +0 -499
  491. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -333
  492. package/src/components/tools/fence/fence-drafting.test.ts +0 -100
  493. package/src/components/tools/fence/fence-drafting.ts +0 -266
  494. package/src/components/ui/sidebar/panels/plugins-panel.tsx +0 -218
  495. package/src/components/viewer/viewer-units-panel.tsx +0 -116
  496. package/src/lib/floorplan/stairs.ts +0 -489
  497. package/src/lib/level-duplication.test.ts +0 -144
  498. package/src/lib/level-duplication.ts +0 -195
@@ -0,0 +1,109 @@
1
+ import { type IconRef, nodeRegistry, type TerrainVerb } from '@pascal-app/core'
2
+ import type { ContinuationMode } from './continuation'
3
+ import { getWallDrawVariantInfo, wallDrawVariantOf } from './wall-draw-variant'
4
+
5
+ /** The contextual helper panel's header: what is in hand, and its key. */
6
+ export type HudTitle = {
7
+ label: string
8
+ icon?: IconRef
9
+ shortcut?: string
10
+ }
11
+
12
+ const url = (src: string): IconRef => ({ kind: 'url', src })
13
+
14
+ /** Tools with a global arming key (`use-keyboard.ts`). */
15
+ const TOOL_SHORTCUTS: Record<string, string> = {
16
+ wall: 'B',
17
+ measurement: 'M',
18
+ }
19
+
20
+ export const SELECT_HUD_TITLE: HudTitle = {
21
+ label: 'Select',
22
+ icon: url('/icons/select.webp'),
23
+ shortcut: 'V',
24
+ }
25
+ export const PAINT_HUD_TITLE: HudTitle = { label: 'Paint', icon: url('/icons/paint.webp') }
26
+ export const ERASE_HUD_TITLE: HudTitle = {
27
+ label: 'Erase',
28
+ icon: { kind: 'iconify', name: 'lucide:eraser' },
29
+ }
30
+ export const PICK_MATERIAL_HUD_TITLE: HudTitle = {
31
+ label: 'Pick material',
32
+ icon: { kind: 'iconify', name: 'lucide:pipette' },
33
+ }
34
+ export const ROTATE_HUD_TITLE: HudTitle = { label: 'Rotate', icon: url('/icons/rotate.webp') }
35
+ export const MOVE_SELECTION_HUD_TITLE: HudTitle = {
36
+ label: 'Move selection',
37
+ icon: url('/icons/pan.webp'),
38
+ }
39
+ export const MOVE_ROOM_HUD_TITLE: HudTitle = { label: 'Move room', icon: url('/icons/room.webp') }
40
+ export const RESIZE_HUD_TITLE: HudTitle = {
41
+ label: 'Resize',
42
+ icon: url('/icons/resize.webp'),
43
+ }
44
+ export const DIVIDE_ROOM_HUD_TITLE: HudTitle = {
45
+ label: 'Divide room',
46
+ icon: url('/icons/divide-room.webp'),
47
+ }
48
+ export const MEZZANINE_HUD_TITLE: HudTitle = {
49
+ label: 'Mezzanine',
50
+ icon: url('/icons/mezzanine.webp'),
51
+ }
52
+ export const OPENING_HUD_TITLE: HudTitle = {
53
+ label: 'Cut opening',
54
+ icon: { kind: 'iconify', name: 'lucide:square-dashed' },
55
+ }
56
+ export const TERRACE_HUD_TITLE: HudTitle = {
57
+ label: 'Terrace',
58
+ icon: url('/icons/floor.webp'),
59
+ }
60
+ export const SMART_MEASURE_HUD_TITLE: HudTitle = {
61
+ label: 'Smart measure',
62
+ icon: url('/icons/measure.webp'),
63
+ shortcut: 'M',
64
+ }
65
+
66
+ const PAINT_REGION_LABELS = {
67
+ rectangle: 'Paint a rectangle',
68
+ polygon: 'Paint a shape',
69
+ } as const
70
+
71
+ export function paintRegionHudTitle(mode: keyof typeof PAINT_REGION_LABELS): HudTitle {
72
+ return { label: PAINT_REGION_LABELS[mode], icon: url('/icons/paint.webp') }
73
+ }
74
+
75
+ export function terrainHudTitle(verb: TerrainVerb): HudTitle {
76
+ const label =
77
+ verb === 'raise'
78
+ ? 'Raise terrain'
79
+ : verb === 'lower'
80
+ ? 'Lower terrain'
81
+ : verb === 'flatten'
82
+ ? 'Level terrain'
83
+ : 'Smooth terrain'
84
+ return { label, icon: url(`/icons/terrain-${verb}.webp`) }
85
+ }
86
+
87
+ /** A kind's registry name and icon, optionally behind a verb ("Move door"). */
88
+ export function nodeKindHudTitle(kind: string, verb?: string): HudTitle | null {
89
+ const presentation = nodeRegistry.get(kind)?.presentation
90
+ if (!presentation) return null
91
+ const name = presentation.label
92
+ return {
93
+ label: verb ? `${verb} ${name.toLowerCase()}` : name,
94
+ icon: presentation.icon,
95
+ shortcut: verb ? undefined : TOOL_SHORTCUTS[kind],
96
+ }
97
+ }
98
+
99
+ /**
100
+ * The armed build tool. The wall tool is named after the Rooms variant it draws
101
+ * (Rectangle room / Polygon room / Walls) — the tile the user picked.
102
+ */
103
+ export function toolHudTitle(tool: string, wallMode: ContinuationMode): HudTitle | null {
104
+ if (tool === 'wall') {
105
+ const variant = getWallDrawVariantInfo(wallDrawVariantOf(wallMode))
106
+ return { label: variant.title, icon: url(variant.iconSrc), shortcut: TOOL_SHORTCUTS.wall }
107
+ }
108
+ return nodeKindHudTitle(tool)
109
+ }
@@ -0,0 +1,49 @@
1
+ import { createJSONStorage, type PersistStorage } from 'zustand/middleware'
2
+
3
+ /**
4
+ * localStorage for a persisted store that rehydrates on mount (`skipHydration`).
5
+ *
6
+ * `persist` saves the whole stored state on every `set`. A save before the
7
+ * store has hydrated would replace the stored preferences with the defaults
8
+ * before they are read (a host component's own mount effect runs first), so
9
+ * those writes stay in memory and rehydrate merges the stored blob over them:
10
+ * host code that sets a persisted field before then has to wait for
11
+ * `persist.onFinishHydration`. After that, a save of the blob this tab already
12
+ * holds is skipped, so an update that changes no preference never replaces
13
+ * what another tab saved since. A blob that cannot be read counts as none:
14
+ * hydration still finishes, on the defaults, and the next save replaces it.
15
+ */
16
+ export function hydrationGatedStorage<S>(
17
+ hasHydrated: () => boolean,
18
+ ): PersistStorage<S> | undefined {
19
+ let held: string | null = null
20
+ const storage = createJSONStorage<S>(() => {
21
+ const local = window.localStorage
22
+ return {
23
+ getItem: (name) => {
24
+ held = local.getItem(name)
25
+ return held
26
+ },
27
+ setItem: (name, value) => {
28
+ if (!hasHydrated() || value === held) return
29
+ held = value
30
+ local.setItem(name, value)
31
+ },
32
+ removeItem: (name) => {
33
+ held = null
34
+ local.removeItem(name)
35
+ },
36
+ }
37
+ })
38
+ if (!storage) return undefined
39
+ return {
40
+ ...storage,
41
+ getItem: (name) => {
42
+ try {
43
+ return storage.getItem(name)
44
+ } catch {
45
+ return null
46
+ }
47
+ },
48
+ }
49
+ }
@@ -0,0 +1,174 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { type AnyNode, sceneRegistry } from '@pascal-app/core'
3
+ import * as THREE from 'three'
4
+ import { collectIfcMeshes, exportPreparedSceneToIfc, ifcFileName } from './ifc-export'
5
+
6
+ const node = (id: string, type: string, parentId: string | null = null) =>
7
+ ({
8
+ object: 'node',
9
+ id,
10
+ type,
11
+ parentId,
12
+ visible: true,
13
+ metadata: {},
14
+ children: [],
15
+ }) as unknown as AnyNode
16
+
17
+ function identity(object: THREE.Object3D, id: string) {
18
+ object.userData = { pascalId: id }
19
+ return object
20
+ }
21
+
22
+ describe('collectIfcMeshes', () => {
23
+ const nodes: Record<string, AnyNode> = {
24
+ level_a: node('level_a', 'level'),
25
+ wall_a: { ...node('wall_a', 'wall', 'level_a'), start: [0, 0], end: [1, 0] } as AnyNode,
26
+ item_a: node('item_a', 'item', 'level_a'),
27
+ }
28
+
29
+ function scene() {
30
+ const root = new THREE.Group()
31
+ const level = identity(new THREE.Group(), 'level_a')
32
+ level.position.y = 3
33
+ root.add(level)
34
+
35
+ const wall = identity(
36
+ new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshStandardMaterial()),
37
+ 'wall_a',
38
+ )
39
+ level.add(wall)
40
+
41
+ const item = identity(new THREE.Group(), 'item_a')
42
+ item.position.set(2, 0, 0)
43
+ level.add(item)
44
+ const geometry = new THREE.BoxGeometry(1, 1, 1)
45
+ // BoxGeometry has six material groups (one per face).
46
+ const red = new THREE.MeshStandardMaterial({ color: new THREE.Color('#ff0000') })
47
+ const glass = new THREE.MeshStandardMaterial({ transparent: true, opacity: 0.25 })
48
+ const materials = [red, red, red, red, glass, glass]
49
+ const child = new THREE.Mesh(geometry, materials)
50
+ item.add(child)
51
+ const hidden = new THREE.Mesh(new THREE.BoxGeometry(), new THREE.MeshStandardMaterial())
52
+ hidden.visible = false
53
+ item.add(hidden)
54
+ return root
55
+ }
56
+
57
+ test('bakes world transforms per owning node and skips parametric kinds', () => {
58
+ const { meshes: parts, renderedNodeIds } = collectIfcMeshes(scene(), nodes)
59
+ expect([...parts.keys()]).toEqual(['item_a'])
60
+ expect([...renderedNodeIds].sort()).toEqual(['item_a', 'wall_a'])
61
+ const itemParts = parts.get('item_a')!
62
+ expect(itemParts).toHaveLength(6)
63
+ const xs: number[] = []
64
+ const ys: number[] = []
65
+ for (const part of itemParts) {
66
+ expect(part.indices!.length % 3).toBe(0)
67
+ expect(part.positions.length).toBe(4 * 3)
68
+ for (let i = 0; i < part.positions.length; i += 3) {
69
+ xs.push(part.positions[i]!)
70
+ ys.push(part.positions[i + 1]!)
71
+ }
72
+ }
73
+ expect(Math.min(...xs)).toBeCloseTo(1.5)
74
+ expect(Math.max(...xs)).toBeCloseTo(2.5)
75
+ expect(Math.min(...ys)).toBeCloseTo(2.5)
76
+ expect(Math.max(...ys)).toBeCloseTo(3.5)
77
+ const [r, g, b] = itemParts[0]!.color!
78
+ expect([r, g, b].map((channel) => Math.round(channel * 1000) / 1000)).toEqual([1, 0, 0])
79
+ expect(itemParts[5]!.opacity).toBe(0.25)
80
+ })
81
+
82
+ test('feeds the IFC writer', () => {
83
+ const { data, warnings } = exportPreparedSceneToIfc(scene(), nodes, { projectName: 'Demo' })
84
+ expect(data).toContain("FILE_SCHEMA(('IFC4'));")
85
+ expect(data).toContain('IFCFURNISHINGELEMENT(')
86
+ expect(data).toContain('IFCTRIANGULATEDFACESET(')
87
+ expect(warnings).toEqual([])
88
+ })
89
+ })
90
+
91
+ describe('export warnings', () => {
92
+ test('flag nodes drawn in the editor that reached the file without geometry', () => {
93
+ const nodes: Record<string, AnyNode> = {
94
+ level_a: node('level_a', 'level'),
95
+ item_ghost: node('item_ghost', 'item', 'level_a'),
96
+ marker_a: node('marker_a', 'item', 'level_a'),
97
+ }
98
+ // Live editor objects: the item draws on the scene layer; the marker only
99
+ // on an overlay layer, like the spawn point.
100
+ const drawn = new THREE.Mesh(new THREE.BoxGeometry(), new THREE.MeshStandardMaterial())
101
+ const overlay = new THREE.Mesh(new THREE.BoxGeometry(), new THREE.MeshStandardMaterial())
102
+ overlay.layers.set(5)
103
+ sceneRegistry.nodes.set('item_ghost', drawn)
104
+ sceneRegistry.nodes.set('marker_a', overlay)
105
+ try {
106
+ const { warnings } = exportPreparedSceneToIfc(new THREE.Group(), nodes, {})
107
+ expect(warnings).toEqual([
108
+ '1 object has no exportable geometry and was left out of the IFC file.',
109
+ ])
110
+ } finally {
111
+ sceneRegistry.nodes.delete('item_ghost')
112
+ sceneRegistry.nodes.delete('marker_a')
113
+ }
114
+ })
115
+ })
116
+
117
+ describe('visibility default', () => {
118
+ test('a hidden wall is left out when no option is given, as scene preparation does', () => {
119
+ const nodes: Record<string, AnyNode> = {
120
+ level_a: node('level_a', 'level'),
121
+ wall_hidden: {
122
+ ...node('wall_hidden', 'wall', 'level_a'),
123
+ visible: false,
124
+ start: [0, 0],
125
+ end: [3, 0],
126
+ } as AnyNode,
127
+ }
128
+ expect(exportPreparedSceneToIfc(new THREE.Group(), nodes, {}).data).not.toContain('IFCWALL(')
129
+ expect(
130
+ exportPreparedSceneToIfc(new THREE.Group(), nodes, { onlyVisible: false }).data,
131
+ ).toContain('IFCWALL(')
132
+ })
133
+ })
134
+
135
+ describe('ifcFileName', () => {
136
+ test('uses a filesystem-safe project name', () => {
137
+ expect(ifcFileName('House: v2/final', 'model')).toBe('House- v2-final.ifc')
138
+ expect(ifcFileName(' ', 'model_2026-09-30')).toBe('model_2026-09-30.ifc')
139
+ expect(ifcFileName(undefined, 'model')).toBe('model.ifc')
140
+ })
141
+ })
142
+
143
+ test('material groups retain their surface owner and semantic subpart', () => {
144
+ const owner = node('item_owner', 'item')
145
+ const child = node('item_child', 'item', owner.id)
146
+ const root = identity(new THREE.Group(), owner.id)
147
+ const geometry = new THREE.BoxGeometry()
148
+ const material = new THREE.MeshStandardMaterial()
149
+ const mesh = new THREE.Mesh(
150
+ geometry,
151
+ Array.from({ length: 6 }, () => material),
152
+ )
153
+ mesh.userData = {
154
+ surfaceNodeIds: [child.id, child.id, owner.id, owner.id, owner.id, owner.id],
155
+ pascalIfcRole: 'railing',
156
+ pascalIfcParts: [
157
+ { start: 0, count: 6, role: 'railing' },
158
+ { start: 6, count: 30, role: 'handrail' },
159
+ ],
160
+ }
161
+ root.add(mesh)
162
+ const { meshes, renderedNodeIds } = collectIfcMeshes(root, {
163
+ [owner.id]: owner,
164
+ [child.id]: child,
165
+ })
166
+ expect(meshes.get(child.id)).toHaveLength(2)
167
+ expect(meshes.get(owner.id)).toHaveLength(4)
168
+ expect(meshes.get(child.id)![0]!.role).toBe('railing')
169
+ expect(meshes.get(child.id)![1]!.role).toBe('handrail')
170
+ expect(meshes.get(owner.id)!.every((part) => part.role === 'handrail')).toBe(true)
171
+ expect(renderedNodeIds.has(child.id)).toBe(true)
172
+ geometry.dispose()
173
+ material.dispose()
174
+ })
@@ -0,0 +1,201 @@
1
+ import { type AnyNode, bakePolicyOf, type Collection, sceneRegistry } from '@pascal-app/core'
2
+ import { buildIfcExport, type IfcMeshPart } from '@pascal-app/ifc-converter/export'
3
+ import { SCENE_LAYER } from '@pascal-app/viewer'
4
+ import * as THREE from 'three'
5
+ import { isIfcRolePart } from './ifc-parts'
6
+
7
+ // Kinds the IFC writer always rebuilds parametrically from node data; their
8
+ // rendered triangles are never read, so skip copying them. (Slabs are copied:
9
+ // pools and legacy zero-thickness slabs export their rendered shape.)
10
+ const PARAMETRIC_KINDS = new Set(['site', 'building', 'level', 'wall', 'ceiling', 'zone'])
11
+
12
+ function owningNodeId(object: THREE.Object3D): string | null {
13
+ for (let current: THREE.Object3D | null = object; current; current = current.parent) {
14
+ const id = current.userData?.pascalId
15
+ if (typeof id === 'string') return id
16
+ }
17
+ return null
18
+ }
19
+
20
+ function materialColor(
21
+ material: THREE.Material | undefined,
22
+ ): Pick<IfcMeshPart, 'color' | 'opacity'> {
23
+ const color = (material as THREE.MeshStandardMaterial | undefined)?.color
24
+ const rgb = color ? color.getRGB(new THREE.Color(), THREE.SRGBColorSpace) : null
25
+ return {
26
+ color: rgb ? [rgb.r, rgb.g, rgb.b] : undefined,
27
+ opacity: material?.transparent ? material.opacity : 1,
28
+ }
29
+ }
30
+
31
+ /**
32
+ * World-space triangles per node from a prepared export scene (identity
33
+ * stamped, instancing expanded, deformation frozen, reflected winding fixed).
34
+ * Each material group becomes one part with only the vertices it uses.
35
+ * `renderedNodeIds` lists every node that drew at least one triangle.
36
+ */
37
+ export function collectIfcMeshes(
38
+ root: THREE.Object3D,
39
+ nodes: Record<string, AnyNode>,
40
+ ): { meshes: Map<string, IfcMeshPart[]>; renderedNodeIds: Set<string> } {
41
+ root.updateMatrixWorld(true)
42
+ const parts = new Map<string, IfcMeshPart[]>()
43
+ const renderedNodeIds = new Set<string>()
44
+ const vertex = new THREE.Vector3()
45
+
46
+ root.traverseVisible((object) => {
47
+ const mesh = object as THREE.Mesh
48
+ if (!mesh.isMesh) return
49
+ const position = mesh.geometry?.getAttribute('position')
50
+ if (!position || position.count < 3) return
51
+ const nodeId = owningNodeId(mesh)
52
+ const node = nodeId ? nodes[nodeId] : undefined
53
+ if (!node) return
54
+ renderedNodeIds.add(node.id)
55
+ if (PARAMETRIC_KINDS.has(node.type)) return
56
+
57
+ const geometry = mesh.geometry
58
+ const drawStart = geometry.drawRange.start
59
+ const drawEnd = Math.min(
60
+ geometry.index ? geometry.index.count : position.count,
61
+ drawStart + geometry.drawRange.count,
62
+ )
63
+ const indexAt = (i: number) => (geometry.index ? geometry.index.getX(i) : i)
64
+ const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
65
+ const groups =
66
+ Array.isArray(mesh.material) && geometry.groups.length > 0
67
+ ? geometry.groups
68
+ : [{ start: drawStart, count: drawEnd - drawStart, materialIndex: 0 }]
69
+
70
+ const roleParts = Array.isArray(mesh.userData.pascalIfcParts)
71
+ ? mesh.userData.pascalIfcParts.filter(isIfcRolePart)
72
+ : []
73
+ const semanticGroups = groups.flatMap((group) => {
74
+ const end = group.start + group.count
75
+ const boundaries = [
76
+ ...new Set([
77
+ group.start,
78
+ end,
79
+ ...roleParts
80
+ .flatMap((part: { start: number; count: number }) => [
81
+ part.start,
82
+ part.start + part.count,
83
+ ])
84
+ .filter((offset: number) => offset > group.start && offset < end),
85
+ ]),
86
+ ].sort((a, b) => a - b)
87
+ return boundaries.slice(0, -1).map((start, index) => ({
88
+ ...group,
89
+ start,
90
+ count: boundaries[index + 1]! - start,
91
+ role: roleParts.find(
92
+ (part: { start: number; count: number }) =>
93
+ start >= part.start && start < part.start + part.count,
94
+ )?.role as string | undefined,
95
+ }))
96
+ })
97
+ for (const group of semanticGroups) {
98
+ const surfaceId = mesh.userData.surfaceNodeIds?.[group.materialIndex ?? 0]
99
+ const owner = typeof surfaceId === 'string' && nodes[surfaceId] ? nodes[surfaceId]! : node
100
+ let role: string | undefined = group.role
101
+ for (let current: THREE.Object3D | null = mesh; current; current = current.parent) {
102
+ if (role === undefined && typeof current.userData.pascalIfcRole === 'string') {
103
+ role = current.userData.pascalIfcRole
104
+ break
105
+ }
106
+ }
107
+ const material = materials[group.materialIndex ?? 0]
108
+ if (!material || material.visible === false) continue
109
+ const start = Math.max(group.start, drawStart)
110
+ const end = Math.min(group.start + group.count, drawEnd)
111
+ const remap = new Map<number, number>()
112
+ const positions: number[] = []
113
+ const indices: number[] = []
114
+ for (let i = start; i + 2 < end; i += 3) {
115
+ for (let corner = 0; corner < 3; corner++) {
116
+ const source = indexAt(i + corner)
117
+ let target = remap.get(source)
118
+ if (target === undefined) {
119
+ target = remap.size
120
+ remap.set(source, target)
121
+ vertex.fromBufferAttribute(position, source).applyMatrix4(mesh.matrixWorld)
122
+ positions.push(vertex.x, vertex.y, vertex.z)
123
+ }
124
+ indices.push(target)
125
+ }
126
+ }
127
+ if (indices.length === 0) continue
128
+ const list = parts.get(owner.id) ?? []
129
+ list.push({
130
+ role,
131
+ positions: new Float32Array(positions),
132
+ indices: new Uint32Array(indices),
133
+ ...materialColor(material),
134
+ })
135
+ parts.set(owner.id, list)
136
+ renderedNodeIds.add(owner.id)
137
+ }
138
+ })
139
+ return { meshes: parts, renderedNodeIds }
140
+ }
141
+
142
+ /**
143
+ * Whether the live editor draws `nodeId`: a visible scene-layer mesh under its
144
+ * registered object, or a collectively rendered (`bake: 'replace'`) kind whose
145
+ * instances live outside that object. Overlay-only markers (spawn point,
146
+ * camera shots) draw nothing here.
147
+ */
148
+ function drawnInEditor(nodeId: string, nodes: Record<string, AnyNode>): boolean {
149
+ const node = nodes[nodeId]
150
+ const live = sceneRegistry.nodes.get(nodeId)
151
+ if (!node || !live) return false
152
+ if (bakePolicyOf(node.type) === 'replace') return true
153
+ let drawn = false
154
+ live.traverseVisible((object) => {
155
+ const mesh = object as THREE.Mesh
156
+ if (drawn || !mesh.isMesh || !object.layers.isEnabled(SCENE_LAYER)) return
157
+ const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
158
+ const count = mesh.geometry?.getAttribute('position')?.count ?? 0
159
+ if (count >= 3 && materials.some((material) => material?.visible !== false)) drawn = true
160
+ })
161
+ return drawn
162
+ }
163
+
164
+ export function ifcFileName(projectName: string | undefined, fallback: string): string {
165
+ const base = projectName?.replace(/[\\/:*?"<>|]+/g, '-').trim()
166
+ return `${base || fallback}.ifc`
167
+ }
168
+
169
+ export function exportPreparedSceneToIfc(
170
+ root: THREE.Object3D,
171
+ nodes: Record<string, AnyNode>,
172
+ options: {
173
+ collections?: Readonly<Record<string, Collection>>
174
+ projectName?: string
175
+ onlyVisible?: boolean
176
+ excludedNodeTypes?: readonly string[]
177
+ },
178
+ ): { data: string; warnings: string[] } {
179
+ const { meshes, renderedNodeIds } = collectIfcMeshes(root, nodes)
180
+ const { ifc, summary } = buildIfcExport({
181
+ nodes,
182
+ collections: options.collections,
183
+ meshes,
184
+ projectName: options.projectName,
185
+ // Same default as scene preparation, which already pruned hidden meshes.
186
+ onlyVisible: options.onlyVisible ?? true,
187
+ excludedNodeTypes: options.excludedNodeTypes,
188
+ })
189
+ // Report everything that is on screen yet did not reach the file; markers
190
+ // that draw nothing (spawn point, camera shots) are skipped silently.
191
+ const missing = summary.skipped.filter(
192
+ (skip) => renderedNodeIds.has(skip.nodeId) || drawnInEditor(skip.nodeId, nodes),
193
+ ).length
194
+ const warnings =
195
+ missing > 0
196
+ ? [
197
+ `${missing} ${missing === 1 ? 'object has' : 'objects have'} no exportable geometry and ${missing === 1 ? 'was' : 'were'} left out of the IFC file.`,
198
+ ]
199
+ : []
200
+ return { data: ifc, warnings }
201
+ }
@@ -0,0 +1,15 @@
1
+ export type IfcRolePart = { start: number; count: number; role: string }
2
+
3
+ export function isIfcRolePart(value: unknown): value is IfcRolePart {
4
+ if (!value || typeof value !== 'object') return false
5
+ const part = value as IfcRolePart
6
+ return (
7
+ Number.isInteger(part.start) &&
8
+ part.start >= 0 &&
9
+ part.start % 3 === 0 &&
10
+ Number.isInteger(part.count) &&
11
+ part.count > 0 &&
12
+ part.count % 3 === 0 &&
13
+ typeof part.role === 'string'
14
+ )
15
+ }
@@ -0,0 +1,44 @@
1
+ import { afterEach, expect, test } from 'bun:test'
2
+
3
+ const saved = Object.getOwnPropertyDescriptor(globalThis, 'localStorage')
4
+ afterEach(() => {
5
+ if (saved) Object.defineProperty(globalThis, 'localStorage', saved)
6
+ else Reflect.deleteProperty(globalThis, 'localStorage')
7
+ })
8
+
9
+ function stubStorage(storage: Partial<Storage>) {
10
+ Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: storage })
11
+ }
12
+
13
+ let fresh = 0
14
+ /** A new instance of the module, reading storage as a page load would. */
15
+ const load = () => import(`./inspector-expanded.ts?load=${++fresh}`)
16
+
17
+ test('expanding once opens every later panel expanded, across a reload, until folded', async () => {
18
+ const values = new Map<string, string>()
19
+ stubStorage({
20
+ getItem: (key) => values.get(key) ?? null,
21
+ setItem: (key, value) => void values.set(key, value),
22
+ })
23
+ const first = await load()
24
+ expect(first.useInspectorExpanded.getState().expanded).toBe(false)
25
+ first.useInspectorExpanded.getState().setExpanded(true)
26
+ expect((await load()).useInspectorExpanded.getState().expanded).toBe(true)
27
+ first.useInspectorExpanded.getState().setExpanded(false)
28
+ expect((await load()).useInspectorExpanded.getState().expanded).toBe(false)
29
+ })
30
+
31
+ test('without usable storage the panel opens folded and expanding still works', async () => {
32
+ stubStorage({
33
+ getItem: () => {
34
+ throw new Error('blocked')
35
+ },
36
+ setItem: () => {
37
+ throw new Error('blocked')
38
+ },
39
+ })
40
+ const { useInspectorExpanded } = await load()
41
+ expect(useInspectorExpanded.getState().expanded).toBe(false)
42
+ useInspectorExpanded.getState().setExpanded(true)
43
+ expect(useInspectorExpanded.getState().expanded).toBe(true)
44
+ })
@@ -0,0 +1,36 @@
1
+ import { create } from 'zustand'
2
+
3
+ // One choice for every right-side inspector panel (room, wall, item…): opened
4
+ // expanded or folded to its header, kept per viewer until they fold it again.
5
+ // Its own key, read when the module loads: the editor's persisted preferences
6
+ // rehydrate after the first render, too late for a panel that opens with the
7
+ // editor. Storage can be missing or refuse (private windows, blocked site
8
+ // data); the panel then opens folded, as it always has.
9
+ const STORAGE_KEY = 'pascal-editor:inspector-expanded'
10
+
11
+ function readExpanded(): boolean {
12
+ try {
13
+ return globalThis.localStorage?.getItem(STORAGE_KEY) === 'true'
14
+ } catch {
15
+ return false
16
+ }
17
+ }
18
+
19
+ function writeExpanded(expanded: boolean) {
20
+ try {
21
+ globalThis.localStorage?.setItem(STORAGE_KEY, String(expanded))
22
+ } catch {
23
+ // Not remembered this time; the panel still follows the choice until reload.
24
+ }
25
+ }
26
+
27
+ export const useInspectorExpanded = create<{
28
+ expanded: boolean
29
+ setExpanded: (expanded: boolean) => void
30
+ }>((set) => ({
31
+ expanded: readExpanded(),
32
+ setExpanded: (expanded) => {
33
+ writeExpanded(expanded)
34
+ set({ expanded })
35
+ },
36
+ }))
@@ -19,10 +19,35 @@ export type ReshapeDriver = 'tool' | 'floorplan'
19
19
  // node, one in-flight reshape. Grouping them as sub-states of `reshaping`
20
20
  // (rather than four sibling scopes) keeps the union small while still making
21
21
  // "curving and hole-editing at once" unrepresentable.
22
- export type ReshapeKind = 'curve' | 'hole' | 'endpoint' | 'boundary' | 'control-point' | 'tangent'
22
+ export type ReshapeKind =
23
+ | 'curve'
24
+ | 'hole'
25
+ | 'endpoint'
26
+ | 'boundary'
27
+ | 'control-point'
28
+ | 'tangent'
29
+ // A kind's own reshape (wall 'split'): mounted from `def.affordanceTools[reshape]`.
30
+ | 'split'
23
31
 
24
32
  export type InteractionScope =
25
33
  | { kind: 'idle' }
34
+ | {
35
+ kind: 'room-divide'
36
+ nodeId: string
37
+ levelId: string
38
+ // The placed points. The first lies on the room's boundary (an open cut)
39
+ // or inside the room (an island loop); the rest are free points.
40
+ points: [number, number][]
41
+ // The boundary the first point sits on, when it sits on one.
42
+ startBoundaryId?: string
43
+ // The live point under the pointer and what clicking it would do: start
44
+ // the path, add a point, finish on the boundary (`edge`), or close the loop.
45
+ end: [number, number] | null
46
+ endKind?: 'start' | 'point' | 'edge' | 'close'
47
+ endBoundaryId?: string
48
+ valid: boolean
49
+ message?: string
50
+ }
26
51
  // Placing a fresh node (catalog/preset/build tool). `pressDrag` is the
27
52
  // gizmo press-drag flavour (commit on release) vs click-to-place.
28
53
  | {
@@ -105,6 +130,7 @@ export function isActive(scope: InteractionScope): scope is ActiveInteractionSco
105
130
  // target no single existing node.
106
131
  export function scopeNodeId(scope: InteractionScope): string | null {
107
132
  switch (scope.kind) {
133
+ case 'room-divide':
108
134
  case 'placing':
109
135
  case 'moving':
110
136
  case 'handle-drag':