@pascal-app/editor 0.9.1 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (262) hide show
  1. package/package.json +12 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
  3. package/src/components/editor/bake-exporter.tsx +47 -0
  4. package/src/components/editor/custom-camera-controls.tsx +63 -25
  5. package/src/components/editor/editor-layout-mobile.tsx +5 -0
  6. package/src/components/editor/editor-layout-v2.tsx +18 -0
  7. package/src/components/editor/export-manager.tsx +71 -63
  8. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +36 -3
  10. package/src/components/editor/first-person/build-collider-world.ts +32 -5
  11. package/src/components/editor/first-person-controls.tsx +118 -7
  12. package/src/components/editor/floating-action-menu.tsx +410 -33
  13. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  14. package/src/components/editor/floorplan-background-selection.test.ts +58 -0
  15. package/src/components/editor/floorplan-background-selection.ts +3 -2
  16. package/src/components/editor/floorplan-panel.tsx +1804 -849
  17. package/src/components/editor/grid.tsx +166 -39
  18. package/src/components/editor/group-actions.ts +457 -0
  19. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  20. package/src/components/editor/group-move-3d.ts +397 -0
  21. package/src/components/editor/group-rotate-handle.tsx +102 -64
  22. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  23. package/src/components/editor/group-transform-shared.test.ts +192 -1
  24. package/src/components/editor/group-transform-shared.ts +240 -7
  25. package/src/components/editor/handles/handle-arrow.tsx +81 -23
  26. package/src/components/editor/handles/use-handle-drag.ts +37 -4
  27. package/src/components/editor/index.tsx +248 -27
  28. package/src/components/editor/measurement-pill.tsx +62 -22
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +366 -173
  31. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  32. package/src/components/editor/quick-measurement-card.tsx +70 -0
  33. package/src/components/editor/quick-measurement-hud.tsx +28 -0
  34. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  35. package/src/components/editor/selection-manager.tsx +812 -466
  36. package/src/components/editor/site-edge-labels.tsx +4 -4
  37. package/src/components/editor/slab-hole-highlights.tsx +13 -5
  38. package/src/components/editor/snapshot-capture-overlay.tsx +255 -115
  39. package/src/components/editor/three-context-bridge.ts +22 -0
  40. package/src/components/editor/thumbnail-generator.tsx +136 -43
  41. package/src/components/editor/use-floorplan-background-placement.ts +113 -45
  42. package/src/components/editor/use-floorplan-scene-data.ts +5 -5
  43. package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
  44. package/src/components/editor/wall-measurement-label.tsx +3 -13
  45. package/src/components/editor/wall-move-side-handles.tsx +36 -21
  46. package/src/components/editor/wall-snap-beacon-layer.tsx +160 -6
  47. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
  48. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +1 -1
  49. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  50. package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
  51. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  52. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
  53. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  54. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +169 -4
  55. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +300 -59
  56. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
  57. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
  58. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
  59. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +4 -1
  60. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
  61. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1704 -342
  62. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  63. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +419 -60
  64. package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
  65. package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
  66. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  67. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  68. package/src/components/systems/zone/zone-system.tsx +42 -13
  69. package/src/components/tools/elevator/elevator-tool.tsx +34 -8
  70. package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
  71. package/src/components/tools/fence/fence-drafting.ts +80 -8
  72. package/src/components/tools/item/move-tool.tsx +17 -13
  73. package/src/components/tools/item/placement-math.test.ts +13 -1
  74. package/src/components/tools/item/placement-math.ts +28 -2
  75. package/src/components/tools/item/placement-strategies.ts +246 -5
  76. package/src/components/tools/item/placement-types.ts +13 -1
  77. package/src/components/tools/item/use-draft-node.ts +31 -1
  78. package/src/components/tools/item/use-placement-coordinator.tsx +761 -121
  79. package/src/components/tools/registry/move-registry-node-tool.tsx +561 -99
  80. package/src/components/tools/roof/roof-tool.tsx +320 -94
  81. package/src/components/tools/select/box-select-state.ts +18 -7
  82. package/src/components/tools/select/box-select-tool.tsx +176 -40
  83. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  84. package/src/components/tools/select/marquee-geometry.ts +155 -0
  85. package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
  86. package/src/components/tools/select/select-candidates.ts +1 -1
  87. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  88. package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
  89. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  90. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  91. package/src/components/tools/shared/placement-box.tsx +183 -1
  92. package/src/components/tools/shared/polygon-editor.tsx +276 -35
  93. package/src/components/tools/site/site-boundary-editor.tsx +88 -36
  94. package/src/components/tools/stair/stair-defaults.ts +1 -0
  95. package/src/components/tools/stair/stair-tool.tsx +82 -11
  96. package/src/components/tools/tool-manager.tsx +86 -25
  97. package/src/components/tools/wall/wall-drafting.test.ts +330 -0
  98. package/src/components/tools/wall/wall-drafting.ts +180 -103
  99. package/src/components/tools/wall/wall-snap-geometry.test.ts +44 -0
  100. package/src/components/tools/wall/wall-snap-geometry.ts +67 -6
  101. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  102. package/src/components/tools/zone/zone-tool.tsx +82 -88
  103. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  104. package/src/components/ui/action-menu/control-modes.tsx +40 -93
  105. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  106. package/src/components/ui/action-menu/index.tsx +2 -4
  107. package/src/components/ui/action-menu/measurement-control.tsx +188 -0
  108. package/src/components/ui/action-menu/structure-tools.tsx +22 -13
  109. package/src/components/ui/action-menu/view-toggles.tsx +42 -90
  110. package/src/components/ui/command-palette/editor-commands.tsx +2 -2
  111. package/src/components/ui/command-palette/index.tsx +4 -3
  112. package/src/components/ui/controls/material-paint-panel.tsx +86 -17
  113. package/src/components/ui/controls/material-picker.tsx +83 -152
  114. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  115. package/src/components/ui/controls/metric-control.tsx +118 -44
  116. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  117. package/src/components/ui/controls/slider-control.tsx +88 -38
  118. package/src/components/ui/floating-level-selector.tsx +1 -1
  119. package/src/components/ui/helpers/building-helper.tsx +10 -23
  120. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  121. package/src/components/ui/helpers/helper-manager.tsx +227 -14
  122. package/src/components/ui/helpers/item-helper.tsx +28 -32
  123. package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
  124. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  125. package/src/components/ui/icon-ref.tsx +47 -0
  126. package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
  127. package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
  128. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  129. package/src/components/ui/panels/node-display.ts +17 -17
  130. package/src/components/ui/panels/panel-manager.tsx +22 -13
  131. package/src/components/ui/panels/panel-wrapper.tsx +24 -3
  132. package/src/components/ui/panels/parametric-inspector.tsx +15 -2
  133. package/src/components/ui/panels/reference-panel.tsx +54 -19
  134. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  135. package/src/components/ui/primitives/sidebar.tsx +1 -0
  136. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  137. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  138. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  139. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +71 -40
  140. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +60 -6
  141. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
  142. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
  143. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
  144. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  145. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  146. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
  147. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
  148. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
  149. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
  150. package/src/components/ui/sidebar/panels/site-panel/index.tsx +40 -17
  151. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  152. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  153. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
  154. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  155. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
  156. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
  157. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
  158. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  159. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  160. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
  161. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  162. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  163. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  164. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  165. package/src/components/ui/sidebar/tab-bar.tsx +42 -28
  166. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  167. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  168. package/src/components/ui/snap-target-badge.tsx +88 -0
  169. package/src/components/viewer-overlay.tsx +85 -10
  170. package/src/components/viewer-zone-system.tsx +6 -1
  171. package/src/hooks/use-auto-save.test.ts +14 -0
  172. package/src/hooks/use-auto-save.ts +54 -7
  173. package/src/hooks/use-ceiling-events.ts +3 -2
  174. package/src/hooks/use-drag-action.ts +4 -1
  175. package/src/hooks/use-keyboard.ts +385 -40
  176. package/src/index.tsx +219 -6
  177. package/src/lib/active-placement-surface.ts +35 -0
  178. package/src/lib/ceiling-plan-snap.ts +24 -0
  179. package/src/lib/contextual-help.test.ts +112 -0
  180. package/src/lib/contextual-help.ts +144 -0
  181. package/src/lib/continuation.ts +64 -0
  182. package/src/lib/direct-manipulation.test.ts +192 -0
  183. package/src/lib/direct-manipulation.ts +109 -0
  184. package/src/lib/editor-api.ts +23 -35
  185. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  186. package/src/lib/floorplan/apply-alignment.ts +18 -12
  187. package/src/lib/floorplan/floorplan-export.tsx +364 -0
  188. package/src/lib/floorplan/geometry.ts +53 -0
  189. package/src/lib/floorplan/index.ts +3 -0
  190. package/src/lib/floorplan/items.ts +5 -2
  191. package/src/lib/floorplan/plan-coords.ts +21 -0
  192. package/src/lib/fresh-planar-placement.test.ts +240 -3
  193. package/src/lib/fresh-planar-placement.ts +68 -1
  194. package/src/lib/glb-export.test.ts +441 -0
  195. package/src/lib/glb-export.ts +874 -0
  196. package/src/lib/history.ts +9 -0
  197. package/src/lib/interaction/hot-set.test.ts +133 -0
  198. package/src/lib/interaction/hot-set.ts +67 -0
  199. package/src/lib/interaction/overlay-policy.test.ts +51 -0
  200. package/src/lib/interaction/overlay-policy.ts +59 -0
  201. package/src/lib/interaction/scope.ts +211 -0
  202. package/src/lib/level-selection.ts +2 -2
  203. package/src/lib/material-paint.ts +36 -49
  204. package/src/lib/measurement-kind.test.ts +30 -0
  205. package/src/lib/measurement-kind.ts +19 -0
  206. package/src/lib/measurement-label.test.ts +47 -0
  207. package/src/lib/measurement-label.ts +68 -0
  208. package/src/lib/measurement-parser.ts +116 -0
  209. package/src/lib/measurements.test.ts +183 -0
  210. package/src/lib/measurements.ts +199 -0
  211. package/src/lib/paint-scope.test.ts +454 -0
  212. package/src/lib/paint-scope.ts +461 -0
  213. package/src/lib/placement-drag-release.ts +23 -0
  214. package/src/lib/planar-cursor-placement.test.ts +57 -0
  215. package/src/lib/plugin-panels.test.ts +19 -0
  216. package/src/lib/plugin-panels.ts +91 -0
  217. package/src/lib/quick-measurement.test.ts +77 -0
  218. package/src/lib/quick-measurement.ts +109 -0
  219. package/src/lib/roof-duplication.ts +6 -6
  220. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  221. package/src/lib/roof-wall-hit.ts +164 -0
  222. package/src/lib/scene-clipboard.test.ts +196 -0
  223. package/src/lib/scene-clipboard.ts +73 -8
  224. package/src/lib/scene.ts +28 -5
  225. package/src/lib/selection-routing.test.ts +298 -0
  226. package/src/lib/selection-routing.ts +175 -0
  227. package/src/lib/sfx/index.ts +1 -0
  228. package/src/lib/sfx/movement-tick.test.ts +65 -0
  229. package/src/lib/sfx/movement-tick.ts +18 -0
  230. package/src/lib/sfx-bus.ts +50 -13
  231. package/src/lib/sfx-player.test.ts +124 -0
  232. package/src/lib/sfx-player.ts +111 -66
  233. package/src/lib/slab-plan-snap.test.ts +93 -0
  234. package/src/lib/slab-plan-snap.ts +108 -0
  235. package/src/lib/snapping-mode.test.ts +138 -0
  236. package/src/lib/snapping-mode.ts +179 -0
  237. package/src/lib/stair-duplication.ts +4 -4
  238. package/src/lib/surface-plan-snap.test.ts +71 -0
  239. package/src/lib/surface-plan-snap.ts +256 -0
  240. package/src/lib/use-linear-display.ts +40 -0
  241. package/src/lib/world-grid-snap.ts +37 -5
  242. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  243. package/src/store/use-editor.tsx +484 -114
  244. package/src/store/use-facing-pose.ts +44 -0
  245. package/src/store/use-fence-curve-draft.ts +21 -0
  246. package/src/store/use-floorplan-draft-preview.ts +101 -0
  247. package/src/store/use-floorplan-marquee.ts +50 -0
  248. package/src/store/use-interaction-scope.test.ts +186 -0
  249. package/src/store/use-interaction-scope.ts +132 -0
  250. package/src/store/use-measurement-draft.test.ts +530 -0
  251. package/src/store/use-measurement-draft.ts +543 -0
  252. package/src/store/use-opening-guides.ts +41 -0
  253. package/src/store/use-placement-preview.ts +11 -3
  254. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  255. package/src/store/use-quick-measurement-hud.ts +77 -0
  256. package/src/store/use-segment-draft-chain.ts +28 -0
  257. package/src/store/use-stair-build-preview.ts +43 -0
  258. package/src/store/use-wall-snap-indicator.ts +2 -0
  259. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  260. package/src/components/editor/group-move-handle.tsx +0 -316
  261. package/src/components/ui/panels/paint-panel.tsx +0 -163
  262. package/src/lib/level-name.ts +0 -11
