@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
@@ -1,14 +1,31 @@
1
- import { sceneRegistry, type ZoneNode } from '@pascal-app/core'
1
+ import { sceneRegistry, useScene, type ZoneNode } from '@pascal-app/core'
2
2
  import { useViewer } from '@pascal-app/viewer'
3
3
  import { useThree } from '@react-three/fiber'
4
4
  import { useCallback, useEffect, useRef } from 'react'
5
- import { Box3, type Camera, type Object3D, Vector3 } from 'three'
5
+ import {
6
+ Box3,
7
+ type Camera,
8
+ Matrix4,
9
+ type Object3D,
10
+ Plane,
11
+ Raycaster,
12
+ Vector2,
13
+ Vector3,
14
+ } from 'three'
6
15
  import useEditor from '../../../store/use-editor'
16
+ import useInteractionScope from '../../../store/use-interaction-scope'
7
17
  import {
8
18
  clearBoxSelectHandled,
9
19
  isBoxSelectPointerSuppressed,
10
20
  markBoxSelectHandled,
11
21
  } from './box-select-state'
22
+ import {
23
+ convexHull2D,
24
+ type Point2,
25
+ polygonsIntersect,
26
+ rectIntersectsHull,
27
+ segmentIntersectsPolygon,
28
+ } from './marquee-geometry'
12
29
  import { PlaneBoxSelectTool } from './plane-box-select-tool'
