@pascal-app/editor 0.9.1 → 1.0.0-beta.1

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 (366) hide show
  1. package/package.json +14 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
  3. package/src/components/editor/bake-exporter.tsx +49 -0
  4. package/src/components/editor/bake-thumbnail.tsx +95 -0
  5. package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
  6. package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
  7. package/src/components/editor/custom-camera-controls.tsx +332 -199
  8. package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
  9. package/src/components/editor/editor-layout-mobile.tsx +5 -0
  10. package/src/components/editor/editor-layout-v2.tsx +18 -0
  11. package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
  12. package/src/components/editor/export-manager.tsx +71 -63
  13. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  14. package/src/components/editor/first-person/build-collider-world.test.ts +102 -5
  15. package/src/components/editor/first-person/build-collider-world.ts +52 -5
  16. package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
  17. package/src/components/editor/first-person/terrain-collider.ts +212 -0
  18. package/src/components/editor/first-person-controls.tsx +410 -136
  19. package/src/components/editor/floating-action-menu.tsx +435 -64
  20. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  21. package/src/components/editor/floorplan-background-selection.test.ts +72 -0
  22. package/src/components/editor/floorplan-background-selection.ts +7 -20
  23. package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
  24. package/src/components/editor/floorplan-camera-sync.ts +213 -0
  25. package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
  26. package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
  27. package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
  28. package/src/components/editor/floorplan-panel.tsx +2517 -1492
  29. package/src/components/editor/grid.tsx +166 -39
  30. package/src/components/editor/group-actions.ts +597 -0
  31. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  32. package/src/components/editor/group-move-3d.ts +397 -0
  33. package/src/components/editor/group-rotate-handle.tsx +102 -64
  34. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  35. package/src/components/editor/group-transform-shared.test.ts +192 -1
  36. package/src/components/editor/group-transform-shared.ts +240 -7
  37. package/src/components/editor/handles/handle-arrow.tsx +81 -23
  38. package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
  39. package/src/components/editor/handles/handle-drag-history.ts +14 -0
  40. package/src/components/editor/handles/preview-overrides.test.ts +26 -0
  41. package/src/components/editor/handles/preview-overrides.ts +13 -0
  42. package/src/components/editor/handles/resize-snap.test.ts +52 -0
  43. package/src/components/editor/handles/resize-snap.ts +23 -0
  44. package/src/components/editor/handles/use-handle-drag.ts +49 -6
  45. package/src/components/editor/index.tsx +289 -35
  46. package/src/components/editor/measurement-pill.tsx +68 -23
  47. package/src/components/editor/node-action-menu.tsx +14 -1
  48. package/src/components/editor/node-arrow-handles.tsx +409 -180
  49. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  50. package/src/components/editor/quick-measurement-card.tsx +76 -0
  51. package/src/components/editor/quick-measurement-hud.tsx +34 -0
  52. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  53. package/src/components/editor/selection-manager.tsx +814 -466
  54. package/src/components/editor/site-edge-labels.tsx +28 -6
  55. package/src/components/editor/slab-hole-highlights.tsx +13 -5
  56. package/src/components/editor/snapshot-capture-overlay.tsx +258 -115
  57. package/src/components/editor/three-context-bridge.ts +22 -0
  58. package/src/components/editor/thumbnail-generator.tsx +118 -253
  59. package/src/components/editor/use-floorplan-background-placement.ts +113 -45
  60. package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
  61. package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
  62. package/src/components/editor/use-floorplan-scene-data.ts +5 -5
  63. package/src/components/editor/use-mesh-settle-epoch.ts +27 -0
  64. package/src/components/editor/wall-measurement-label.tsx +10 -28
  65. package/src/components/editor/wall-move-side-handles.tsx +229 -39
  66. package/src/components/editor/wall-opening-highlights.tsx +63 -32
  67. package/src/components/editor/wall-snap-beacon-layer.tsx +162 -6
  68. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
  69. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
  70. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +40 -9
  71. package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
  72. package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
  73. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  74. package/src/components/editor-2d/floorplan-group-move.tsx +744 -0
  75. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  76. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1672 -0
  77. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  78. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
  79. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +219 -20
  80. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +327 -59
  81. package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
  82. package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
  83. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
  84. package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
  85. package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
  86. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
  87. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
  88. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
  89. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
  90. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +89 -0
  91. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +106 -0
  92. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +94 -26
  93. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +587 -0
  94. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +2280 -578
  95. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
  96. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  97. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +425 -61
  98. package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
  99. package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
  100. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  101. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  102. package/src/components/systems/zone/zone-system.tsx +42 -13
  103. package/src/components/tools/elevator/elevator-tool.tsx +34 -8
  104. package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
  105. package/src/components/tools/fence/fence-drafting.ts +107 -8
  106. package/src/components/tools/item/move-tool.tsx +17 -13
  107. package/src/components/tools/item/placement-math.test.ts +13 -1
  108. package/src/components/tools/item/placement-math.ts +28 -2
  109. package/src/components/tools/item/placement-strategies.ts +246 -5
  110. package/src/components/tools/item/placement-types.ts +13 -1
  111. package/src/components/tools/item/use-draft-node.ts +146 -57
  112. package/src/components/tools/item/use-placement-coordinator.tsx +813 -125
  113. package/src/components/tools/registry/move-registry-node-tool.tsx +636 -103
  114. package/src/components/tools/roof/roof-tool.tsx +336 -94
  115. package/src/components/tools/select/box-select-state.ts +18 -7
  116. package/src/components/tools/select/box-select-tool.tsx +176 -40
  117. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  118. package/src/components/tools/select/marquee-geometry.ts +155 -0
  119. package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
  120. package/src/components/tools/select/select-candidates.ts +1 -1
  121. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  122. package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
  123. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  124. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  125. package/src/components/tools/shared/floor-stack-preview.ts +4 -0
  126. package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
  127. package/src/components/tools/shared/placement-box.tsx +183 -1
  128. package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
  129. package/src/components/tools/shared/pointer-support-cap.ts +273 -0
  130. package/src/components/tools/shared/polygon-editor.tsx +276 -35
  131. package/src/components/tools/site/site-boundary-editor.tsx +101 -42
  132. package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
  133. package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
  134. package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
  135. package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
  136. package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
  137. package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
  138. package/src/components/tools/stair/stair-click-guard.ts +72 -0
  139. package/src/components/tools/stair/stair-defaults.ts +1 -0
  140. package/src/components/tools/stair/stair-tool.tsx +164 -42
  141. package/src/components/tools/tool-manager.tsx +149 -30
  142. package/src/components/tools/wall/wall-drafting.test.ts +447 -0
  143. package/src/components/tools/wall/wall-drafting.ts +304 -108
  144. package/src/components/tools/wall/wall-snap-geometry.test.ts +52 -0
  145. package/src/components/tools/wall/wall-snap-geometry.ts +137 -9
  146. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  147. package/src/components/tools/zone/zone-tool.tsx +98 -88
  148. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  149. package/src/components/ui/action-menu/control-modes.tsx +44 -94
  150. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  151. package/src/components/ui/action-menu/index.tsx +2 -4
  152. package/src/components/ui/action-menu/measurement-control.tsx +267 -0
  153. package/src/components/ui/action-menu/structure-tools.tsx +22 -13
  154. package/src/components/ui/action-menu/view-toggles.tsx +42 -90
  155. package/src/components/ui/command-palette/editor-commands.tsx +16 -3
  156. package/src/components/ui/command-palette/index.tsx +4 -3
  157. package/src/components/ui/controls/material-paint-panel.tsx +93 -18
  158. package/src/components/ui/controls/material-picker.tsx +162 -152
  159. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  160. package/src/components/ui/controls/metric-control.tsx +118 -44
  161. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  162. package/src/components/ui/controls/slider-control.tsx +88 -38
  163. package/src/components/ui/controls/terrain-sculpt-panel.tsx +193 -0
  164. package/src/components/ui/floating-level-selector.tsx +74 -20
  165. package/src/components/ui/helpers/building-helper.tsx +10 -23
  166. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  167. package/src/components/ui/helpers/helper-manager.tsx +273 -14
  168. package/src/components/ui/helpers/item-helper.tsx +28 -32
  169. package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
  170. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  171. package/src/components/ui/icon-ref.tsx +47 -0
  172. package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
  173. package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
  174. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  175. package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
  176. package/src/components/ui/panels/node-display.ts +17 -17
  177. package/src/components/ui/panels/panel-manager.tsx +36 -14
  178. package/src/components/ui/panels/panel-wrapper.tsx +24 -3
  179. package/src/components/ui/panels/parametric-inspector.tsx +19 -4
  180. package/src/components/ui/panels/reference-panel.tsx +54 -19
  181. package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
  182. package/src/components/ui/panels/selection-breakdown.ts +20 -0
  183. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  184. package/src/components/ui/primitives/sidebar.tsx +1 -0
  185. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  186. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  187. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  188. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +82 -41
  189. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +76 -6
  190. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
  191. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
  192. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
  193. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
  194. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  195. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  196. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
  197. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
  198. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
  199. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
  200. package/src/components/ui/sidebar/panels/site-panel/index.tsx +42 -17
  201. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  202. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  203. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
  204. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  205. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
  206. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
  207. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
  208. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  209. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  210. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
  211. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  212. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  213. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  214. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  215. package/src/components/ui/sidebar/tab-bar.tsx +42 -28
  216. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  217. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  218. package/src/components/ui/snap-target-badge.tsx +88 -0
  219. package/src/components/viewer/viewer-controls-bar.tsx +466 -0
  220. package/src/components/viewer/viewer-scene-header.tsx +235 -0
  221. package/src/components/viewer-overlay.tsx +36 -596
  222. package/src/components/viewer-zone-system.tsx +6 -1
  223. package/src/components/walkthrough-hud.tsx +111 -0
  224. package/src/hooks/use-auto-save.test.ts +14 -0
  225. package/src/hooks/use-auto-save.ts +54 -7
  226. package/src/hooks/use-ceiling-events.ts +3 -2
  227. package/src/hooks/use-drag-action.ts +4 -1
  228. package/src/hooks/use-grid-events.ts +24 -0
  229. package/src/hooks/use-keyboard.ts +438 -70
  230. package/src/index.tsx +355 -9
  231. package/src/lib/active-placement-surface.test.ts +24 -0
  232. package/src/lib/active-placement-surface.ts +42 -0
  233. package/src/lib/camera-pose.test.ts +237 -0
  234. package/src/lib/camera-pose.ts +172 -0
  235. package/src/lib/ceiling-plan-snap.ts +24 -0
  236. package/src/lib/contextual-help.test.ts +112 -0
  237. package/src/lib/contextual-help.ts +144 -0
  238. package/src/lib/continuation.ts +64 -0
  239. package/src/lib/direct-manipulation.test.ts +275 -0
  240. package/src/lib/direct-manipulation.ts +118 -0
  241. package/src/lib/door-interaction.ts +1 -1
  242. package/src/lib/editor-api.ts +23 -35
  243. package/src/lib/elevation-guides.test.ts +107 -0
  244. package/src/lib/elevation-guides.ts +236 -0
  245. package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
  246. package/src/lib/floorplan/annotation-visibility.ts +133 -0
  247. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  248. package/src/lib/floorplan/apply-alignment.ts +18 -12
  249. package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
  250. package/src/lib/floorplan/drawing-coordination.ts +13 -0
  251. package/src/lib/floorplan/floorplan-export.test.ts +319 -0
  252. package/src/lib/floorplan/floorplan-export.tsx +978 -0
  253. package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
  254. package/src/lib/floorplan/floorplan-extension.ts +181 -0
  255. package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
  256. package/src/lib/floorplan/floorplan-mode.ts +73 -0
  257. package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
  258. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
  259. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
  260. package/src/lib/floorplan/geometry.ts +52 -0
  261. package/src/lib/floorplan/index.ts +3 -0
  262. package/src/lib/floorplan/items.ts +5 -2
  263. package/src/lib/floorplan/plan-coords.ts +21 -0
  264. package/src/lib/fresh-planar-placement.test.ts +283 -3
  265. package/src/lib/fresh-planar-placement.ts +85 -1
  266. package/src/lib/glb-export.test.ts +556 -0
  267. package/src/lib/glb-export.ts +1071 -0
  268. package/src/lib/ground-surface.ts +95 -0
  269. package/src/lib/history.test.ts +96 -0
  270. package/src/lib/history.ts +31 -0
  271. package/src/lib/interaction/hot-set.test.ts +133 -0
  272. package/src/lib/interaction/hot-set.ts +67 -0
  273. package/src/lib/interaction/overlay-policy.test.ts +52 -0
  274. package/src/lib/interaction/overlay-policy.ts +59 -0
  275. package/src/lib/interaction/scope.ts +243 -0
  276. package/src/lib/level-duplication.test.ts +2 -1
  277. package/src/lib/level-selection.ts +2 -2
  278. package/src/lib/material-paint.ts +36 -49
  279. package/src/lib/measurement-kind.test.ts +30 -0
  280. package/src/lib/measurement-kind.ts +19 -0
  281. package/src/lib/measurement-label.test.ts +47 -0
  282. package/src/lib/measurement-label.ts +68 -0
  283. package/src/lib/measurement-parser.ts +116 -0
  284. package/src/lib/measurements.test.ts +188 -0
  285. package/src/lib/measurements.ts +210 -0
  286. package/src/lib/paint-scope.test.ts +454 -0
  287. package/src/lib/paint-scope.ts +461 -0
  288. package/src/lib/placement-drag-release.ts +23 -0
  289. package/src/lib/planar-cursor-placement.test.ts +57 -0
  290. package/src/lib/plugin-panels.test.ts +19 -0
  291. package/src/lib/plugin-panels.ts +91 -0
  292. package/src/lib/quick-action-feedback.ts +33 -0
  293. package/src/lib/quick-action-nodes.test.ts +94 -0
  294. package/src/lib/quick-action-nodes.ts +47 -0
  295. package/src/lib/quick-measurement.test.ts +77 -0
  296. package/src/lib/quick-measurement.ts +109 -0
  297. package/src/lib/roof-duplication.ts +6 -6
  298. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  299. package/src/lib/roof-wall-hit.ts +164 -0
  300. package/src/lib/scene-clipboard.test.ts +325 -0
  301. package/src/lib/scene-clipboard.ts +310 -21
  302. package/src/lib/scene.ts +28 -5
  303. package/src/lib/selection-routing.test.ts +313 -0
  304. package/src/lib/selection-routing.ts +180 -0
  305. package/src/lib/sfx/index.ts +1 -0
  306. package/src/lib/sfx/movement-tick.test.ts +65 -0
  307. package/src/lib/sfx/movement-tick.ts +18 -0
  308. package/src/lib/sfx-bus.ts +82 -14
  309. package/src/lib/sfx-player.test.ts +266 -0
  310. package/src/lib/sfx-player.ts +262 -69
  311. package/src/lib/site-boundary.test.ts +53 -0
  312. package/src/lib/site-boundary.ts +27 -0
  313. package/src/lib/slab-plan-snap.test.ts +93 -0
  314. package/src/lib/slab-plan-snap.ts +108 -0
  315. package/src/lib/snapping-mode.test.ts +165 -0
  316. package/src/lib/snapping-mode.ts +191 -0
  317. package/src/lib/stair-duplication.test.ts +103 -0
  318. package/src/lib/stair-duplication.ts +58 -86
  319. package/src/lib/stair-levels.ts +2 -0
  320. package/src/lib/surface-plan-snap.test.ts +71 -0
  321. package/src/lib/surface-plan-snap.ts +256 -0
  322. package/src/lib/terrain-sculpt.test.ts +429 -0
  323. package/src/lib/terrain-sculpt.ts +301 -0
  324. package/src/lib/terrain-verb-color.test.ts +51 -0
  325. package/src/lib/terrain-verb-color.ts +58 -0
  326. package/src/lib/touch-gesture-priority.test.ts +45 -0
  327. package/src/lib/touch-gesture-priority.ts +30 -0
  328. package/src/lib/use-linear-display.ts +40 -0
  329. package/src/lib/window-interaction.ts +1 -1
  330. package/src/lib/world-grid-snap.ts +37 -5
  331. package/src/store/camera-pose-store.test.ts +29 -0
  332. package/src/store/camera-pose-store.ts +27 -0
  333. package/src/store/live-draft-preview-stores.test.ts +136 -0
  334. package/src/store/navigation-sync-pose-store.test.ts +34 -0
  335. package/src/store/navigation-sync-pose-store.ts +27 -0
  336. package/src/store/terrain-sculpt-mode.test.ts +223 -0
  337. package/src/store/use-delete-confirmation.ts +27 -0
  338. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  339. package/src/store/use-drawing-view.test.ts +23 -0
  340. package/src/store/use-drawing-view.ts +84 -0
  341. package/src/store/use-editor.tsx +665 -126
  342. package/src/store/use-elevation-guides.ts +40 -0
  343. package/src/store/use-facing-pose.ts +44 -0
  344. package/src/store/use-fence-curve-draft.ts +51 -0
  345. package/src/store/use-first-person-hud.ts +40 -0
  346. package/src/store/use-floorplan-annotation-visibility.ts +60 -0
  347. package/src/store/use-floorplan-draft-preview.ts +151 -0
  348. package/src/store/use-floorplan-marquee.ts +50 -0
  349. package/src/store/use-floorplan-mode.ts +110 -0
  350. package/src/store/use-interaction-scope.test.ts +206 -0
  351. package/src/store/use-interaction-scope.ts +140 -0
  352. package/src/store/use-measurement-draft.test.ts +559 -0
  353. package/src/store/use-measurement-draft.ts +570 -0
  354. package/src/store/use-opening-guides.ts +41 -0
  355. package/src/store/use-path-draft-preview.ts +90 -0
  356. package/src/store/use-placement-preview.ts +11 -3
  357. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  358. package/src/store/use-quick-measurement-hud.ts +77 -0
  359. package/src/store/use-segment-draft-chain.ts +28 -0
  360. package/src/store/use-stair-build-preview.ts +53 -0
  361. package/src/store/use-wall-snap-indicator.ts +2 -0
  362. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  363. package/src/components/editor/group-move-handle.tsx +0 -316
  364. package/src/components/ui/panels/paint-panel.tsx +0 -163
  365. package/src/lib/floorplan/selection-tool.ts +0 -271
  366. package/src/lib/level-name.ts +0 -11
