@pascal-app/editor 0.8.0 → 0.9.2

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 (350) hide show
  1. package/package.json +15 -13
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +168 -0
  3. package/src/components/editor/bake-exporter.tsx +47 -0
  4. package/src/components/editor/custom-camera-controls.tsx +795 -28
  5. package/src/components/editor/editor-layout-mobile.tsx +8 -3
  6. package/src/components/editor/editor-layout-v2.tsx +76 -51
  7. package/src/components/editor/export-manager.tsx +69 -32
  8. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +182 -0
  10. package/src/components/editor/first-person/build-collider-world.ts +228 -83
  11. package/src/components/editor/first-person-controls.tsx +943 -27
  12. package/src/components/editor/floating-action-menu.tsx +676 -208
  13. package/src/components/editor/floating-building-action-menu.tsx +2 -2
  14. package/src/components/editor/floorplan-background-selection.test.ts +58 -0
  15. package/src/components/editor/floorplan-background-selection.ts +3 -2
  16. package/src/components/editor/floorplan-panel.tsx +6916 -12137
  17. package/src/components/editor/grid.tsx +197 -36
  18. package/src/components/editor/group-actions.ts +457 -0
  19. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  20. package/src/components/editor/group-move-3d.ts +397 -0
  21. package/src/components/editor/group-rotate-handle.tsx +420 -0
  22. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  23. package/src/components/editor/group-transform-shared.test.ts +396 -0
  24. package/src/components/editor/group-transform-shared.ts +489 -0
  25. package/src/components/editor/handles/handle-arrow.tsx +581 -0
  26. package/src/components/editor/handles/use-handle-drag.ts +246 -0
  27. package/src/components/editor/index.tsx +412 -162
  28. package/src/components/editor/measurement-pill.tsx +115 -0
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +1646 -0
  31. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  32. package/src/components/editor/quick-measurement-card.tsx +70 -0
  33. package/src/components/editor/quick-measurement-hud.tsx +28 -0
  34. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  35. package/src/components/editor/selection-manager.tsx +1106 -483
  36. package/src/components/editor/site-edge-labels.tsx +45 -21
  37. package/src/components/editor/slab-hole-highlights.tsx +474 -0
  38. package/src/components/editor/snapshot-capture-overlay.tsx +369 -155
  39. package/src/components/editor/three-context-bridge.ts +22 -0
  40. package/src/components/editor/thumbnail-generator.tsx +140 -41
  41. package/src/components/editor/use-floorplan-background-placement.ts +190 -35
  42. package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
  43. package/src/components/editor/use-floorplan-scene-data.ts +19 -7
  44. package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
  45. package/src/components/editor/wall-measurement-label.tsx +14 -19
  46. package/src/components/editor/wall-move-side-handles.tsx +870 -0
  47. package/src/components/editor/wall-opening-highlights.tsx +181 -0
  48. package/src/components/editor/wall-snap-beacon-layer.tsx +304 -0
  49. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +15 -6
  50. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
  51. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
  52. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  53. package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
  54. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  55. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
  56. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  57. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +392 -0
  58. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +857 -0
  59. package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
  60. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +77 -0
  61. package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
  62. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
  63. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
  64. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
  65. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -66
  66. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +62 -0
  67. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
  68. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +3325 -0
  69. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +24 -9
  70. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  71. package/src/components/editor-2d/svg-paths.ts +10 -2
  72. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +439 -57
  73. package/src/components/systems/ceiling/ceiling-system.tsx +109 -12
  74. package/src/components/systems/roof/roof-edit-system.tsx +1851 -22
  75. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  76. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  77. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  78. package/src/components/systems/zone/zone-system.tsx +42 -13
  79. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  80. package/src/components/tools/elevator/elevator-tool.tsx +375 -0
  81. package/src/components/tools/elevator/move-elevator-tool.tsx +263 -0
  82. package/src/components/tools/fence/fence-drafting.ts +141 -27
  83. package/src/components/tools/item/move-tool.tsx +45 -105
  84. package/src/components/tools/item/placement-math.test.ts +22 -0
  85. package/src/components/tools/item/placement-math.ts +59 -7
  86. package/src/components/tools/item/placement-strategies.ts +432 -16
  87. package/src/components/tools/item/placement-types.ts +22 -1
  88. package/src/components/tools/item/use-draft-node.ts +51 -2
  89. package/src/components/tools/item/use-placement-coordinator.tsx +1399 -256
  90. package/src/components/tools/registry/move-registry-node-tool.tsx +1064 -0
  91. package/src/components/tools/roof/roof-tool.tsx +350 -28
  92. package/src/components/tools/select/box-select-state.ts +88 -0
  93. package/src/components/tools/select/box-select-tool.tsx +442 -520
  94. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  95. package/src/components/tools/select/marquee-geometry.ts +155 -0
  96. package/src/components/tools/select/plane-box-select-tool.tsx +570 -0
  97. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  98. package/src/components/tools/select/select-candidates.test.ts +109 -0
  99. package/src/components/tools/select/select-candidates.ts +85 -0
  100. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  101. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  102. package/src/components/tools/shared/drag-bounding-box.tsx +195 -0
  103. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  104. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  105. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  106. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  107. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  108. package/src/components/tools/shared/placement-box.tsx +305 -0
  109. package/src/components/tools/shared/polygon-editor.tsx +866 -175
  110. package/src/components/tools/shared/segment-angle.ts +56 -0
  111. package/src/components/tools/site/site-boundary-editor.tsx +520 -16
  112. package/src/components/tools/site/site-flag-model.ts +18 -0
  113. package/src/components/tools/stair/stair-defaults.ts +2 -1
  114. package/src/components/tools/stair/stair-tool.tsx +342 -37
  115. package/src/components/tools/tool-manager.tsx +239 -62
  116. package/src/components/tools/wall/wall-drafting.test.ts +330 -0
  117. package/src/components/tools/wall/wall-drafting.ts +248 -171
  118. package/src/components/tools/wall/wall-snap-geometry.test.ts +123 -0
  119. package/src/components/tools/wall/wall-snap-geometry.ts +331 -0
  120. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  121. package/src/components/tools/zone/zone-tool.tsx +86 -82
  122. package/src/components/ui/action-menu/action-button.tsx +21 -1
  123. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  124. package/src/components/ui/action-menu/control-modes.tsx +40 -170
  125. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  126. package/src/components/ui/action-menu/index.tsx +3 -117
  127. package/src/components/ui/action-menu/measurement-control.tsx +188 -0
  128. package/src/components/ui/action-menu/structure-tools.tsx +27 -93
  129. package/src/components/ui/action-menu/view-toggles.tsx +279 -90
  130. package/src/components/ui/command-palette/editor-commands.tsx +20 -15
  131. package/src/components/ui/command-palette/index.tsx +5 -3
  132. package/src/components/ui/controls/material-paint-panel.tsx +152 -0
  133. package/src/components/ui/controls/material-picker.tsx +87 -177
  134. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  135. package/src/components/ui/controls/metric-control.tsx +142 -46
  136. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  137. package/src/components/ui/controls/slider-control.tsx +88 -38
  138. package/src/components/ui/floating-level-selector.tsx +47 -10
  139. package/src/components/ui/helpers/building-helper.tsx +10 -23
  140. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  141. package/src/components/ui/helpers/helper-manager.tsx +235 -23
  142. package/src/components/ui/helpers/item-helper.tsx +28 -32
  143. package/src/components/ui/helpers/registered-tool-helper.tsx +59 -0
  144. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  145. package/src/components/ui/icon-ref.tsx +47 -0
  146. package/src/components/ui/item-catalog/catalog-items.tsx +40 -1
  147. package/src/components/ui/item-catalog/item-catalog.tsx +16 -36
  148. package/src/components/ui/level-duplicate-dialog.tsx +2 -1
  149. package/src/components/ui/panels/node-display.ts +17 -16
  150. package/src/components/ui/panels/panel-manager.tsx +55 -58
  151. package/src/components/ui/panels/panel-wrapper.tsx +238 -12
  152. package/src/components/ui/panels/parametric-inspector.tsx +482 -0
  153. package/src/components/ui/panels/reference-panel.tsx +54 -19
  154. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  155. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  156. package/src/components/ui/primitives/sidebar.tsx +1 -0
  157. package/src/components/ui/scene-loader.tsx +1 -3
  158. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  159. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  160. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  161. package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
  162. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  163. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +122 -41
  164. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +66 -7
  165. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +267 -0
  166. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  167. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +9 -5
  168. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +86 -0
  169. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  170. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  171. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +92 -0
  172. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  173. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +3 -3
  174. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +86 -0
  175. package/src/components/ui/sidebar/panels/site-panel/index.tsx +42 -21
  176. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  177. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  178. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +140 -0
  179. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +48 -8
  180. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +132 -0
  181. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +9 -5
  182. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +86 -0
  183. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +7 -1
  184. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  185. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +112 -39
  186. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  187. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  188. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  189. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  190. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +51 -2
  191. package/src/components/ui/sidebar/tab-bar.tsx +75 -1
  192. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  193. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  194. package/src/components/ui/snap-target-badge.tsx +88 -0
  195. package/src/components/viewer-overlay.tsx +253 -61
  196. package/src/components/viewer-zone-system.tsx +7 -2
  197. package/src/hooks/use-auto-save.test.ts +14 -0
  198. package/src/hooks/use-auto-save.ts +64 -10
  199. package/src/hooks/use-ceiling-events.ts +176 -0
  200. package/src/hooks/use-drag-action.ts +127 -0
  201. package/src/hooks/use-keyboard.ts +493 -95
  202. package/src/hooks/use-selection.ts +64 -0
  203. package/src/index.tsx +451 -6
  204. package/src/lib/active-placement-surface.ts +35 -0
  205. package/src/lib/ceiling-plan-snap.ts +24 -0
  206. package/src/lib/constants.ts +6 -2
  207. package/src/lib/contextual-help.test.ts +112 -0
  208. package/src/lib/contextual-help.ts +144 -0
  209. package/src/lib/continuation.ts +64 -0
  210. package/src/lib/direct-manipulation.test.ts +192 -0
  211. package/src/lib/direct-manipulation.ts +109 -0
  212. package/src/lib/editor-api.ts +50 -0
  213. package/src/lib/elevator-support.ts +96 -0
  214. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  215. package/src/lib/floorplan/apply-alignment.ts +117 -0
  216. package/src/lib/floorplan/floorplan-export.tsx +364 -0
  217. package/src/lib/floorplan/geometry.ts +53 -0
  218. package/src/lib/floorplan/index.ts +10 -0
  219. package/src/lib/floorplan/items.ts +5 -2
  220. package/src/lib/floorplan/plan-coords.ts +21 -0
  221. package/src/lib/floorplan/selection-tool.ts +40 -0
  222. package/src/lib/floorplan/stairs.ts +12 -1
  223. package/src/lib/fresh-planar-placement.test.ts +339 -0
  224. package/src/lib/fresh-planar-placement.ts +130 -0
  225. package/src/lib/glb-export.test.ts +441 -0
  226. package/src/lib/glb-export.ts +874 -0
  227. package/src/lib/history.ts +11 -1
  228. package/src/lib/interaction/hot-set.test.ts +133 -0
  229. package/src/lib/interaction/hot-set.ts +67 -0
  230. package/src/lib/interaction/overlay-policy.test.ts +51 -0
  231. package/src/lib/interaction/overlay-policy.ts +59 -0
  232. package/src/lib/interaction/scope.ts +211 -0
  233. package/src/lib/level-duplication.ts +54 -42
  234. package/src/lib/level-selection.ts +2 -2
  235. package/src/lib/material-paint.ts +227 -48
  236. package/src/lib/measurement-kind.test.ts +30 -0
  237. package/src/lib/measurement-kind.ts +19 -0
  238. package/src/lib/measurement-label.test.ts +47 -0
  239. package/src/lib/measurement-label.ts +68 -0
  240. package/src/lib/measurement-parser.ts +116 -0
  241. package/src/lib/measurements.test.ts +183 -0
  242. package/src/lib/measurements.ts +199 -0
  243. package/src/lib/paint-scope.test.ts +454 -0
  244. package/src/lib/paint-scope.ts +461 -0
  245. package/src/lib/placement-drag-release.ts +23 -0
  246. package/src/lib/placement-metadata.ts +29 -0
  247. package/src/lib/planar-cursor-placement.test.ts +100 -0
  248. package/src/lib/planar-cursor-placement.ts +42 -0
  249. package/src/lib/plugin-panels.test.ts +19 -0
  250. package/src/lib/plugin-panels.ts +91 -0
  251. package/src/lib/quick-measurement.test.ts +77 -0
  252. package/src/lib/quick-measurement.ts +109 -0
  253. package/src/lib/roof-duplication.ts +7 -7
  254. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  255. package/src/lib/roof-wall-hit.ts +164 -0
  256. package/src/lib/scene-clipboard.test.ts +196 -0
  257. package/src/lib/scene-clipboard.ts +332 -0
  258. package/src/lib/scene.ts +40 -8
  259. package/src/lib/selection-routing.test.ts +298 -0
  260. package/src/lib/selection-routing.ts +175 -0
  261. package/src/lib/sfx/index.ts +1 -0
  262. package/src/lib/sfx/movement-tick.test.ts +65 -0
  263. package/src/lib/sfx/movement-tick.ts +18 -0
  264. package/src/lib/sfx-bus.ts +69 -10
  265. package/src/lib/sfx-player.test.ts +124 -0
  266. package/src/lib/sfx-player.ts +148 -55
  267. package/src/lib/site-boundary.ts +1 -0
  268. package/src/lib/slab-plan-snap.test.ts +93 -0
  269. package/src/lib/slab-plan-snap.ts +108 -0
  270. package/src/lib/snapping-mode.test.ts +138 -0
  271. package/src/lib/snapping-mode.ts +179 -0
  272. package/src/lib/stair-duplication.ts +4 -4
  273. package/src/lib/stair-levels.test.ts +146 -0
  274. package/src/lib/stair-levels.ts +177 -0
  275. package/src/lib/surface-plan-snap.test.ts +71 -0
  276. package/src/lib/surface-plan-snap.ts +256 -0
  277. package/src/lib/use-linear-display.ts +40 -0
  278. package/src/lib/world-grid-snap.ts +231 -0
  279. package/src/lib/zone-content.ts +130 -0
  280. package/src/store/use-alignment-guides.ts +21 -0
  281. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  282. package/src/store/use-editor.tsx +767 -114
  283. package/src/store/use-facing-pose.ts +44 -0
  284. package/src/store/use-fence-curve-draft.ts +21 -0
  285. package/src/store/use-floorplan-draft-preview.ts +101 -0
  286. package/src/store/use-floorplan-marquee.ts +50 -0
  287. package/src/store/use-interaction-scope.test.ts +186 -0
  288. package/src/store/use-interaction-scope.ts +132 -0
  289. package/src/store/use-measurement-draft.test.ts +530 -0
  290. package/src/store/use-measurement-draft.ts +543 -0
  291. package/src/store/use-opening-guides.ts +41 -0
  292. package/src/store/use-placement-preview.ts +39 -0
  293. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  294. package/src/store/use-quick-measurement-hud.ts +77 -0
  295. package/src/store/use-segment-draft-chain.ts +28 -0
  296. package/src/store/use-stair-build-preview.ts +43 -0
  297. package/src/store/use-wall-move-ghosts.ts +36 -0
  298. package/src/store/use-wall-snap-indicator.ts +34 -0
  299. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  300. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  301. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  302. package/src/components/tools/building/move-building-tool.tsx +0 -157
  303. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  304. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  305. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  306. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -264
  307. package/src/components/tools/column/column-tool.tsx +0 -97
  308. package/src/components/tools/column/move-column-tool.tsx +0 -105
  309. package/src/components/tools/door/door-math.ts +0 -110
  310. package/src/components/tools/door/door-tool.tsx +0 -324
  311. package/src/components/tools/door/move-door-tool.tsx +0 -412
  312. package/src/components/tools/fence/curve-fence-tool.tsx +0 -178
  313. package/src/components/tools/fence/fence-tool.tsx +0 -346
  314. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
  315. package/src/components/tools/fence/move-fence-tool.tsx +0 -302
  316. package/src/components/tools/item/item-tool.tsx +0 -26
  317. package/src/components/tools/roof/move-roof-tool.tsx +0 -364
  318. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  319. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  320. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  321. package/src/components/tools/slab/slab-tool.tsx +0 -322
  322. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  323. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  324. package/src/components/tools/wall/curve-wall-tool.tsx +0 -178
  325. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
  326. package/src/components/tools/wall/move-wall-tool.tsx +0 -358
  327. package/src/components/tools/wall/wall-tool.tsx +0 -332
  328. package/src/components/tools/window/move-window-tool.tsx +0 -447
  329. package/src/components/tools/window/window-math.ts +0 -117
  330. package/src/components/tools/window/window-tool.tsx +0 -332
  331. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  332. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  333. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  334. package/src/components/ui/panels/ceiling-panel.tsx +0 -249
  335. package/src/components/ui/panels/column-panel.tsx +0 -759
  336. package/src/components/ui/panels/door-panel.tsx +0 -1299
  337. package/src/components/ui/panels/fence-panel.tsx +0 -222
  338. package/src/components/ui/panels/item-panel.tsx +0 -306
  339. package/src/components/ui/panels/paint-panel.tsx +0 -163
  340. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  341. package/src/components/ui/panels/roof-panel.tsx +0 -280
  342. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  343. package/src/components/ui/panels/slab-panel.tsx +0 -250
  344. package/src/components/ui/panels/spawn-panel.tsx +0 -161
  345. package/src/components/ui/panels/stair-panel.tsx +0 -577
  346. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  347. package/src/components/ui/panels/wall-panel.tsx +0 -273
  348. package/src/components/ui/panels/window-panel.tsx +0 -968
  349. package/src/contexts/presets-context.tsx +0 -121
  350. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import { useViewer } from '@pascal-app/viewer'