13
30
  import {
14
31
  createScreenRectangleSelectionElement,
@@ -18,14 +35,20 @@ import {
18
35
  SCREEN_RECTANGLE_SELECTION_DRAG_THRESHOLD_PX,
19
36
  type ScreenRect,
20
37
  screenRectFromDomRect,
21
- screenRectsIntersect,
22
38
  updateScreenRectangleSelectionElement,
23
39
  } from './screen-rectangle-selection'
24
40
  import { collectSelectableCandidateIds } from './select-candidates'
25
41
 
26
42
  const tempBox = new Box3()
43
+ const tempChildBox = new Box3()
44
+ const tempInvWorld = new Matrix4()
45
+ const tempRelMatrix = new Matrix4()
27
46
  const tempWorldPoint = new Vector3()
28
47
  const tempScreenPoint = new Vector3()
48
+ const tempNDC = new Vector2()
49
+ const tempPlane = new Plane()
50
+ const tempRaycaster = new Raycaster()
51
+ const UP = new Vector3(0, 1, 0)
29
52
  const boxCorners = [
30
53
  new Vector3(),
31
54
  new Vector3(),
@@ -58,55 +81,72 @@ function projectWorldPointToScreen(
58
81
  ]
59
82
  }
60
83
 
61
- function getObjectScreenRect(
84
+ /**
85
+ * Union bounding box of the object's mesh geometry in the OBJECT's OWN frame
86
+ * (an oriented box). The world AABB the previous implementation used inflates
87
+ * around rotated geometry — a diagonal wall's world AABB spans a whole square
88
+ * — and projecting THAT to a screen AABB inflates again, which made the
89
+ * marquee select objects visually far from the cursor.
90
+ */
91
+ function computeLocalBox(object: Object3D): Box3 | null {
92
+ tempBox.makeEmpty()
93
+ tempInvWorld.copy(object.matrixWorld).invert()
94
+ object.traverse((child) => {
95
+ const mesh = child as {
96
+ isMesh?: boolean
97
+ geometry?: { boundingBox: Box3 | null; computeBoundingBox: () => void }
98
+ matrixWorld: import('three').Matrix4
99
+ }
100
+ if (!mesh.isMesh || !mesh.geometry) return
101
+ if (!mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox()
102
+ const bounds = mesh.geometry.boundingBox
103
+ if (!bounds || bounds.isEmpty()) return
104
+ tempChildBox.copy(bounds)
105
+ tempRelMatrix.multiplyMatrices(tempInvWorld, mesh.matrixWorld)
106
+ tempChildBox.applyMatrix4(tempRelMatrix)
107
+ tempBox.union(tempChildBox)
108
+ })
109
+ return tempBox.isEmpty() ? null : tempBox
110
+ }
111
+
112
+ /** Screen-space convex hull of the object's oriented bounding box. */
113
+ function getObjectScreenHull(
62
114
  object: Object3D,
63
115
  camera: Camera,
64
116
  canvasRect: DOMRect,
65
- ): ScreenRect | null {
117
+ ): Point2[] | null {
66
118
  object.updateWorldMatrix(true, true)
67
- tempBox.setFromObject(object)
119
+ const localBox = computeLocalBox(object)
68
120
 
69
- if (tempBox.isEmpty()) {
121
+ if (!localBox) {
70
122
  object.getWorldPosition(tempWorldPoint)
71
123
  const projected = projectWorldPointToScreen(tempWorldPoint, camera, canvasRect)
72
- if (!projected) return null
73
- const [x, y] = projected
74
- return { minX: x, minY: y, maxX: x, maxY: y }
124
+ return projected ? [projected] : null
75
125
  }
76
126
 
77
- boxCorners[0]!.set(tempBox.min.x, tempBox.min.y, tempBox.min.z)
78
- boxCorners[1]!.set(tempBox.min.x, tempBox.min.y, tempBox.max.z)
79
- boxCorners[2]!.set(tempBox.min.x, tempBox.max.y, tempBox.min.z)
80
- boxCorners[3]!.set(tempBox.min.x, tempBox.max.y, tempBox.max.z)
81
- boxCorners[4]!.set(tempBox.max.x, tempBox.min.y, tempBox.min.z)
82
- boxCorners[5]!.set(tempBox.max.x, tempBox.min.y, tempBox.max.z)
83
- boxCorners[6]!.set(tempBox.max.x, tempBox.max.y, tempBox.min.z)
84
- boxCorners[7]!.set(tempBox.max.x, tempBox.max.y, tempBox.max.z)
85
-
86
- let minX = Number.POSITIVE_INFINITY
87
- let minY = Number.POSITIVE_INFINITY
88
- let maxX = Number.NEGATIVE_INFINITY
89
- let maxY = Number.NEGATIVE_INFINITY
127
+ boxCorners[0]!.set(localBox.min.x, localBox.min.y, localBox.min.z)
128
+ boxCorners[1]!.set(localBox.min.x, localBox.min.y, localBox.max.z)
129
+ boxCorners[2]!.set(localBox.min.x, localBox.max.y, localBox.min.z)
130
+ boxCorners[3]!.set(localBox.min.x, localBox.max.y, localBox.max.z)
131
+ boxCorners[4]!.set(localBox.max.x, localBox.min.y, localBox.min.z)
132
+ boxCorners[5]!.set(localBox.max.x, localBox.min.y, localBox.max.z)
133
+ boxCorners[6]!.set(localBox.max.x, localBox.max.y, localBox.min.z)
134
+ boxCorners[7]!.set(localBox.max.x, localBox.max.y, localBox.max.z)
90
135
 
136
+ const projectedPoints: Point2[] = []
91
137
  for (const corner of boxCorners) {
138
+ corner.applyMatrix4(object.matrixWorld)
92
139
  const projected = projectWorldPointToScreen(corner, camera, canvasRect)
93
- if (!projected) continue
94
- const [x, y] = projected
95
- minX = Math.min(minX, x)
96
- minY = Math.min(minY, y)
97
- maxX = Math.max(maxX, x)
98
- maxY = Math.max(maxY, y)
140
+ if (projected) projectedPoints.push(projected)
99
141
  }
100
142
 
101
- if (minX !== Number.POSITIVE_INFINITY) {
102
- return { minX, minY, maxX, maxY }
143
+ if (projectedPoints.length === 0) {
144
+ object.getWorldPosition(tempWorldPoint)
145
+ const projected = projectWorldPointToScreen(tempWorldPoint, camera, canvasRect)
146
+ return projected ? [projected] : null
103
147
  }
104
148
 
105
- object.getWorldPosition(tempWorldPoint)
106
- const projected = projectWorldPointToScreen(tempWorldPoint, camera, canvasRect)
107
- if (!projected) return null
108
- const [x, y] = projected
109
- return { minX: x, minY: y, maxX: x, maxY: y }
149
+ return convexHull2D(projectedPoints)
110
150
  }
111
151
 
112
152
  function isObjectVisible(object: Object3D): boolean {
@@ -118,6 +158,46 @@ function isObjectVisible(object: Object3D): boolean {
118
158
  return true
119
159
  }
120
160
 
161
+ const isVec2 = (v: unknown): v is [number, number] =>
162
+ Array.isArray(v) && v.length === 2 && v.every((n) => typeof n === 'number')
163
+ const isVec2Array = (v: unknown): v is [number, number][] =>
164
+ Array.isArray(v) && v.length > 0 && v.every(isVec2)
165
+
166
+ /**
167
+ * The marquee rect projected onto the active level's floor plane, in the
168
+ * LEVEL frame — a convex quad (perspective keeps rect convexity). Null when
169
+ * any corner ray misses the plane (camera near the horizon); callers fall
170
+ * back to the screen-hull test then.
171
+ */
172
+ function marqueeGroundQuad(rect: ScreenRect, camera: Camera, canvasRect: DOMRect): Point2[] | null {
173
+ const levelId = useViewer.getState().selection.levelId
174
+ const levelObject = levelId ? sceneRegistry.nodes.get(levelId) : null
175
+ if (!levelObject) return null
176
+ levelObject.updateWorldMatrix(true, false)
177
+ tempInvWorld.copy(levelObject.matrixWorld).invert()
178
+ levelObject.getWorldPosition(tempWorldPoint)
179
+ tempPlane.set(UP, -tempWorldPoint.y)
180
+
181
+ const corners: [number, number][] = [
182
+ [rect.minX, rect.minY],
183
+ [rect.maxX, rect.minY],
184
+ [rect.maxX, rect.maxY],
185
+ [rect.minX, rect.maxY],
186
+ ]
187
+ const quad: Point2[] = []
188
+ for (const [cx, cy] of corners) {
189
+ tempNDC.set(
190
+ ((cx - canvasRect.left) / canvasRect.width) * 2 - 1,
191
+ -((cy - canvasRect.top) / canvasRect.height) * 2 + 1,
192
+ )
193
+ tempRaycaster.setFromCamera(tempNDC, camera)
194
+ if (!tempRaycaster.ray.intersectPlane(tempPlane, tempWorldPoint)) return null
195
+ tempWorldPoint.applyMatrix4(tempInvWorld)
196
+ quad.push([tempWorldPoint.x, tempWorldPoint.z])
197
+ }
198
+ return quad
199
+ }
200
+
121
201
  function collectNodeIdsInScreenRect(
122
202
  rect: ScreenRect,
123
203
  camera: Camera,
@@ -126,11 +206,37 @@ function collectNodeIdsInScreenRect(
126
206
  const canvasRect = canvas.getBoundingClientRect()
127
207
  const result: string[] = []
128
208
 
209
+ // Plan-footprint membership for the data kinds (walls / fences by their
210
+ // segment, slabs / ceilings / zones by their polygon) — exact under any
211
+ // rotation, matching the plane-marquee tool's semantics. Kinds whose
212
+ // placement lives in mesh transforms (items, columns, …) intersect the
213
+ // marquee with their oriented bbox projected to a screen hull instead.
214
+ const quad = marqueeGroundQuad(rect, camera, canvasRect)
215
+ const nodes = useScene.getState().nodes
216
+
129
217
  for (const id of collectSelectableCandidateIds()) {
130
218
  const object = sceneRegistry.nodes.get(id)
131
219
  if (!object || !isObjectVisible(object)) continue
132
- const objectRect = getObjectScreenRect(object, camera, canvasRect)
133
- if (objectRect && screenRectsIntersect(rect, objectRect)) {
220
+
221
+ if (quad) {
222
+ const node = nodes[id as keyof typeof nodes] as
223
+ | { start?: unknown; end?: unknown; polygon?: unknown }
224
+ | undefined
225
+ if (node) {
226
+ const { start, end, polygon } = node
227
+ if (isVec2(start) && isVec2(end)) {
228
+ if (segmentIntersectsPolygon(start, end, quad)) result.push(id)
229
+ continue
230
+ }
231
+ if (isVec2Array(polygon)) {
232
+ if (polygonsIntersect(polygon, quad)) result.push(id)
233
+ continue
234
+ }
235
+ }
236
+ }
237
+
238
+ const hull = getObjectScreenHull(object, camera, canvasRect)
239
+ if (hull && rectIntersectsHull(rect, hull)) {
134
240
  result.push(id)
135
241
  }
136
242
  }
@@ -139,7 +245,7 @@ function collectNodeIdsInScreenRect(
139
245
  }
140
246
 
141
247
  function commitBoxSelection(ids: string[], event: PointerEvent) {
142
- const shouldAppend = event.metaKey || event.ctrlKey
248
+ const shouldAppend = event.metaKey || event.ctrlKey || event.shiftKey
143
249
  const { phase, structureLayer } = useEditor.getState()
144
250
  const viewer = useViewer.getState()
145
251
 
@@ -191,6 +297,12 @@ const ScreenRectangleSelectTool: React.FC = () => {
191
297
  const currentClientXRef = useRef(0)
192
298
  const currentClientYRef = useRef(0)
193
299
  const spaceDownRef = useRef(false)
300
+ // rAF throttle for the expensive marquee preview pass. pointermove can fire
301
+ // several times per animation frame; the per-node AABB projection in
302
+ // `collectNodeIdsInScreenRect` only needs to run once per frame. We stash the
303
+ // latest clamped rect and process it inside the rAF callback.
304
+ const previewRafRef = useRef<number | null>(null)
305
+ const pendingPreviewRectRef = useRef<ScreenRect | null>(null)
194
306
 
195
307
  const syncPreviewSelectedIds = useCallback(
196
308
  (nextIds: string[]) => {
@@ -206,6 +318,11 @@ const ScreenRectangleSelectTool: React.FC = () => {
206
318
  pointerDownRef.current = false
207
319
  isDraggingRef.current = false
208
320
  pointerIdRef.current = null
321
+ if (previewRafRef.current !== null) {
322
+ cancelAnimationFrame(previewRafRef.current)
323
+ previewRafRef.current = null
324
+ }
325
+ pendingPreviewRectRef.current = null
209
326
  hideScreenRectangleSelectionElement(elementRef.current)
210
327
  syncPreviewSelectedIds([])
211
328
 
@@ -213,6 +330,7 @@ const ScreenRectangleSelectTool: React.FC = () => {
213
330
  useViewer.getState().setInputDragging(false)
214
331
  ownsInputDraggingRef.current = false
215
332
  }
333
+ useInteractionScope.getState().endIf((s) => s.kind === 'box-select')
216
334
  }, [syncPreviewSelectedIds])
217
335
 
218
336
  useEffect(() => {
@@ -263,6 +381,14 @@ const ScreenRectangleSelectTool: React.FC = () => {
263
381
  useEffect(() => {
264
382
  const canvas = gl.domElement
265
383
 
384
+ const flushPreview = () => {
385
+ previewRafRef.current = null
386
+ const rect = pendingPreviewRectRef.current
387
+ if (!rect) return
388
+ pendingPreviewRectRef.current = null
389
+ syncPreviewSelectedIds(collectNodeIdsInScreenRect(rect, camera, canvas))
390
+ }
391
+
266
392
  const updateDrag = (event: PointerEvent) => {
267
393
  if (!pointerDownRef.current) return
268
394
  if (pointerIdRef.current !== null && event.pointerId !== pointerIdRef.current) return
@@ -291,6 +417,7 @@ const ScreenRectangleSelectTool: React.FC = () => {
291
417
  isDraggingRef.current = true
292
418
  ownsInputDraggingRef.current = true
293
419
  useViewer.getState().setInputDragging(true)
420
+ useInteractionScope.getState().begin({ kind: 'box-select' })
294
421
  markBoxSelectHandled()
295
422
  try {
296
423
  canvas.setPointerCapture(event.pointerId)
@@ -311,13 +438,22 @@ const ScreenRectangleSelectTool: React.FC = () => {
311
438
  screenRectFromDomRect(canvas.getBoundingClientRect()),
312
439
  )
313
440
  if (!clampedRect) {
441
+ if (previewRafRef.current !== null) {
442
+ cancelAnimationFrame(previewRafRef.current)
443
+ previewRafRef.current = null
444
+ }
445
+ pendingPreviewRectRef.current = null
314
446
  hideScreenRectangleSelectionElement(elementRef.current)
315
447
  syncPreviewSelectedIds([])
316
448
  return
317
449
  }
318
450
 
319
451
  updateScreenRectangleSelectionElement(elementRef.current!, clampedRect)
320
- syncPreviewSelectedIds(collectNodeIdsInScreenRect(clampedRect, camera, canvas))
452
+ // Coalesce the per-node AABB projection to one run per animation frame.
453
+ pendingPreviewRectRef.current = clampedRect
454
+ if (previewRafRef.current === null) {
455
+ previewRafRef.current = requestAnimationFrame(flushPreview)
456
+ }
321
457
  }
322
458
 
323
459
  const finishDrag = (event: PointerEvent) => {
@@ -0,0 +1,161 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import {
3
+ convexHull2D,
4
+ type Point2,
5
+ polygonsIntersect,
6
+ rectIntersectsHull,
7
+ type ScreenRect,
8
+ segmentIntersectsPolygon,
9
+ } from './marquee-geometry'
10
+
11
+ const rect = (minX: number, minY: number, maxX: number, maxY: number): ScreenRect => ({
12
+ minX,
13
+ minY,
14
+ maxX,
15
+ maxY,
16
+ })
17
+
18
+ describe('convexHull2D', () => {
19
+ test('drops interior points and keeps the hull', () => {
20
+ const hull = convexHull2D([
21
+ [0, 0],
22
+ [4, 0],
23
+ [4, 4],
24
+ [0, 4],
25
+ [2, 2],
26
+ [1, 3],
27
+ ])
28
+ expect(hull).toHaveLength(4)
29
+ const set = new Set(hull.map((p) => p.join(',')))
30
+ expect(set).toEqual(new Set(['0,0', '4,0', '4,4', '0,4']))
31
+ })
32
+
33
+ test('passes degenerate inputs through', () => {
34
+ expect(convexHull2D([])).toEqual([])
35
+ expect(convexHull2D([[1, 2]])).toEqual([[1, 2]])
36
+ expect(
37
+ convexHull2D([
38
+ [0, 0],
39
+ [2, 2],
40
+ ]),
41
+ ).toHaveLength(2)
42
+ })
43
+ })
44
+
45
+ describe('rectIntersectsHull', () => {
46
+ // The regression case: a thin diagonal wall. Its world AABB spans the full
47
+ // square, so the old AABB test matched a marquee sitting in the empty
48
+ // corner; the hull test must not.
49
+ const diagonalWall: Point2[] = convexHull2D([
50
+ [0, 0],
51
+ [1, 0.6],
52
+ [10, 9.4],
53
+ [10, 10],
54
+ [9, 9.4],
55
+ [0, 0.6],
56
+ ])
57
+
58
+ test('marquee in the empty corner of a diagonal shape does not match', () => {
59
+ expect(rectIntersectsHull(rect(7, 0, 9, 2), diagonalWall)).toBe(false)
60
+ expect(rectIntersectsHull(rect(0, 7, 2, 9), diagonalWall)).toBe(false)
61
+ })
62
+
63
+ test('marquee crossing the diagonal matches', () => {
64
+ expect(rectIntersectsHull(rect(4, 4, 6, 6), diagonalWall)).toBe(true)
65
+ })
66
+
67
+ test('hull vertex inside the marquee matches', () => {
68
+ expect(rectIntersectsHull(rect(-1, -1, 0.5, 0.5), diagonalWall)).toBe(true)
69
+ })
70
+
71
+ test('marquee fully inside a large hull matches', () => {
72
+ const square: Point2[] = [
73
+ [0, 0],
74
+ [10, 0],
75
+ [10, 10],
76
+ [0, 10],
77
+ ]
78
+ expect(rectIntersectsHull(rect(4, 4, 5, 5), square)).toBe(true)
79
+ })
80
+
81
+ test('edge-only crossing (no vertices contained either way) matches', () => {
82
+ // Tall thin rect crossing a wide thin hull like a plus sign.
83
+ const bar: Point2[] = [
84
+ [0, 4],
85
+ [10, 4],
86
+ [10, 6],
87
+ [0, 6],
88
+ ]
89
+ expect(rectIntersectsHull(rect(4, 0, 6, 10), bar)).toBe(true)
90
+ })
91
+
92
+ test('disjoint shapes do not match', () => {
93
+ const tri: Point2[] = [
94
+ [0, 0],
95
+ [2, 0],
96
+ [1, 2],
97
+ ]
98
+ expect(rectIntersectsHull(rect(5, 5, 7, 7), tri)).toBe(false)
99
+ })
100
+
101
+ test('single projected point matches only when inside the rect', () => {
102
+ expect(rectIntersectsHull(rect(0, 0, 2, 2), [[1, 1]])).toBe(true)
103
+ expect(rectIntersectsHull(rect(0, 0, 2, 2), [[3, 3]])).toBe(false)
104
+ })
105
+ })
106
+
107
+ describe('segmentIntersectsPolygon', () => {
108
+ const quad: Point2[] = [
109
+ [0, 0],
110
+ [4, 0],
111
+ [4, 4],
112
+ [0, 4],
113
+ ]
114
+ test('crossing segment matches', () => {
115
+ expect(segmentIntersectsPolygon([-1, 2], [5, 2], quad)).toBe(true)
116
+ })
117
+ test('fully inside matches', () => {
118
+ expect(segmentIntersectsPolygon([1, 1], [2, 2], quad)).toBe(true)
119
+ })
120
+ test('outside segment does not match', () => {
121
+ expect(segmentIntersectsPolygon([5, 5], [7, 8], quad)).toBe(false)
122
+ })
123
+ })
124
+
125
+ describe('polygonsIntersect', () => {
126
+ const quad: Point2[] = [
127
+ [0, 0],
128
+ [4, 0],
129
+ [4, 4],
130
+ [0, 4],
131
+ ]
132
+ test('overlapping polygons match', () => {
133
+ expect(
134
+ polygonsIntersect(quad, [
135
+ [3, 3],
136
+ [6, 3],
137
+ [6, 6],
138
+ [3, 6],
139
+ ]),
140
+ ).toBe(true)
141
+ })
142
+ test('containment matches both ways', () => {
143
+ const inner: Point2[] = [
144
+ [1, 1],
145
+ [2, 1],
146
+ [2, 2],
147
+ ]
148
+ expect(polygonsIntersect(quad, inner)).toBe(true)
149
+ expect(polygonsIntersect(inner, quad)).toBe(true)
150
+ })
151
+ test('a rotated diamond beside the quad does not match', () => {
152
+ expect(
153
+ polygonsIntersect(quad, [
154
+ [7, 2],
155
+ [9, 0],
156
+ [11, 2],
157
+ [9, 4],
158
+ ]),
159
+ ).toBe(false)
160
+ })
161
+ })
@@ -0,0 +1,155 @@
1
+ // Pure 2D geometry for the screen-rect marquee's membership test. The
2
+ // marquee intersects each node's ORIENTED bounding box projected to screen —
3
+ // a convex hull — instead of a screen-space AABB of the world AABB, whose
4
+ // double inflation (world AABB around rotated geometry, then a 2D AABB
5
+ // around its projection) selected objects visually far from the cursor.
6
+
7
+ export type ScreenRect = {
8
+ minX: number
9
+ minY: number
10
+ maxX: number
11
+ maxY: number
12
+ }
13
+
14
+ export type Point2 = [number, number]
15
+
16
+ function cross(o: Point2, a: Point2, b: Point2): number {
17
+ return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
18
+ }
19
+
20
+ /** Andrew monotone-chain convex hull. Returns CCW hull without the closing
21
+ * point; degenerate inputs (0–2 points, collinear sets) pass through. */
22
+ export function convexHull2D(points: readonly Point2[]): Point2[] {
23
+ if (points.length <= 2) return [...points]
24
+ const sorted = [...points].sort((a, b) => a[0] - b[0] || a[1] - b[1])
25
+ const lower: Point2[] = []
26
+ for (const p of sorted) {
27
+ while (lower.length >= 2 && cross(lower[lower.length - 2]!, lower[lower.length - 1]!, p) <= 0) {
28
+ lower.pop()
29
+ }
30
+ lower.push(p)
31
+ }
32
+ const upper: Point2[] = []
33
+ for (let i = sorted.length - 1; i >= 0; i--) {
34
+ const p = sorted[i]!
35
+ while (upper.length >= 2 && cross(upper[upper.length - 2]!, upper[upper.length - 1]!, p) <= 0) {
36
+ upper.pop()
37
+ }
38
+ upper.push(p)
39
+ }
40
+ lower.pop()
41
+ upper.pop()
42
+ const hull = [...lower, ...upper]
43
+ return hull.length > 0 ? hull : [sorted[0]!]
44
+ }
45
+
46
+ function pointInRect(p: Point2, rect: ScreenRect): boolean {
47
+ return p[0] >= rect.minX && p[0] <= rect.maxX && p[1] >= rect.minY && p[1] <= rect.maxY
48
+ }
49
+
50
+ /** Ray-crossing point-in-polygon — works for convex and degenerate hulls. */
51
+ function pointInPolygon(p: Point2, polygon: readonly Point2[]): boolean {
52
+ let inside = false
53
+ for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
54
+ const [xi, yi] = polygon[i]!
55
+ const [xj, yj] = polygon[j]!
56
+ if (yi > p[1] !== yj > p[1] && p[0] < ((xj - xi) * (p[1] - yi)) / (yj - yi) + xi) {
57
+ inside = !inside
58
+ }
59
+ }
60
+ return inside
61
+ }
62
+
63
+ function orientation(a: Point2, b: Point2, c: Point2): number {
64
+ const v = cross(a, b, c)
65
+ if (v > 0) return 1
66
+ if (v < 0) return -1
67
+ return 0
68
+ }
69
+
70
+ function onSegment(a: Point2, b: Point2, p: Point2): boolean {
71
+ return (
72
+ Math.min(a[0], b[0]) <= p[0] &&
73
+ p[0] <= Math.max(a[0], b[0]) &&
74
+ Math.min(a[1], b[1]) <= p[1] &&
75
+ p[1] <= Math.max(a[1], b[1])
76
+ )
77
+ }
78
+
79
+ function segmentsIntersect(a1: Point2, a2: Point2, b1: Point2, b2: Point2): boolean {
80
+ const o1 = orientation(a1, a2, b1)
81
+ const o2 = orientation(a1, a2, b2)
82
+ const o3 = orientation(b1, b2, a1)
83
+ const o4 = orientation(b1, b2, a2)
84
+ if (o1 !== o2 && o3 !== o4) return true
85
+ if (o1 === 0 && onSegment(a1, a2, b1)) return true
86
+ if (o2 === 0 && onSegment(a1, a2, b2)) return true
87
+ if (o3 === 0 && onSegment(b1, b2, a1)) return true
88
+ if (o4 === 0 && onSegment(b1, b2, a2)) return true
89
+ return false
90
+ }
91
+
92
+ /** Segment vs polygon (general, possibly concave): endpoint containment or
93
+ * any edge crossing. */
94
+ export function segmentIntersectsPolygon(
95
+ a: Point2,
96
+ b: Point2,
97
+ polygon: readonly Point2[],
98
+ ): boolean {
99
+ if (polygon.length === 0) return false
100
+ if (polygon.length === 1) return false
101
+ if (polygon.length >= 3 && (pointInPolygon(a, polygon) || pointInPolygon(b, polygon))) return true
102
+ for (let i = 0; i < polygon.length; i++) {
103
+ if (segmentsIntersect(a, b, polygon[i]!, polygon[(i + 1) % polygon.length]!)) return true
104
+ }
105
+ return false
106
+ }
107
+
108
+ /** Polygon vs polygon (general): containment either way or any edge crossing. */
109
+ export function polygonsIntersect(a: readonly Point2[], b: readonly Point2[]): boolean {
110
+ if (a.length === 0 || b.length === 0) return false
111
+ if (a.length >= 3 && b.some((p) => pointInPolygon(p, a))) return true
112
+ if (b.length >= 3 && a.some((p) => pointInPolygon(p, b))) return true
113
+ for (let i = 0; i < a.length; i++) {
114
+ const a1 = a[i]!
115
+ const a2 = a[(i + 1) % a.length]!
116
+ for (let j = 0; j < b.length; j++) {
117
+ if (segmentsIntersect(a1, a2, b[j]!, b[(j + 1) % b.length]!)) return true
118
+ }
119
+ }
120
+ return false
121
+ }
122
+
123
+ /**
124
+ * Does the axis-aligned marquee rect intersect the (convex) hull polygon?
125
+ * Covers all cases: hull vertex inside the rect, rect fully inside the hull,
126
+ * and pure edge crossings.
127
+ */
128
+ export function rectIntersectsHull(rect: ScreenRect, hull: readonly Point2[]): boolean {
129
+ if (hull.length === 0) return false
130
+ for (const p of hull) {
131
+ if (pointInRect(p, rect)) return true
132
+ }
133
+ if (hull.length === 1) return false
134
+ const rectCorners: Point2[] = [
135
+ [rect.minX, rect.minY],
136
+ [rect.maxX, rect.minY],
137
+ [rect.maxX, rect.maxY],
138
+ [rect.minX, rect.maxY],
139
+ ]
140
+ if (hull.length >= 3) {
141
+ for (const c of rectCorners) {
142
+ if (pointInPolygon(c, hull)) return true
143
+ }
144
+ }
145
+ for (let i = 0; i < hull.length; i++) {
146
+ const h1 = hull[i]!
147
+ const h2 = hull[(i + 1) % hull.length]!
148
+ for (let j = 0; j < 4; j++) {
149
+ const r1 = rectCorners[j]!
150
+ const r2 = rectCorners[(j + 1) % 4]!
151
+ if (segmentsIntersect(h1, h2, r1, r2)) return true
152
+ }
153
+ }
154
+ return false
155
+ }
@@ -10,7 +10,6 @@ import {
10
10
  type ZoneNode,
11
11
  } from '@pascal-app/core'
12
12
  import { useViewer } from '@pascal-app/viewer'
13
- import type { ThreeElements } from '@react-three/fiber'
14
13
  import { useThree } from '@react-three/fiber'
15
14
  import { useCallback, useEffect, useRef } from 'react'
16
15
  import {
@@ -34,12 +33,6 @@ import { CursorSphere } from '../shared/cursor-sphere'
34
33
  import { isBoxSelectPointerSuppressed, markBoxSelectHandled } from './box-select-state'
35
34
  import { collectSelectableCandidateIds } from './select-candidates'
36
35
 
37
- declare module 'react/jsx-runtime' {
38
- namespace JSX {
39
- interface IntrinsicElements extends ThreeElements {}
40
- }
41
- }
42
-
43
36
  type Bounds = { minX: number; maxX: number; minZ: number; maxZ: number }
44
37
 
45
38
  const BOX_SELECT_ACCENT_COLOR = '#818cf8'
@@ -446,7 +439,7 @@ export const PlaneBoxSelectTool: React.FC = () => {
446
439
  }
447
440
 
448
441
  const ids = collectNodeIdsInPlaneBounds(bounds)
449
- const shouldAppend = event.metaKey || event.ctrlKey
442
+ const shouldAppend = event.metaKey || event.ctrlKey || event.shiftKey
450
443
  const { phase, structureLayer } = useEditor.getState()
451
444
 
452
445
  if (phase === 'structure' && structureLayer === 'zones') {
@@ -50,7 +50,7 @@ export function collectSelectableCandidateIds(): string[] {
50
50
 
51
51
  if (!levelId) return []
52
52
  const levelNode = nodes[levelId as AnyNodeId] as LevelNode | undefined
53
- if (!levelNode || levelNode.type !== 'level') return []
53
+ if (levelNode?.type !== 'level') return []
54
54
 
55
55
  if (phase === 'structure' && structureLayer === 'zones') {
56
56
  for (const childId of levelNode.children) {