@@ -0,0 +1,54 @@
1
+ 'use client'
2
+
3
+ import { useEffect } from 'react'
4
+ import useDeleteConfirmation from '../../store/use-delete-confirmation'
5
+ import {
6
+ Dialog,
7
+ DialogContent,
8
+ DialogDescription,
9
+ DialogFooter,
10
+ DialogHeader,
11
+ DialogTitle,
12
+ } from '../ui/primitives/dialog'
13
+
14
+ export function DeleteConfirmationDialog() {
15
+ const request = useDeleteConfirmation((state) => state.request)
16
+ const cancel = useDeleteConfirmation((state) => state.cancel)
17
+ const confirm = useDeleteConfirmation((state) => state.confirm)
18
+
19
+ useEffect(() => cancel, [cancel])
20
+
21
+ return (
22
+ <Dialog onOpenChange={(open) => !open && cancel()} open={request !== null}>
23
+ <DialogContent
24
+ className="border-border/70 bg-background/95 shadow-2xl backdrop-blur-xl sm:max-w-md"
25
+ data-delete-confirmation-dialog
26
+ showCloseButton={false}
27
+ >
28
+ <DialogHeader>
29
+ <DialogTitle>Delete {request?.count ?? 0} elements?</DialogTitle>
30
+ <DialogDescription>
31
+ This removes every selected element. You can undo the deletion while it remains in the
32
+ editor history.
33
+ </DialogDescription>
34
+ </DialogHeader>
35
+ <DialogFooter>
36
+ <button
37
+ className="rounded-full border border-border px-4 py-2 text-sm transition-colors hover:bg-accent"
38
+ onClick={cancel}
39
+ type="button"
40
+ >
41
+ Cancel
42
+ </button>
43
+ <button
44
+ className="rounded-full bg-red-600 px-4 py-2 text-sm text-white transition-colors hover:bg-red-700"
45
+ onClick={confirm}
46
+ type="button"
47
+ >
48
+ Delete
49
+ </button>
50
+ </DialogFooter>
51
+ </DialogContent>
52
+ </Dialog>
53
+ )
54
+ }
@@ -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
  }