3
+ import { getSceneTheme, useViewer } from '@pascal-app/viewer'
4
4
  import { type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
5
5
  import useEditor from '../../store/use-editor'
6
6
  import { MobileTabBar } from '../ui/sidebar/mobile-tab-bar'
@@ -56,8 +56,8 @@ export function EditorLayoutMobile({
56
56
  const activePanel = useEditor((s) => s.activeSidebarPanel)
57
57
  const setActivePanel = useEditor((s) => s.setActiveSidebarPanel)
58
58
  const panelSheetHeight = useEditor((s) => s.mobilePanelSheetHeight)
59
- const theme = useViewer((s) => s.theme)
60
- const viewerBg = theme === 'light' ? VIEWER_BG_LIGHT : VIEWER_BG_DARK
59
+ const isDark = useViewer((s) => getSceneTheme(s.sceneTheme).appearance === 'dark')
60
+ const viewerBg = isDark ? VIEWER_BG_DARK : VIEWER_BG_LIGHT
61
61
 
62
62
  const middleRef = useRef<HTMLDivElement>(null)
63
63
  const sheetRef = useRef<BottomSheetHandle>(null)
@@ -159,6 +159,11 @@ export function EditorLayoutMobile({
159
159
  const expandedThreshold = Math.max(SHEET_HANDLE_PX, defaultPx * 0.5)
160
160
  if (current > expandedThreshold) {
161
161
  sheetRef.current?.snapTo(SHEET_HANDLE_PX)
162
+ // Closing the sheet disarms any build tool back to select
163
+ const { mode, setMode } = useEditor.getState()
164
+ if (mode === 'build') {
165
+ setMode('select')
166
+ }
162
167
  } else {
163
168
  sheetRef.current?.snapTo(defaultPx)
164
169
  }
@@ -5,12 +5,14 @@ import { useIsMobile } from '../../hooks/use-mobile'
5
5
  import useEditor from '../../store/use-editor'
6
6
 
7
7
  import { useSidebarStore } from '../ui/primitives/sidebar'
8
- import { type SidebarTab, TabBar } from '../ui/sidebar/tab-bar'
8
+ import { IconRail, type SidebarTab } from '../ui/sidebar/tab-bar'
9
9
  import { EditorLayoutMobile } from './editor-layout-mobile'
10
10
 
11
11
  const SIDEBAR_MIN_WIDTH = 300
12
12
  const SIDEBAR_MAX_WIDTH = 800
13
13
  const SIDEBAR_COLLAPSE_THRESHOLD = 220
14
+ // Matches the `w-14` rail in <IconRail>; the resize math is relative to it.
15
+ const RAIL_WIDTH = 56
14
16
 
15
17
  // ── Left column: resizable panel with tab bar ────────────────────────────────
16
18
 
@@ -33,7 +35,6 @@ function LeftColumn({
33
35
  const setActivePanel = useEditor((s) => s.setActiveSidebarPanel)
34
36
 
35
37
  const isResizing = useRef(false)
36
- const isExpanding = useRef(false)
37
38
 
38
39
  // Ensure active panel is a valid tab
39
40
  useEffect(() => {
@@ -51,6 +52,15 @@ function LeftColumn({
51
52
  }
52
53
  }, [activePanel])
53
54
 
55
+ // Closing (collapsing) the sidebar disarms any build tool back to select
56
+ useEffect(() => {
57
+ if (!isCollapsed) return
58
+ const { mode, setMode } = useEditor.getState()
59
+ if (mode === 'build') {
60
+ setMode('select')
61
+ }
62
+ }, [isCollapsed])
63
+
54
64
  const handleResizerDown = useCallback(
55
65
  (e: React.PointerEvent) => {
56
66
  e.preventDefault()
@@ -62,35 +72,40 @@ function LeftColumn({
62
72
  [setIsDragging],
63
73
  )
64
74
 
65
- const handleGrabDown = useCallback(
66
- (e: React.PointerEvent) => {
67
- e.preventDefault()
68
- isExpanding.current = true
69
- setIsDragging(true)
70
- document.body.style.cursor = 'col-resize'
71
- document.body.style.userSelect = 'none'
75
+ // Rail click: reopen a collapsed panel, collapse when re-clicking the open
76
+ // tab, otherwise switch tabs. Reopening clamps below-min persisted widths
77
+ // up to the minimum so the panel always returns to a usable size.
78
+ const handleRailClick = useCallback(
79
+ (id: string) => {
80
+ if (isCollapsed) {
81
+ setIsCollapsed(false)
82
+ if (width < SIDEBAR_MIN_WIDTH) setWidth(SIDEBAR_MIN_WIDTH)
83
+ setActivePanel(id)
84
+ return
85
+ }
86
+ if (id === activePanel) {
87
+ setIsCollapsed(true)
88
+ return
89
+ }
90
+ setActivePanel(id)
72
91
  },
73
- [setIsDragging],
92
+ [isCollapsed, width, activePanel, setIsCollapsed, setWidth, setActivePanel],
74
93
  )
75
94
 
76
95
  useEffect(() => {
77
96
  const handlePointerMove = (e: PointerEvent) => {
78
- if (isResizing.current) {
79
- const newWidth = e.clientX
80
- if (newWidth < SIDEBAR_COLLAPSE_THRESHOLD) {
81
- setIsCollapsed(true)
82
- } else {
83
- setIsCollapsed(false)
84
- setWidth(Math.max(SIDEBAR_MIN_WIDTH, Math.min(newWidth, SIDEBAR_MAX_WIDTH)))
85
- }
86
- } else if (isExpanding.current && e.clientX > 60) {
97
+ if (!isResizing.current) return
98
+ // Rail occupies the leftmost 48px; the panel starts after it.
99
+ const newWidth = e.clientX - RAIL_WIDTH
100
+ if (newWidth < SIDEBAR_COLLAPSE_THRESHOLD) {
101
+ setIsCollapsed(true)
102
+ } else {
87
103
  setIsCollapsed(false)
88
- setWidth(Math.max(SIDEBAR_MIN_WIDTH, Math.min(e.clientX, SIDEBAR_MAX_WIDTH)))
104
+ setWidth(Math.max(SIDEBAR_MIN_WIDTH, Math.min(newWidth, SIDEBAR_MAX_WIDTH)))
89
105
  }
90
106
  }
91
107
  const handlePointerUp = () => {
92
108
  isResizing.current = false
93
- isExpanding.current = false
94
109
  setIsDragging(false)
95
110
  document.body.style.cursor = ''
96
111
  document.body.style.userSelect = ''
@@ -103,37 +118,36 @@ function LeftColumn({
103
118
  }
104
119
  }, [setWidth, setIsCollapsed, setIsDragging])
105
120
 
106
- if (isCollapsed) {
107
- return (
108
- <div
109
- className="relative h-full w-2 flex-shrink-0 cursor-col-resize transition-colors hover:bg-primary/20"
110
- onPointerDown={handleGrabDown}
111
- title="Expand sidebar"
112
- />
113
- )
114
- }
115
-
116
121
  return (
117
- <div
118
- className="relative z-10 flex h-full flex-shrink-0 flex-col bg-sidebar text-sidebar-foreground"
119
- style={{
120
- width,
121
- transition: isDragging ? 'none' : 'width 150ms ease',
122
- }}
123
- >
124
- <TabBar activeTab={activePanel} onTabChange={setActivePanel} tabs={tabs} />
125
- <div className="relative flex flex-1 flex-col overflow-hidden">
126
- {renderTabContent(activePanel)}
127
- {sidebarOverlay && <div className="absolute inset-0 z-50">{sidebarOverlay}</div>}
128
- </div>
122
+ <div className="relative z-10 flex h-full flex-shrink-0 bg-sidebar text-sidebar-foreground">
123
+ <IconRail
124
+ activeTab={activePanel}
125
+ collapsed={isCollapsed}
126
+ onIconClick={handleRailClick}
127
+ tabs={tabs}
128
+ />
129
+ {!isCollapsed && (
130
+ <div
131
+ className="relative flex h-full flex-col"
132
+ style={{
133
+ width,
134
+ transition: isDragging ? 'none' : 'width 150ms ease',
135
+ }}
136
+ >
137
+ <div className="relative flex flex-1 flex-col overflow-hidden">
138
+ {renderTabContent(activePanel)}
139
+ {sidebarOverlay && <div className="absolute inset-0 z-50">{sidebarOverlay}</div>}
140
+ </div>
129
141
 
130
- {/* Resize handle + hit area */}
131
- <div
132
- className="absolute inset-y-0 -right-3 z-[100] flex w-6 cursor-col-resize items-center justify-center"
133
- onPointerDown={handleResizerDown}
134
- >
135
- <div className="h-8 w-1 rounded-full bg-neutral-500" />
136
- </div>
142
+ {/* Resize handle + hit area */}
143
+ <div
144
+ className="absolute inset-y-0 -right-3 z-[100] flex w-6 cursor-col-resize items-center justify-center"
145
+ onPointerDown={handleResizerDown}
146
+ >
147
+ <div className="h-8 w-1 rounded-full bg-neutral-500" />
148
+ </div>
149
+ </div>
150
+ )}
137
151
  </div>
138
152
  )
139
153
  }
@@ -145,11 +159,13 @@ function RightColumn({
145
159
  toolbarRight,
146
160
  children,
147
161
  overlays,
162
+ stageOverlay,
148
163
  }: {
149
164
  toolbarLeft?: ReactNode
150
165
  toolbarRight?: ReactNode
151
166
  children: ReactNode
152
167
  overlays?: ReactNode
168
+ stageOverlay?: ReactNode
153
169
  }) {
154
170
  return (
155
171
  <div
@@ -169,10 +185,16 @@ function RightColumn({
169
185
  )}
170
186
  {/* Canvas area */}
171
187
  <div className="relative flex-1 overflow-hidden">{children}</div>
172
- {/* Overlays scoped to the viewer column */}
188
+ {/* Stage overlay replaces the canvas visually (e.g. studio gallery)
189
+ while keeping it mounted. Sits below the viewer toolbar (z-20) so
190
+ the stage switch stays reachable. */}
191
+ {stageOverlay && <div className="absolute inset-0 z-10">{stageOverlay}</div>}
192
+ {/* Overlays scoped to the viewer column. `data-viewer-bounds` marks the
193
+ draggable region the floating inspector clamps itself to. */}
173
194
  {overlays && (
174
195
  <div
175
196
  className="pointer-events-none absolute inset-0 z-30"
197
+ data-viewer-bounds
176
198
  style={{ transform: 'translateZ(0)' }}
177
199
  >
178
200
  {overlays}
@@ -193,6 +215,7 @@ export interface EditorLayoutV2Props {
193
215
  viewerToolbarRight?: ReactNode
194
216
  viewerContent: ReactNode
195
217
  overlays?: ReactNode
218
+ stageOverlay?: ReactNode
196
219
  }
197
220
 
198
221
  export function EditorLayoutV2({
@@ -204,6 +227,7 @@ export function EditorLayoutV2({
204
227
  viewerToolbarRight,
205
228
  viewerContent,
206
229
  overlays,
230
+ stageOverlay,
207
231
  }: EditorLayoutV2Props) {
208
232
  const isCaptureMode = useEditor((s) => s.isCaptureMode)
209
233
  const isMobile = useIsMobile()
@@ -239,6 +263,7 @@ export function EditorLayoutV2({
239
263
  )}
240
264
  <RightColumn
241
265
  overlays={overlays}
266
+ stageOverlay={stageOverlay}
242
267
  toolbarLeft={isCaptureMode ? undefined : viewerToolbarLeft}
243
268
  toolbarRight={isCaptureMode ? undefined : viewerToolbarRight}
244
269
  >
@@ -1,11 +1,35 @@
1
1
  'use client'
2
2
 
3
+ import { emitter, useScene } from '@pascal-app/core'
3
4
  import { useViewer } from '@pascal-app/viewer'
4
5
  import { useThree } from '@react-three/fiber'
5
6
  import { useEffect } from 'react'
6
- import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js'
7
+ import * as THREE from 'three'
7
8
  import { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter.js'
8
9
  import { STLExporter } from 'three/examples/jsm/exporters/STLExporter.js'
10
+ import { exportSceneToGlb, nextFrames, prepareSceneForExport } from '../../lib/glb-export'
11
+
12
+ // prepareSceneForExport neutralises container meshes (door/window hitbox roots,
13
+ // material-less renderables) with an attribute-less geometry — GLTFExporter
14
+ // emits those as plain transform nodes, but STL/OBJExporter read
15
+ // `position.count` unconditionally and crash. Swap in a geometry with an empty
16
+ // (count-0) position so they iterate zero vertices instead. Shared: the export
17
+ // scene is a throwaway clone, only its geometry *ref* is swapped.
18
+ const EMPTY_POSITION_GEOMETRY = new THREE.BufferGeometry()
19
+ EMPTY_POSITION_GEOMETRY.setAttribute(
20
+ 'position',
21
+ new THREE.Float32BufferAttribute(new Float32Array(0), 3),
22
+ )
23
+
24
+ function ensurePositionAttributes(root: THREE.Object3D) {
25
+ root.traverse((object) => {
26
+ const renderable = object as THREE.Mesh & { isLine?: boolean; isPoints?: boolean }
27
+ if (!(renderable.isMesh || renderable.isLine || renderable.isPoints)) return
28
+ if (!renderable.geometry?.getAttribute('position')) {
29
+ renderable.geometry = EMPTY_POSITION_GEOMETRY
30
+ }
31
+ })
32
+ }
9
33
 
10
34
  export function ExportManager() {
11
35
  const scene = useThree((state) => state.scene)
@@ -22,40 +46,53 @@ export function ExportManager() {
22
46
 
23
47
  const date = new Date().toISOString().split('T')[0]
24
48
 
25
- if (format === 'stl') {
26
- const exporter = new STLExporter()
27
- const result = exporter.parse(sceneGroup, { binary: true })
28
- const blob = new Blob([result], { type: 'model/stl' })
29
- downloadBlob(blob, `model_${date}.stl`)
30
- return
31
- }
49
+ // Signal export so instanced kinds (trees/flowers/grass) swap their
50
+ // invisible proxy for real, exportable geometry, then wait for the
51
+ // commit before cloning the scene graph (same dance as BakeExporter
52
+ // without it every plant exports as its raycast collider, a white box).
53
+ useViewer.getState().setExporting(true)
54
+ try {
55
+ await nextFrames()
32
56
 
33
- if (format === 'obj') {
34
- const exporter = new OBJExporter()
35
- const result = exporter.parse(sceneGroup)
36
- const blob = new Blob([result], { type: 'model/obj' })
37
- downloadBlob(blob, `model_${date}.obj`)
38
- return
39
- }
57
+ if (format === 'glb') {
58
+ const buffer = await exportSceneToGlb(sceneGroup, useScene.getState().nodes)
59
+ const blob = new Blob([buffer], { type: 'model/gltf-binary' })
60
+ downloadBlob(blob, `model_${date}.glb`)
61
+ return
62
+ }
40
63
 
41
- // Default: GLB export (existing behavior)
42
- const exporter = new GLTFExporter()
64
+ // Hide editor affordances that live on the scene layer (selection handles,
65
+ // ceiling/site brackets) and let wall-cutout reveal all walls — the same
66
+ // synchronous capture path thumbnails use. We clone the scene inside the
67
+ // window, so the export snapshots the clean building, then restore.
68
+ emitter.emit('thumbnail:before-capture', undefined)
69
+ let prepared: ReturnType<typeof prepareSceneForExport>
70
+ try {
71
+ prepared = prepareSceneForExport(sceneGroup, useScene.getState().nodes)
72
+ } finally {
73
+ emitter.emit('thumbnail:after-capture', undefined)
74
+ }
75
+ const { scene: exportScene } = prepared
76
+ ensurePositionAttributes(exportScene)
43
77
 
44
- return new Promise<void>((resolve, reject) => {
45
- exporter.parse(
46
- sceneGroup,
47
- (gltf) => {
48
- const blob = new Blob([gltf as ArrayBuffer], { type: 'model/gltf-binary' })
49
- downloadBlob(blob, `model_${date}.glb`)
50
- resolve()
51
- },
52
- (error) => {
53
- console.error('Export error:', error)
54
- reject(error)
55
- },
56
- { binary: true },
57
- )
58
- })
78
+ if (format === 'stl') {
79
+ const exporter = new STLExporter()
80
+ const result = exporter.parse(exportScene, { binary: true })
81
+ const blob = new Blob([result], { type: 'model/stl' })
82
+ downloadBlob(blob, `model_${date}.stl`)
83
+ return
84
+ }
85
+
86
+ if (format === 'obj') {
87
+ const exporter = new OBJExporter()
88
+ const result = exporter.parse(exportScene)
89
+ const blob = new Blob([result], { type: 'model/obj' })
90
+ downloadBlob(blob, `model_${date}.obj`)
91
+ return
92
+ }
93
+ } finally {
94
+ useViewer.getState().setExporting(false)
95
+ }
59
96
  }
60
97
 
61
98
  setExportScene(exportFn)
@@ -0,0 +1,87 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type AnyNodeId,
5
+ type FenceNode,
6
+ getFenceControlHandle,
7
+ isSplineFence,
8
+ sceneRegistry,
9
+ useLiveNodeOverrides,
10
+ useScene,
11
+ } from '@pascal-app/core'
12
+ import { useViewer } from '@pascal-app/viewer'
13
+ import { createPortal, useFrame } from '@react-three/fiber'
14
+ import { useMemo, useState } from 'react'
15
+ import { BufferGeometry, type Object3D, Vector3 } from 'three'
16
+ import { EDITOR_LAYER } from '../../lib/constants'
17
+
18
+ /**
19
+ * Straight connecting line through each spline-fence control point joining its
20
+ * two tangent handle ends (the classic pen-tool look). The handle *dots* are
21
+ * registry tap-handles (see `fence/definition.ts`); this overlay only draws
22
+ * the line between them, in both views' 3D scene.
23
+ *
24
+ * Must match the handle placement's arm scale so the line ends land exactly on
25
+ * the dots. Portals into the fence's own Object3D so it inherits the same world
26
+ * transform as the geometry (path coords are node-local plan meters), and reads
27
+ * the live override so the line tracks an in-flight tangent / point drag.
28
+ */
29
+
30
+ // Keep in sync with TANGENT_HANDLE_ARM_SCALE in fence/definition.ts.
31
+ const TANGENT_HANDLE_ARM_SCALE = 3
32
+ const LINE_LIFT_Y = 0.02
33
+
34
+ export function FenceTangentLines3D() {
35
+ const selectedIds = useViewer((s) => s.selection.selectedIds)
36
+ const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
37
+
38
+ const rawNode = useScene((s) => (selectedId ? s.nodes[selectedId as AnyNodeId] : null))
39
+ const liveOverride = useLiveNodeOverrides((s) =>
40
+ selectedId ? s.overrides.get(selectedId as AnyNodeId) : undefined,
41
+ )
42
+ const fence = useMemo<FenceNode | null>(() => {
43
+ if (rawNode?.type !== 'fence') return null
44
+ const merged = liveOverride ? ({ ...rawNode, ...liveOverride } as FenceNode) : rawNode
45
+ return isSplineFence(merged) ? merged : null
46
+ }, [rawNode, liveOverride])
47
+
48
+ const [object, setObject] = useState<{ id: AnyNodeId; object: Object3D } | null>(null)
49
+ const selectedObject = selectedId && object?.id === selectedId ? object.object : null
50
+
51
+ useFrame(() => {
52
+ if (!selectedId || selectedObject) return
53
+ const next = sceneRegistry.nodes.get(selectedId)
54
+ if (next) setObject({ id: selectedId as AnyNodeId, object: next })
55
+ })
56
+
57
+ const geometry = useMemo(() => {
58
+ if (!fence?.path) return null
59
+ const positions: Vector3[] = []
60
+ for (let i = 0; i < fence.path.length; i += 1) {
61
+ const point = fence.path[i]!
62
+ const handle = getFenceControlHandle(fence.path, fence.tangents, i)
63
+ const ax = handle.x * TANGENT_HANDLE_ARM_SCALE
64
+ const az = handle.y * TANGENT_HANDLE_ARM_SCALE
65
+ // One disjoint segment per point — `lineSegments` pairs vertices, so the
66
+ // in/out ends connect through the point without joining across points.
67
+ positions.push(new Vector3(point[0] - ax, LINE_LIFT_Y, point[1] - az))
68
+ positions.push(new Vector3(point[0] + ax, LINE_LIFT_Y, point[1] + az))
69
+ }
70
+ return new BufferGeometry().setFromPoints(positions)
71
+ }, [fence])
72
+
73
+ if (!(fence && selectedObject && geometry)) return null
74
+
75
+ return createPortal(
76
+ <lineSegments frustumCulled={false} geometry={geometry} layers={EDITOR_LAYER} renderOrder={3}>
77
+ <lineBasicNodeMaterial
78
+ color="#8381ed"
79
+ depthTest={false}
80
+ depthWrite={false}
81
+ opacity={0.85}
82
+ transparent
83
+ />
84
+ </lineSegments>,
85
+ selectedObject,
86
+ )
87
+ }
@@ -0,0 +1,182 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
+ import {
3
+ type AnyNode,
4
+ type AnyNodeDefinition,
5
+ CeilingNode,
6
+ ColumnNode,
7
+ ElevatorNode,
8
+ LevelNode,
9
+ nodeRegistry,
10
+ registerNode,
11
+ ShelfNode,
12
+ SiteNode,
13
+ sceneRegistry,
14
+ useScene,
15
+ } from '@pascal-app/core'
16
+ import { BoxGeometry, Group, Mesh, MeshBasicMaterial } from 'three'
17
+ import { buildFirstPersonColliderWorldFromRegistry } from './build-collider-world'
18
+
19
+ function registerColliderDefinition(
20
+ kind: AnyNode['type'],
21
+ schema: AnyNodeDefinition['schema'],
22
+ category: AnyNodeDefinition['category'],
23
+ surfaceRole?: AnyNodeDefinition['surfaceRole'],
24
+ ) {
25
+ registerNode({
26
+ kind,
27
+ schema,
28
+ schemaVersion: 1,
29
+ category,
30
+ surfaceRole,
31
+ capabilities: {},
32
+ } as AnyNodeDefinition)
33
+ }
34
+
35
+ function mountNode(
36
+ node: AnyNode,
37
+ box: [number, number, number],
38
+ position: [number, number, number],
39
+ ) {
40
+ const group = new Group()
41
+ const mesh = new Mesh(new BoxGeometry(box[0], box[1], box[2]), new MeshBasicMaterial())
42
+ mesh.position.set(position[0], position[1], position[2])
43
+ group.add(mesh)
44
+ group.updateMatrixWorld(true)
45
+ sceneRegistry.nodes.set(node.id, group)
46
+ sceneRegistry.byType[node.type]!.add(node.id)
47
+ }
48
+
49
+ function mountRegistryGroup(node: AnyNode) {
50
+ const group = new Group()
51
+ group.updateMatrixWorld(true)
52
+ sceneRegistry.nodes.set(node.id, group)
53
+ sceneRegistry.byType[node.type]!.add(node.id)
54
+ }
55
+
56
+ function setSceneNodes(nodes: AnyNode[]) {
57
+ useScene.setState({
58
+ nodes: Object.fromEntries(nodes.map((node) => [node.id, node])),
59
+ rootNodeIds: nodes.map((node) => node.id),
60
+ } as never)
61
+ }
62
+
63
+ describe('buildFirstPersonColliderWorldFromRegistry', () => {
64
+ afterEach(() => {
65
+ sceneRegistry.clear()
66
+ nodeRegistry._reset()
67
+ useScene.setState({ nodes: {}, rootNodeIds: [] } as never)
68
+ })
69
+
70
+ test('includes structure and furnish nodes discovered through the node registry', () => {
71
+ registerColliderDefinition('column', ColumnNode, 'structure')
72
+ registerColliderDefinition('shelf', ShelfNode, 'furnish')
73
+
74
+ const column = ColumnNode.parse({ id: 'column_test' })
75
+ const shelf = ShelfNode.parse({ id: 'shelf_test', position: [3, 0, 0] })
76
+ setSceneNodes([column, shelf])
77
+ mountNode(column, [1, 2, 1], [0, 1, 0])
78
+ mountNode(shelf, [2, 1, 1], [3, 0.5, 0])
79
+
80
+ const world = buildFirstPersonColliderWorldFromRegistry()
81
+
82
+ expect(world).not.toBeNull()
83
+ expect(world?.bounds?.min.x).toBeCloseTo(-0.5)
84
+ expect(world?.bounds?.max.x).toBeCloseTo(4)
85
+ world?.dispose()
86
+ })
87
+
88
+ test('excludes ceiling surfaces so the walkthrough player passes through them', () => {
89
+ registerColliderDefinition('column', ColumnNode, 'structure')
90
+ registerColliderDefinition('ceiling', CeilingNode, 'structure', 'ceiling')
91
+
92
+ const column = ColumnNode.parse({ id: 'column_test' })
93
+ const ceiling = CeilingNode.parse({ id: 'ceiling_test', polygon: [] })
94
+ setSceneNodes([column, ceiling])
95
+ mountNode(column, [1, 2, 1], [0, 1, 0])
96
+ // A wide ceiling at head height — if it were collected, bounds would span ±5.
97
+ mountNode(ceiling, [10, 0.1, 10], [0, 2.5, 0])
98
+
99
+ const world = buildFirstPersonColliderWorldFromRegistry()
100
+
101
+ expect(world).not.toBeNull()
102
+ // Bounds reflect only the 1×1 column; the ceiling contributed no geometry.
103
+ expect(world?.bounds?.min.x).toBeCloseTo(-0.5)
104
+ expect(world?.bounds?.max.x).toBeCloseTo(0.5)
105
+ world?.dispose()
106
+ })
107
+
108
+ test('skips meshes hidden by an invisible ancestor (stale roof segment CSG)', () => {
109
+ registerColliderDefinition('column', ColumnNode, 'structure')
110
+
111
+ // Mirror the roof's segments-wrapper shape: the registered mesh's own
112
+ // visible flag stays true while a hidden wrapper hides it at render
113
+ // time. The collider must match the render, not the own-flag.
114
+ const column = ColumnNode.parse({ id: 'column_test' })
115
+ const visibleColumn = ColumnNode.parse({ id: 'column_visible', position: [3, 0, 0] })
116
+ setSceneNodes([column, visibleColumn])
117
+
118
+ const wrapper = new Group()
119
+ wrapper.visible = false
120
+ const hiddenMesh = new Mesh(new BoxGeometry(10, 2, 10), new MeshBasicMaterial())
121
+ wrapper.add(hiddenMesh)
122
+ wrapper.updateMatrixWorld(true)
123
+ sceneRegistry.nodes.set(column.id, hiddenMesh)
124
+ sceneRegistry.byType[column.type]!.add(column.id)
125
+
126
+ mountNode(visibleColumn, [1, 2, 1], [3, 1, 0])
127
+
128
+ const world = buildFirstPersonColliderWorldFromRegistry()
129
+
130
+ expect(world).not.toBeNull()
131
+ // Bounds reflect only the visible 1×1 column at x = 3; the 10×10 mesh
132
+ // under the hidden wrapper contributed no geometry.
133
+ expect(world?.bounds?.min.x).toBeCloseTo(2.5)
134
+ expect(world?.bounds?.max.x).toBeCloseTo(3.5)
135
+ world?.dispose()
136
+ })
137
+
138
+ test('leaves elevators to their dedicated dynamic collider meshes', () => {
139
+ registerColliderDefinition('elevator', ElevatorNode, 'structure')
140
+
141
+ const elevator = ElevatorNode.parse({ id: 'elevator_test' })
142
+ setSceneNodes([elevator])
143
+ mountNode(elevator, [2, 3, 2], [0, 1.5, 0])
144
+
145
+ const world = buildFirstPersonColliderWorldFromRegistry()
146
+
147
+ expect(world).toBeNull()
148
+ })
149
+
150
+ test('adds a fallback floor for a visible level with no slab', () => {
151
+ const level = LevelNode.parse({ id: 'level_test', level: 0 })
152
+ setSceneNodes([level])
153
+ mountRegistryGroup(level)
154
+
155
+ const world = buildFirstPersonColliderWorldFromRegistry()
156
+
157
+ expect(world).not.toBeNull()
158
+ expect(world?.bounds?.min.y).toBeCloseTo(-0.08)
159
+ expect(world?.bounds?.max.y).toBeCloseTo(0)
160
+ world?.dispose()
161
+ })
162
+
163
+ test('adds a site ground collider so a spawn on bare ground has a floor', () => {
164
+ const site = SiteNode.parse({ id: 'site_test' })
165
+ setSceneNodes([site])
166
+ mountRegistryGroup(site)
167
+
168
+ const world = buildFirstPersonColliderWorldFromRegistry()
169
+
170
+ expect(world).not.toBeNull()
171
+ // Ground slab sits just below the site ground plane (y = 0).
172
+ expect(world?.bounds?.min.y).toBeCloseTo(-0.08)
173
+ expect(world?.bounds?.max.y).toBeCloseTo(0)
174
+ // The ground collider extends far past the site polygon so stepping out of
175
+ // the site boundary never drops the player below the ground plane.
176
+ expect(world?.bounds?.min.x).toBeCloseTo(-1000)
177
+ expect(world?.bounds?.max.x).toBeCloseTo(1000)
178
+ expect(world?.bounds?.min.z).toBeCloseTo(-1000)
179
+ expect(world?.bounds?.max.z).toBeCloseTo(1000)
180
+ world?.dispose()
181
+ })
182
+ })