@@ -1,4 +1,5 @@
1
1
  import {
2
+ type AlignmentAnchor,
2
3
  type AnyNode,
3
4
  type AnyNodeId,
4
5
  collectAlignmentAnchors,
@@ -7,33 +8,93 @@ import {
7
8
  type LevelNode,
8
9
  RoofNode,
9
10
  RoofSegmentNode,
11
+ resolveBuildingForLevel,
10
12
  sceneRegistry,
11
- snapScalar,
12
13
  useScene,
14
+ type WallNode,
15
+ wallSegmentAnchors,
13
16
  } from '@pascal-app/core'
14
- import { useAlignmentGuides } from '@pascal-app/editor'
17
+ import { clearSurfacePlanSnapFeedback, resolveSurfacePlanPointSnap } from '@pascal-app/editor'
15
18
  import { useViewer } from '@pascal-app/viewer'
16
19
  import { useEffect, useMemo, useRef, useState } from 'react'
17
20
  import * as THREE from 'three'
18
- import { BufferGeometry, DoubleSide, type Group, type Line, Vector3 } from 'three'
21
+ import {
22
+ BufferGeometry,
23
+ DoubleSide,
24
+ Float32BufferAttribute,
25
+ type Group,
26
+ type Line,
27
+ Vector3,
28
+ } from 'three'
19
29
  import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
20
30
  import { EDITOR_LAYER } from '../../../lib/constants'
21
31
  import { sfxEmitter } from '../../../lib/sfx-bus'
22
- import {
23
- resolveAlignmentForActiveBuilding,
24
- snapWorldXZForActiveBuilding,
25
- } from '../../../lib/world-grid-snap'
26
- import useEditor from '../../../store/use-editor'
32
+ import { snapWorldXZForActiveBuilding } from '../../../lib/world-grid-snap'
33
+ import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../../store/use-editor'
27
34
  import { CursorSphere } from '../shared/cursor-sphere'
28
35
 
29
36
  const DEFAULT_WALL_HEIGHT = 0.5
30
37
  const DEFAULT_PITCH_DEG = 40
31
38
  const GRID_OFFSET = 0.02
32
- /** Figma-style alignment-snap threshold (meters), matching the move tools. */
33
- const ALIGNMENT_THRESHOLD_M = 0.08
34
39
 
35
- function snapToActiveGrid(value: number): number {
36
- return snapScalar(value, useEditor.getState().gridSnapStep)
40
+ // Walls that are direct children of a level.
41
+ function getLevelWalls(
42
+ levelId: string | null,
43
+ nodes: Readonly<Record<string, AnyNode>>,
44
+ ): WallNode[] {
45
+ if (!levelId) return []
46
+ const levelNode = nodes[levelId]
47
+ if (levelNode?.type !== 'level') return []
48
+ return (levelNode as LevelNode).children
49
+ .map((childId) => nodes[childId])
50
+ .filter((node): node is WallNode => node?.type === 'wall')
51
+ }
52
+
53
+ // Walls on the level directly beneath the active one. Levels share the same
54
+ // local XZ origin (they only differ in world Y), so these walls live in the
55
+ // identical coordinate frame and feed straight into both the alignment pool
56
+ // and the magnetic wall-snap pipeline — letting a roof drawn on the upper
57
+ // floor snap onto the wall corners of the floor below.
58
+ function getBelowLevelWalls(
59
+ currentLevelId: string | null,
60
+ nodes: Readonly<Record<string, AnyNode>>,
61
+ ): WallNode[] {
62
+ if (!currentLevelId) return []
63
+ const currentLevel = nodes[currentLevelId]
64
+ if (currentLevel?.type !== 'level') return []
65
+ const buildingId = resolveBuildingForLevel(currentLevel.id, nodes)
66
+ if (!buildingId) return []
67
+ const building = nodes[buildingId]
68
+ if (building?.type !== 'building') return []
69
+ const currentIndex = (currentLevel as LevelNode).level
70
+ const belowLevel = (building.children ?? [])
71
+ .map((childId) => nodes[childId])
72
+ .filter((node): node is LevelNode => node?.type === 'level' && node.level < currentIndex)
73
+ .sort((a, b) => b.level - a.level)[0]
74
+ return getLevelWalls(belowLevel?.id ?? null, nodes)
75
+ }
76
+
77
+ // Current-level + floor-below walls — the magnetic snap targets the roof draft
78
+ // locks onto (corners, midpoints, crossings, wall bodies), matching the wall
79
+ // tool. Same coordinate frame, so no transform is needed.
80
+ function getRoofSnapWalls(
81
+ currentLevelId: string | null,
82
+ nodes: Readonly<Record<string, AnyNode>>,
83
+ ): WallNode[] {
84
+ return [...getLevelWalls(currentLevelId, nodes), ...getBelowLevelWalls(currentLevelId, nodes)]
85
+ }
86
+
87
+ // Current-level alignment anchors plus the floor-below wall corners.
88
+ function collectRoofAlignmentAnchors(
89
+ nodes: Readonly<Record<string, AnyNode>>,
90
+ currentLevelId: string | null,
91
+ ): AlignmentAnchor[] {
92
+ return [
93
+ ...collectAlignmentAnchors(nodes, '', currentLevelId),
94
+ ...getBelowLevelWalls(currentLevelId, nodes).flatMap((wall) =>
95
+ wallSegmentAnchors(wall.id, wall.start, wall.end, wall.thickness),
96
+ ),
97
+ ]
37
98
  }
38
99
 
39
100
  /**
@@ -148,14 +209,168 @@ type PreviewState = {
148
209
  levelY: number
149
210
  }
150
211
 
212
+ function buildRoofGhostGeometry(
213
+ width: number,
214
+ depth: number,
215
+ wallHeight: number,
216
+ pitchDeg: number,
217
+ ) {
218
+ const safeWidth = Math.max(width, 0.1)
219
+ const safeDepth = Math.max(depth, 0.1)
220
+ const halfWidth = safeWidth / 2
221
+ const halfDepth = safeDepth / 2
222
+ const ridgeHeight = wallHeight + Math.tan((pitchDeg * Math.PI) / 180) * halfDepth
223
+
224
+ const vertices = [
225
+ // Front slope
226
+ -halfWidth,
227
+ wallHeight,
228
+ -halfDepth,
229
+ halfWidth,
230
+ wallHeight,
231
+ -halfDepth,
232
+ halfWidth,
233
+ ridgeHeight,
234
+ 0,
235
+
236
+ -halfWidth,
237
+ wallHeight,
238
+ -halfDepth,
239
+ halfWidth,
240
+ ridgeHeight,
241
+ 0,
242
+ -halfWidth,
243
+ ridgeHeight,
244
+ 0,
245
+
246
+ // Back slope
247
+ -halfWidth,
248
+ ridgeHeight,
249
+ 0,
250
+ halfWidth,
251
+ ridgeHeight,
252
+ 0,
253
+ halfWidth,
254
+ wallHeight,
255
+ halfDepth,
256
+
257
+ -halfWidth,
258
+ ridgeHeight,
259
+ 0,
260
+ halfWidth,
261
+ wallHeight,
262
+ halfDepth,
263
+ -halfWidth,
264
+ wallHeight,
265
+ halfDepth,
266
+
267
+ // Left gable
268
+ -halfWidth,
269
+ wallHeight,
270
+ -halfDepth,
271
+ -halfWidth,
272
+ ridgeHeight,
273
+ 0,
274
+ -halfWidth,
275
+ wallHeight,
276
+ halfDepth,
277
+
278
+ // Right gable
279
+ halfWidth,
280
+ wallHeight,
281
+ -halfDepth,
282
+ halfWidth,
283
+ wallHeight,
284
+ halfDepth,
285
+ halfWidth,
286
+ ridgeHeight,
287
+ 0,
288
+ ]
289
+
290
+ const geometry = new BufferGeometry()
291
+ geometry.setAttribute('position', new Float32BufferAttribute(vertices, 3))
292
+ geometry.computeVertexNormals()
293
+ return geometry
294
+ }
295
+
296
+ function buildRoofGhostEdges(width: number, depth: number, wallHeight: number, pitchDeg: number) {
297
+ const safeWidth = Math.max(width, 0.1)
298
+ const safeDepth = Math.max(depth, 0.1)
299
+ const halfWidth = safeWidth / 2
300
+ const halfDepth = safeDepth / 2
301
+ const ridgeHeight = wallHeight + Math.tan((pitchDeg * Math.PI) / 180) * halfDepth
302
+
303
+ const vertices = [
304
+ // Base rectangle
305
+ -halfWidth,
306
+ wallHeight,
307
+ -halfDepth,
308
+ halfWidth,
309
+ wallHeight,
310
+ -halfDepth,
311
+ halfWidth,
312
+ wallHeight,
313
+ -halfDepth,
314
+ halfWidth,
315
+ wallHeight,
316
+ halfDepth,
317
+ halfWidth,
318
+ wallHeight,
319
+ halfDepth,
320
+ -halfWidth,
321
+ wallHeight,
322
+ halfDepth,
323
+ -halfWidth,
324
+ wallHeight,
325
+ halfDepth,
326
+ -halfWidth,
327
+ wallHeight,
328
+ -halfDepth,
329
+
330
+ // Ridge + gable edges
331
+ -halfWidth,
332
+ ridgeHeight,
333
+ 0,
334
+ halfWidth,
335
+ ridgeHeight,
336
+ 0,
337
+ -halfWidth,
338
+ wallHeight,
339
+ -halfDepth,
340
+ -halfWidth,
341
+ ridgeHeight,
342
+ 0,
343
+ -halfWidth,
344
+ ridgeHeight,
345
+ 0,
346
+ -halfWidth,
347
+ wallHeight,
348
+ halfDepth,
349
+ halfWidth,
350
+ wallHeight,
351
+ -halfDepth,
352
+ halfWidth,
353
+ ridgeHeight,
354
+ 0,
355
+ halfWidth,
356
+ ridgeHeight,
357
+ 0,
358
+ halfWidth,
359
+ wallHeight,
360
+ halfDepth,
361
+ ]
362
+
363
+ const geometry = new BufferGeometry()
364
+ geometry.setAttribute('position', new Float32BufferAttribute(vertices, 3))
365
+ return geometry
366
+ }
367
+
151
368
  export const RoofTool: React.FC = () => {
152
369
  const cursorRef = useRef<Group>(null)
153
370
  const outlineRef = useRef<Line>(null!)
154
371
  const currentLevelId = useViewer((state) => state.selection.levelId)
155
372
  const selectedIds = useViewer((state) => state.selection.selectedIds)
156
373
  const setSelection = useViewer((state) => state.setSelection)
157
- const setTool = useEditor((state) => state.setTool)
158
- const setMode = useEditor((state) => state.setMode)
159
374
 
160
375
  const selectedIdsRef = useRef(selectedIds)
161
376
  useEffect(() => {
@@ -179,43 +394,38 @@ export const RoofTool: React.FC = () => {
179
394
 
180
395
  outlineRef.current.geometry = new BufferGeometry()
181
396
 
182
- // Alignment candidates — anchors of every alignable object; refreshed
183
- // after each roof commits. Both corners of the rectangle align.
184
- let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '', currentLevelId)
185
- // Snap the drafted corner onto another object's nearest real anchor and
186
- // publish the guide. The probe is the RAW cursor, NOT the 0.5m-grid-snapped
187
- // point: resolving against the grid point would only ever catch anchors
188
- // that happen to sit on a grid line, so off-grid items (furniture, angled
189
- // walls) would never surface a guide. The matched axis locks exactly to the
190
- // candidate's coordinate; the other axis keeps its grid snap. Alt bypasses.
191
- const alignPoint = (
192
- gridX: number,
193
- gridZ: number,
194
- rawX: number,
195
- rawZ: number,
196
- bypass: boolean,
197
- ): [number, number] => {
198
- if (bypass || alignmentCandidates.length === 0) {
199
- useAlignmentGuides.getState().clear()
200
- return [gridX, gridZ]
201
- }
202
- const ar = resolveAlignmentForActiveBuilding({
203
- moving: [{ nodeId: '__roof-draft__', kind: 'corner', x: rawX, z: rawZ }],
397
+ // Alignment candidates — anchors of every alignable object on the active
398
+ // level plus the wall corners of the floor directly below, so a roof drawn
399
+ // on the upper floor aligns to the walls beneath it. Refreshed after each
400
+ // roof commits. Both corners of the rectangle align.
401
+ let alignmentCandidates = collectRoofAlignmentAnchors(useScene.getState().nodes, currentLevelId)
402
+
403
+ // Resolve a grid:move/click into the drafted corner via the shared surface
404
+ // snap pipeline: magnetic lock onto wall corners / midpoints / crossings /
405
+ // bodies on the active level + floor below (raising the green beacon),
406
+ // falling back to alignment guides, then to the world-grid snap. The same
407
+ // path the slab/ceiling tools use, so the beacon and coloring match. The
408
+ // pipeline reads the snapping mode itself (Shift bypass, magnetic on/off),
409
+ // so this tool never inspects the flags. `levelId` is intentionally omitted
410
+ // so the explicit floor-below `walls` aren't filtered back out.
411
+ const resolveDraftPoint = (event: GridEvent): [number, number] => {
412
+ const rawPoint: [number, number] = [event.localPosition[0], event.localPosition[2]]
413
+ const gridFallback: [number, number] = isGridSnapActive()
414
+ ? snapWorldXZForActiveBuilding(
415
+ event.position[0],
416
+ event.position[2],
417
+ useEditor.getState().gridSnapStep,
418
+ ).local
419
+ : rawPoint
420
+ const nodes = useScene.getState().nodes
421
+ return resolveSurfacePlanPointSnap({
422
+ rawPoint,
423
+ fallbackPoint: gridFallback,
424
+ walls: getRoofSnapWalls(currentLevelId, nodes),
204
425
  candidates: alignmentCandidates,
205
- threshold: ALIGNMENT_THRESHOLD_M,
206
- })
207
- if (ar.guides.length === 0) {
208
- useAlignmentGuides.getState().clear()
209
- return [gridX, gridZ]
210
- }
211
- useAlignmentGuides.getState().set(ar.guides)
212
- let x = gridX
213
- let z = gridZ
214
- for (const guide of ar.guides) {
215
- if (guide.axis === 'x') x = guide.coord
216
- else z = guide.coord
217
- }
218
- return [x, z]
426
+ movingId: '__roof-draft__',
427
+ highlightWalls: true,
428
+ }).point
219
429
  }
220
430
 
221
431
  const updateOutline = (
@@ -240,20 +450,7 @@ export const RoofTool: React.FC = () => {
240
450
  const onGridMove = (event: GridEvent) => {
241
451
  if (!cursorRef.current) return
242
452
 
243
- // World-grid snap projected into building-local; rotated buildings
244
- // used to drag every roof corner off the visible grid.
245
- const snapped = snapWorldXZForActiveBuilding(
246
- event.position[0],
247
- event.position[2],
248
- useEditor.getState().gridSnapStep,
249
- ).local
250
- const [gridX, gridZ] = alignPoint(
251
- snapped[0],
252
- snapped[1],
253
- event.localPosition[0],
254
- event.localPosition[2],
255
- event.nativeEvent?.altKey === true,
256
- )
453
+ const [gridX, gridZ] = resolveDraftPoint(event)
257
454
  const y = event.localPosition[1]
258
455
 
259
456
  const cursorPosition: [number, number, number] = [gridX, y, gridZ]
@@ -262,6 +459,7 @@ export const RoofTool: React.FC = () => {
262
459
  cursorRef.current.position.set(gridX, gridY, gridZ)
263
460
 
264
461
  if (
462
+ (isGridSnapActive() || isMagneticSnapActive()) &&
265
463
  corner1Ref.current &&
266
464
  previousGridPosRef.current &&
267
465
  (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
@@ -285,20 +483,7 @@ export const RoofTool: React.FC = () => {
285
483
  const onGridClick = (event: GridEvent) => {
286
484
  if (!currentLevelId) return
287
485
 
288
- // World-grid snap projected into building-local; rotated buildings
289
- // used to drag every roof corner off the visible grid.
290
- const snapped = snapWorldXZForActiveBuilding(
291
- event.position[0],
292
- event.position[2],
293
- useEditor.getState().gridSnapStep,
294
- ).local
295
- const [gridX, gridZ] = alignPoint(
296
- snapped[0],
297
- snapped[1],
298
- event.localPosition[0],
299
- event.localPosition[2],
300
- event.nativeEvent?.altKey === true,
301
- )
486
+ const [gridX, gridZ] = resolveDraftPoint(event)
302
487
  const y = event.localPosition[1]
303
488
 
304
489
  if (corner1Ref.current) {
@@ -313,8 +498,8 @@ export const RoofTool: React.FC = () => {
313
498
 
314
499
  corner1Ref.current = null
315
500
  outlineRef.current.visible = false
316
- alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '', currentLevelId)
317
- useAlignmentGuides.getState().clear()
501
+ alignmentCandidates = collectRoofAlignmentAnchors(useScene.getState().nodes, currentLevelId)
502
+ clearSurfacePlanSnapFeedback()
318
503
  } else {
319
504
  corner1Ref.current = [gridX, y, gridZ]
320
505
  sfxEmitter.emit('sfx:structure-build-start')
@@ -332,7 +517,7 @@ export const RoofTool: React.FC = () => {
332
517
  outlineRef.current.visible = false
333
518
  setPreview((prev) => ({ ...prev, corner1: null }))
334
519
  }
335
- useAlignmentGuides.getState().clear()
520
+ clearSurfacePlanSnapFeedback()
336
521
  }
337
522
 
338
523
  emitter.on('grid:move', onGridMove)
@@ -343,7 +528,7 @@ export const RoofTool: React.FC = () => {
343
528
  emitter.off('grid:move', onGridMove)
344
529
  emitter.off('grid:click', onGridClick)
345
530
  emitter.off('tool:cancel', onCancel)
346
- useAlignmentGuides.getState().clear()
531
+ clearSurfacePlanSnapFeedback()
347
532
 
348
533
  corner1Ref.current = null
349
534
  }
@@ -360,6 +545,34 @@ export const RoofTool: React.FC = () => {
360
545
  return { length, width, centerX, centerZ }
361
546
  }, [corner1, cursorPosition])
362
547
 
548
+ const roofGhostGeometry = useMemo(() => {
549
+ if (!previewDimensions) return null
550
+ return buildRoofGhostGeometry(
551
+ previewDimensions.length,
552
+ previewDimensions.width,
553
+ DEFAULT_WALL_HEIGHT,
554
+ DEFAULT_PITCH_DEG,
555
+ )
556
+ }, [previewDimensions])
557
+
558
+ const roofGhostEdges = useMemo(() => {
559
+ if (!previewDimensions) return null
560
+ return buildRoofGhostEdges(
561
+ previewDimensions.length,
562
+ previewDimensions.width,
563
+ DEFAULT_WALL_HEIGHT,
564
+ DEFAULT_PITCH_DEG,
565
+ )
566
+ }, [previewDimensions])
567
+
568
+ useEffect(
569
+ () => () => {
570
+ roofGhostGeometry?.dispose()
571
+ roofGhostEdges?.dispose()
572
+ },
573
+ [roofGhostEdges, roofGhostGeometry],
574
+ )
575
+
363
576
  return (
364
577
  <group>
365
578
  <CursorSphere ref={cursorRef} />
@@ -393,21 +606,34 @@ export const RoofTool: React.FC = () => {
393
606
  )}
394
607
 
395
608
  {previewDimensions && previewDimensions.length > 0.1 && previewDimensions.width > 0.1 && (
396
- <mesh
609
+ <group
397
610
  layers={EDITOR_LAYER}
398
611
  position={[previewDimensions.centerX, levelY + GRID_OFFSET, previewDimensions.centerZ]}
399
- rotation={[-Math.PI / 2, 0, 0]}
400
612
  >
401
- <planeGeometry args={[previewDimensions.length, previewDimensions.width]} />
402
- <meshBasicMaterial
403
- color="#818cf8"
404
- depthTest={false}
405
- depthWrite={false}
406
- opacity={0.1}
407
- side={DoubleSide}
408
- transparent
409
- />
410
- </mesh>
613
+ {roofGhostGeometry && (
614
+ <mesh geometry={roofGhostGeometry} layers={EDITOR_LAYER} renderOrder={1}>
615
+ <meshBasicMaterial
616
+ color="#818cf8"
617
+ depthTest={false}
618
+ depthWrite={false}
619
+ opacity={0.16}
620
+ side={DoubleSide}
621
+ transparent
622
+ />
623
+ </mesh>
624
+ )}
625
+ {roofGhostEdges && (
626
+ <lineSegments geometry={roofGhostEdges} layers={EDITOR_LAYER} renderOrder={2}>
627
+ <lineBasicMaterial
628
+ color="#818cf8"
629
+ depthTest={false}
630
+ depthWrite={false}
631
+ opacity={0.5}
632
+ transparent
633
+ />
634
+ </lineSegments>
635
+ )}
636
+ </group>
411
637
  )}
412
638
  </group>
413
639
  )
@@ -9,6 +9,10 @@ type PointerEventLike = {
9
9
  nativeEvent?: PointerEvent | PointerEventLike
10
10
  }
11
11
 
12
+ type SuppressBoxSelectOptions = {
13
+ markHandled?: boolean
14
+ }
15
+
12
16
  function pointerIdFor(event: PointerEvent | PointerEventLike): number | null {
13
17
  if ('pointerId' in event && typeof event.pointerId === 'number') {
14
18
  return event.pointerId
@@ -28,8 +32,12 @@ export function markBoxSelectHandled() {
28
32
  }, 50)
29
33
  }
30
34
 
31
- export function suppressBoxSelectForPointer(event: PointerEvent | PointerEventLike) {
32
- markBoxSelectHandled()
35
+ export function suppressBoxSelectForPointer(
36
+ event: PointerEvent | PointerEventLike,
37
+ options: SuppressBoxSelectOptions = {},
38
+ ) {
39
+ const markHandled = options.markHandled ?? true
40
+ if (markHandled) markBoxSelectHandled()
33
41
 
34
42
  const pointerId = pointerIdFor(event)
35
43
  if (pointerId === null || suppressedPointerIds.has(pointerId)) return
@@ -38,7 +46,7 @@ export function suppressBoxSelectForPointer(event: PointerEvent | PointerEventLi
38
46
 
39
47
  const clear = (releaseEvent?: PointerEvent) => {
40
48
  if (releaseEvent && releaseEvent.pointerId !== pointerId) return
41
- markBoxSelectHandled()
49
+ if (markHandled) markBoxSelectHandled()
42
50
  suppressedPointerIds.delete(pointerId)
43
51
  const cleanup = suppressionCleanups.get(pointerId)
44
52
  suppressionCleanups.delete(pointerId)
@@ -48,15 +56,18 @@ export function suppressBoxSelectForPointer(event: PointerEvent | PointerEventLi
48
56
  const onPointerUp = (releaseEvent: PointerEvent) => clear(releaseEvent)
49
57
  const onPointerCancel = (releaseEvent: PointerEvent) => clear(releaseEvent)
50
58
  const onBlur = () => clear()
59
+ // Click-preserving handle interactions need suppression cleared before
60
+ // canvas-level pointerup handlers decide whether to block the follow-up click.
61
+ const releaseListenerOptions = markHandled ? undefined : { capture: true }
51
62
  const cleanup = () => {
52
- window.removeEventListener('pointerup', onPointerUp)
53
- window.removeEventListener('pointercancel', onPointerCancel)
63
+ window.removeEventListener('pointerup', onPointerUp, releaseListenerOptions)
64
+ window.removeEventListener('pointercancel', onPointerCancel, releaseListenerOptions)
54
65
  window.removeEventListener('blur', onBlur)
55
66
  }
56
67
 
57
68
  suppressionCleanups.set(pointerId, cleanup)
58
- window.addEventListener('pointerup', onPointerUp)
59
- window.addEventListener('pointercancel', onPointerCancel)
69
+ window.addEventListener('pointerup', onPointerUp, releaseListenerOptions)
70
+ window.addEventListener('pointercancel', onPointerCancel, releaseListenerOptions)
60
71
  window.addEventListener('blur', onBlur)
61
72
  }
62
73