@@ -52,6 +52,15 @@ function LeftColumn({
52
52
  }
53
53
  }, [activePanel])
54
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
+
55
64
  const handleResizerDown = useCallback(
56
65
  (e: React.PointerEvent) => {
57
66
  e.preventDefault()
@@ -150,11 +159,13 @@ function RightColumn({
150
159
  toolbarRight,
151
160
  children,
152
161
  overlays,
162
+ stageOverlay,
153
163
  }: {
154
164
  toolbarLeft?: ReactNode
155
165
  toolbarRight?: ReactNode
156
166
  children: ReactNode
157
167
  overlays?: ReactNode
168
+ stageOverlay?: ReactNode
158
169
  }) {
159
170
  return (
160
171
  <div
@@ -174,6 +185,10 @@ function RightColumn({
174
185
  )}
175
186
  {/* Canvas area */}
176
187
  <div className="relative flex-1 overflow-hidden">{children}</div>
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>}
177
192
  {/* Overlays scoped to the viewer column. `data-viewer-bounds` marks the
178
193
  draggable region the floating inspector clamps itself to. */}
179
194
  {overlays && (
@@ -200,6 +215,7 @@ export interface EditorLayoutV2Props {
200
215
  viewerToolbarRight?: ReactNode
201
216
  viewerContent: ReactNode
202
217
  overlays?: ReactNode
218
+ stageOverlay?: ReactNode
203
219
  }
204
220
 
205
221
  export function EditorLayoutV2({
@@ -211,6 +227,7 @@ export function EditorLayoutV2({
211
227
  viewerToolbarRight,
212
228
  viewerContent,
213
229
  overlays,
230
+ stageOverlay,
214
231
  }: EditorLayoutV2Props) {
215
232
  const isCaptureMode = useEditor((s) => s.isCaptureMode)
216
233
  const isMobile = useIsMobile()
@@ -246,6 +263,7 @@ export function EditorLayoutV2({
246
263
  )}
247
264
  <RightColumn
248
265
  overlays={overlays}
266
+ stageOverlay={stageOverlay}
249
267
  toolbarLeft={isCaptureMode ? undefined : viewerToolbarLeft}
250
268
  toolbarRight={isCaptureMode ? undefined : viewerToolbarRight}
251
269
  >
@@ -0,0 +1,120 @@
1
+ 'use client'
2
+
3
+ import { sceneRegistry } from '@pascal-app/core'
4
+ import { useViewer } from '@pascal-app/viewer'
5
+ import { Html } from '@react-three/drei'
6
+ import { useFrame } from '@react-three/fiber'
7
+ import { memo, useMemo, useRef } from 'react'
8
+ import { BoxGeometry, CircleGeometry, type Group } from 'three'
9
+ import { MeshBasicNodeMaterial } from 'three/webgpu'
10
+ import { EDITOR_LAYER } from '../../lib/constants'
11
+ import useElevationGuides from '../../store/use-elevation-guides'
12
+ import { formatMeasurement } from './measurement-pill'
13
+
14
+ const LINE_COLOR = 0x81_8c_f8
15
+ const PILL_COLOR = '#6366f1'
16
+ const GUIDE_LIFT = 0.025
17
+ const GUIDE_LENGTH = 2.4
18
+ const DASH_LENGTH = 0.16
19
+ const DASH_GAP = 0.1
20
+ const LINE_WIDTH = 0.035
21
+ const DOT_RADIUS = 0.07
22
+
23
+ const guideMaterial = new MeshBasicNodeMaterial({
24
+ color: LINE_COLOR,
25
+ depthTest: false,
26
+ depthWrite: false,
27
+ toneMapped: false,
28
+ transparent: true,
29
+ opacity: 0.9,
30
+ })
31
+ const dotMaterial = new MeshBasicNodeMaterial({
32
+ color: LINE_COLOR,
33
+ depthTest: false,
34
+ depthWrite: false,
35
+ toneMapped: false,
36
+ transparent: true,
37
+ opacity: 0.95,
38
+ })
39
+ const DASH_GEOMETRY = new BoxGeometry(1, 1, 1)
40
+ const DOT_GEOMETRY = new CircleGeometry(1, 24)
41
+
42
+ type Vec3 = [number, number, number]
43
+
44
+ export const Elevation3DGuideLayer = memo(function Elevation3DGuideLayer() {
45
+ const guide = useElevationGuides((state) => state.guide)
46
+ const unit = useViewer((state) => state.unit)
47
+ const groupRef = useRef<Group>(null)
48
+
49
+ useFrame(() => {
50
+ const group = groupRef.current
51
+ if (!(group && guide)) return
52
+ const levelObject = sceneRegistry.nodes.get(guide.levelId)
53
+ group.position.y = (levelObject?.position.y ?? 0) + guide.elevation + GUIDE_LIFT
54
+ })
55
+
56
+ const dashes = useMemo(() => {
57
+ if (!guide) return [] as Vec3[]
58
+ const [cx, cz] = guide.center
59
+ const [dx, dz] = guide.direction
60
+ const start = -GUIDE_LENGTH / 2
61
+ const period = DASH_LENGTH + DASH_GAP
62
+ const centers: Vec3[] = []
63
+ for (let offset = start + DASH_LENGTH / 2; offset < GUIDE_LENGTH / 2; offset += period) {
64
+ centers.push([cx + dx * offset, 0, cz + dz * offset])
65
+ }
66
+ return centers
67
+ }, [guide])
68
+
69
+ if (!guide) return null
70
+
71
+ const angleY = -Math.atan2(guide.direction[1], guide.direction[0])
72
+ const labelPosition: Vec3 = [
73
+ guide.center[0] - guide.direction[1] * 0.65,
74
+ 0.16,
75
+ guide.center[1] + guide.direction[0] * 0.65,
76
+ ]
77
+ const elevationText =
78
+ Math.abs(guide.elevation) < 1e-6
79
+ ? formatMeasurement(0, unit)
80
+ : `${guide.elevation < 0 ? '-' : '+'}${formatMeasurement(Math.abs(guide.elevation), unit)}`
81
+
82
+ return (
83
+ <group ref={groupRef}>
84
+ {dashes.map((position, index) => (
85
+ <mesh
86
+ geometry={DASH_GEOMETRY}
87
+ key={index}
88
+ layers={EDITOR_LAYER}
89
+ material={guideMaterial}
90
+ position={position}
91
+ renderOrder={1010}
92
+ rotation={[0, angleY, 0]}
93
+ scale={[DASH_LENGTH, 0.002, LINE_WIDTH]}
94
+ />
95
+ ))}
96
+ <mesh
97
+ geometry={DOT_GEOMETRY}
98
+ layers={EDITOR_LAYER}
99
+ material={dotMaterial}
100
+ position={[guide.center[0], 0.002, guide.center[1]]}
101
+ renderOrder={1011}
102
+ rotation={[-Math.PI / 2, 0, 0]}
103
+ scale={[DOT_RADIUS, DOT_RADIUS, DOT_RADIUS]}
104
+ />
105
+ <Html
106
+ center
107
+ position={labelPosition}
108
+ style={{ pointerEvents: 'none', userSelect: 'none' }}
109
+ zIndexRange={[25, 0]}
110
+ >
111
+ <div
112
+ className="whitespace-nowrap rounded-[3px] px-[5px] py-[2px] font-medium font-sans text-[11px] text-white"
113
+ style={{ backgroundColor: PILL_COLOR }}
114
+ >
115
+ {`${guide.label} · Y ${elevationText}`}
116
+ </div>
117
+ </Html>
118
+ </group>
119
+ )
120
+ })
@@ -1,12 +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 type { Mesh, Object3D } from 'three'
7
- import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js'
7
+ import * as THREE from 'three'
8
8
  import { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter.js'
9
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
+ }
10
33
 
11
34
  export function ExportManager() {
12
35
  const scene = useThree((state) => state.scene)
@@ -22,42 +45,54 @@ export function ExportManager() {
22
45
  }
23
46
 
24
47
  const date = new Date().toISOString().split('T')[0]
25
- const exportScene = prepareSceneForExport(sceneGroup)
26
-
27
- if (format === 'stl') {
28
- const exporter = new STLExporter()
29
- const result = exporter.parse(exportScene, { binary: true })
30
- const blob = new Blob([result], { type: 'model/stl' })
31
- downloadBlob(blob, `model_${date}.stl`)
32
- return
33
- }
34
48
 
35
- if (format === 'obj') {
36
- const exporter = new OBJExporter()
37
- const result = exporter.parse(exportScene)
38
- const blob = new Blob([result], { type: 'model/obj' })
39
- downloadBlob(blob, `model_${date}.obj`)
40
- return
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()
56
+
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
+ }
63
+
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)
77
+
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)
41
95
  }
42
-
43
- // Default: GLB export (existing behavior)
44
- const exporter = new GLTFExporter()
45
-
46
- return new Promise<void>((resolve, reject) => {
47
- exporter.parse(
48
- exportScene,
49
- (gltf) => {
50
- const blob = new Blob([gltf as ArrayBuffer], { type: 'model/gltf-binary' })
51
- downloadBlob(blob, `model_${date}.glb`)
52
- resolve()
53
- },
54
- (error) => {
55
- console.error('Export error:', error)
56
- reject(error)
57
- },
58
- { binary: true },
59
- )
60
- })
61
96
  }
62
97
 
63
98
  setExportScene(exportFn)
@@ -70,33 +105,6 @@ export function ExportManager() {
70
105
  return null
71
106
  }
72
107
 
73
- function prepareSceneForExport(source: Object3D) {
74
- const clone = source.clone(true)
75
- const meshesToRemove: Mesh[] = []
76
-
77
- clone.traverse((object) => {
78
- if (isMeshWithInvalidGeometry(object)) meshesToRemove.push(object)
79
- })
80
-
81
- for (const mesh of meshesToRemove) {
82
- mesh.removeFromParent()
83
- }
84
-
85
- return clone
86
- }
87
-
88
- function isMeshWithInvalidGeometry(object: Object3D): object is Mesh {
89
- if (!isMesh(object)) return false
90
-
91
- // Three exporters can crash when a Mesh has no readable position attribute.
92
- const position = object.geometry?.getAttribute('position')
93
- return !position || position.count === 0
94
- }
95
-
96
- function isMesh(object: Object3D): object is Mesh {
97
- return (object as Mesh).isMesh === true
98
- }
99
-
100
108
  function downloadBlob(blob: Blob, filename: string) {
101
109
  const url = URL.createObjectURL(blob)
102
110
  const link = document.createElement('a')
@@ -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
+ }
@@ -2,9 +2,14 @@ import { afterEach, describe, expect, test } from 'bun:test'
2
2
  import {
3
3
  type AnyNode,
4
4
  type AnyNodeDefinition,
5
+ applyHeightPatch,
6
+ BuildingNode,
5
7
  CeilingNode,
6
8
  ColumnNode,
9
+ createTerrainField,
7
10
  ElevatorNode,
11
+ encodeTerrainField,
12
+ flattenPatch,
8
13
  LevelNode,
9
14
  nodeRegistry,
10
15
  registerNode,
@@ -13,7 +18,7 @@ import {
13
18
  sceneRegistry,
14
19
  useScene,
15
20
  } from '@pascal-app/core'
16
- import { BoxGeometry, Group, Mesh, MeshBasicMaterial } from 'three'
21
+ import { BoxGeometry, Group, Mesh, MeshBasicMaterial, Raycaster, Vector3 } from 'three'
17
22
  import { buildFirstPersonColliderWorldFromRegistry } from './build-collider-world'
18
23
 
19
24
  function registerColliderDefinition(
@@ -46,8 +51,9 @@ function mountNode(
46
51
  sceneRegistry.byType[node.type]!.add(node.id)
47
52
  }
48
53
 
49
- function mountRegistryGroup(node: AnyNode) {
54
+ function mountRegistryGroup(node: AnyNode, position: [number, number, number] = [0, 0, 0]) {
50
55
  const group = new Group()
56
+ group.position.set(position[0], position[1], position[2])
51
57
  group.updateMatrixWorld(true)
52
58
  sceneRegistry.nodes.set(node.id, group)
53
59
  sceneRegistry.byType[node.type]!.add(node.id)
@@ -105,6 +111,36 @@ describe('buildFirstPersonColliderWorldFromRegistry', () => {
105
111
  world?.dispose()
106
112
  })
107
113
 
114
+ test('skips meshes hidden by an invisible ancestor (stale roof segment CSG)', () => {
115
+ registerColliderDefinition('column', ColumnNode, 'structure')
116
+
117
+ // Mirror the roof's segments-wrapper shape: the registered mesh's own
118
+ // visible flag stays true while a hidden wrapper hides it at render
119
+ // time. The collider must match the render, not the own-flag.
120
+ const column = ColumnNode.parse({ id: 'column_test' })
121
+ const visibleColumn = ColumnNode.parse({ id: 'column_visible', position: [3, 0, 0] })
122
+ setSceneNodes([column, visibleColumn])
123
+
124
+ const wrapper = new Group()
125
+ wrapper.visible = false
126
+ const hiddenMesh = new Mesh(new BoxGeometry(10, 2, 10), new MeshBasicMaterial())
127
+ wrapper.add(hiddenMesh)
128
+ wrapper.updateMatrixWorld(true)
129
+ sceneRegistry.nodes.set(column.id, hiddenMesh)
130
+ sceneRegistry.byType[column.type]!.add(column.id)
131
+
132
+ mountNode(visibleColumn, [1, 2, 1], [3, 1, 0])
133
+
134
+ const world = buildFirstPersonColliderWorldFromRegistry()
135
+
136
+ expect(world).not.toBeNull()
137
+ // Bounds reflect only the visible 1×1 column at x = 3; the 10×10 mesh
138
+ // under the hidden wrapper contributed no geometry.
139
+ expect(world?.bounds?.min.x).toBeCloseTo(2.5)
140
+ expect(world?.bounds?.max.x).toBeCloseTo(3.5)
141
+ world?.dispose()
142
+ })
143
+
108
144
  test('leaves elevators to their dedicated dynamic collider meshes', () => {
109
145
  registerColliderDefinition('elevator', ElevatorNode, 'structure')
110
146
 
@@ -130,6 +166,34 @@ describe('buildFirstPersonColliderWorldFromRegistry', () => {
130
166
  world?.dispose()
131
167
  })
132
168
 
169
+ test('adds a fallback floor only for the lowest slab-less level in a building', () => {
170
+ const building = BuildingNode.parse({
171
+ id: 'building_test',
172
+ children: ['level_ground', 'level_upper'],
173
+ })
174
+ const groundLevel = LevelNode.parse({
175
+ id: 'level_ground',
176
+ parentId: building.id,
177
+ level: 0,
178
+ height: 3,
179
+ })
180
+ const upperLevel = LevelNode.parse({
181
+ id: 'level_upper',
182
+ parentId: building.id,
183
+ level: 1,
184
+ })
185
+ setSceneNodes([building, groundLevel, upperLevel])
186
+ mountRegistryGroup(groundLevel)
187
+ mountRegistryGroup(upperLevel, [0, 3, 0])
188
+
189
+ const world = buildFirstPersonColliderWorldFromRegistry()
190
+
191
+ expect(world).not.toBeNull()
192
+ expect(world?.bounds?.min.y).toBeCloseTo(-0.08)
193
+ expect(world?.bounds?.max.y).toBeCloseTo(0)
194
+ world?.dispose()
195
+ })
196
+
133
197
  test('adds a site ground collider so a spawn on bare ground has a floor', () => {
134
198
  const site = SiteNode.parse({ id: 'site_test' })
135
199
  setSceneNodes([site])
@@ -141,9 +205,42 @@ describe('buildFirstPersonColliderWorldFromRegistry', () => {
141
205
  // Ground slab sits just below the site ground plane (y = 0).
142
206
  expect(world?.bounds?.min.y).toBeCloseTo(-0.08)
143
207
  expect(world?.bounds?.max.y).toBeCloseTo(0)
144
- // Default site footprint falls back to the 30 m minimum size.
145
- expect(world?.bounds?.min.x).toBeCloseTo(-15)
146
- expect(world?.bounds?.max.x).toBeCloseTo(15)
208
+ // The ground collider extends far past the site polygon so stepping out of
209
+ // the site boundary never drops the player below the ground plane.
210
+ expect(world?.bounds?.min.x).toBeCloseTo(-1000)
211
+ expect(world?.bounds?.max.x).toBeCloseTo(1000)
212
+ expect(world?.bounds?.min.z).toBeCloseTo(-1000)
213
+ expect(world?.bounds?.max.z).toBeCloseTo(1000)
147
214
  world?.dispose()
148
215
  })
216
+
217
+ test('a sculpted site walks on its terrain instead of the flat ground slab', () => {
218
+ const base = createTerrainField({ cols: 17, rows: 17, spacing: 1, origin: [-8, -8] })
219
+ const patch = flattenPatch(base, { minX: 2, minZ: 2, maxX: 5, maxZ: 5 }, 2.5)
220
+ const field = applyHeightPatch(base, patch as never)
221
+ const site = SiteNode.parse({ id: 'site_test', terrain: encodeTerrainField(field) })
222
+ setSceneNodes([site])
223
+ mountRegistryGroup(site)
224
+
225
+ const world = buildFirstPersonColliderWorldFromRegistry()
226
+ expect(world).not.toBeNull()
227
+ if (!world) return
228
+
229
+ // The hill is in the collider: a flat slab would top out at 0.
230
+ expect(world.bounds?.max.y).toBeCloseTo(2.5)
231
+ // And it still reaches past the site so stepping out does not drop the player.
232
+ expect(world.bounds?.min.x).toBeCloseTo(-1008)
233
+ expect(world.bounds?.max.x).toBeCloseTo(1008)
234
+
235
+ // What the player actually stands on, on the plateau and off it.
236
+ const raycaster = new Raycaster()
237
+ const standOn = (x: number, z: number) => {
238
+ raycaster.set(new Vector3(x, 500, z), new Vector3(0, -1, 0))
239
+ return raycaster.intersectObject(world.mesh, false)[0]?.point.y ?? null
240
+ }
241
+ expect(standOn(3, 3)).toBeCloseTo(2.5, 4)
242
+ expect(standOn(-3, -3)).toBeCloseTo(0, 4)
243
+
244
+ world.dispose()
245
+ })
149
246
  })