@pascal-app/editor 0.8.0 → 0.9.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 (223) hide show
  1. package/package.json +8 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
  3. package/src/components/editor/custom-camera-controls.tsx +754 -25
  4. package/src/components/editor/editor-layout-mobile.tsx +3 -3
  5. package/src/components/editor/editor-layout-v2.tsx +58 -51
  6. package/src/components/editor/export-manager.tsx +32 -3
  7. package/src/components/editor/first-person/build-collider-world.test.ts +149 -0
  8. package/src/components/editor/first-person/build-collider-world.ts +201 -83
  9. package/src/components/editor/first-person/bvh-ecctrl.tsx +8 -8
  10. package/src/components/editor/first-person-controls.tsx +825 -20
  11. package/src/components/editor/floating-action-menu.tsx +287 -196
  12. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  13. package/src/components/editor/floorplan-panel.tsx +5829 -12005
  14. package/src/components/editor/grid.tsx +51 -17
  15. package/src/components/editor/group-move-handle.tsx +316 -0
  16. package/src/components/editor/group-rotate-handle.tsx +382 -0
  17. package/src/components/editor/group-transform-shared.test.ts +205 -0
  18. package/src/components/editor/group-transform-shared.ts +256 -0
  19. package/src/components/editor/handles/handle-arrow.tsx +523 -0
  20. package/src/components/editor/handles/use-handle-drag.ts +213 -0
  21. package/src/components/editor/index.tsx +178 -149
  22. package/src/components/editor/measurement-pill.tsx +75 -0
  23. package/src/components/editor/node-arrow-handles.tsx +1453 -0
  24. package/src/components/editor/selection-manager.tsx +433 -156
  25. package/src/components/editor/site-edge-labels.tsx +44 -20
  26. package/src/components/editor/slab-hole-highlights.tsx +466 -0
  27. package/src/components/editor/snapshot-capture-overlay.tsx +136 -62
  28. package/src/components/editor/thumbnail-generator.tsx +9 -3
  29. package/src/components/editor/use-floorplan-background-placement.ts +119 -32
  30. package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
  31. package/src/components/editor/use-floorplan-scene-data.ts +14 -2
  32. package/src/components/editor/wall-measurement-label.tsx +11 -6
  33. package/src/components/editor/wall-move-side-handles.tsx +855 -0
  34. package/src/components/editor/wall-opening-highlights.tsx +181 -0
  35. package/src/components/editor/wall-snap-beacon-layer.tsx +150 -0
  36. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +6 -1
  37. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
  38. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
  39. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +227 -0
  40. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +616 -0
  41. package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
  42. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +75 -0
  43. package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
  44. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
  45. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -66
  46. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
  47. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
  48. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +24 -9
  49. package/src/components/editor-2d/svg-paths.ts +10 -2
  50. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +26 -3
  51. package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
  52. package/src/components/systems/roof/roof-edit-system.tsx +86 -12
  53. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  54. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  55. package/src/components/tools/elevator/elevator-tool.tsx +349 -0
  56. package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
  57. package/src/components/tools/fence/fence-drafting.ts +64 -22
  58. package/src/components/tools/item/move-tool.tsx +41 -105
  59. package/src/components/tools/item/placement-math.test.ts +10 -0
  60. package/src/components/tools/item/placement-math.ts +31 -5
  61. package/src/components/tools/item/placement-strategies.ts +190 -15
  62. package/src/components/tools/item/placement-types.ts +10 -1
  63. package/src/components/tools/item/use-draft-node.ts +20 -1
  64. package/src/components/tools/item/use-placement-coordinator.tsx +694 -191
  65. package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
  66. package/src/components/tools/roof/roof-tool.tsx +108 -12
  67. package/src/components/tools/select/box-select-state.ts +77 -0
  68. package/src/components/tools/select/box-select-tool.tsx +324 -538
  69. package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
  70. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  71. package/src/components/tools/select/select-candidates.test.ts +109 -0
  72. package/src/components/tools/select/select-candidates.ts +85 -0
  73. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  74. package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
  75. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  76. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  77. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  78. package/src/components/tools/shared/placement-box.tsx +123 -0
  79. package/src/components/tools/shared/polygon-editor.tsx +617 -167
  80. package/src/components/tools/shared/segment-angle.ts +56 -0
  81. package/src/components/tools/site/site-boundary-editor.tsx +464 -12
  82. package/src/components/tools/site/site-flag-model.ts +18 -0
  83. package/src/components/tools/stair/stair-defaults.ts +1 -1
  84. package/src/components/tools/stair/stair-tool.tsx +269 -35
  85. package/src/components/tools/tool-manager.tsx +172 -56
  86. package/src/components/tools/wall/wall-drafting.ts +98 -98
  87. package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
  88. package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
  89. package/src/components/tools/zone/zone-tool.tsx +16 -6
  90. package/src/components/ui/action-menu/action-button.tsx +21 -1
  91. package/src/components/ui/action-menu/control-modes.tsx +5 -82
  92. package/src/components/ui/action-menu/index.tsx +1 -113
  93. package/src/components/ui/action-menu/structure-tools.tsx +13 -88
  94. package/src/components/ui/action-menu/view-toggles.tsx +243 -6
  95. package/src/components/ui/command-palette/editor-commands.tsx +18 -13
  96. package/src/components/ui/command-palette/index.tsx +2 -1
  97. package/src/components/ui/controls/material-paint-panel.tsx +83 -0
  98. package/src/components/ui/controls/material-picker.tsx +11 -32
  99. package/src/components/ui/controls/metric-control.tsx +33 -11
  100. package/src/components/ui/floating-level-selector.tsx +47 -10
  101. package/src/components/ui/helpers/helper-manager.tsx +16 -17
  102. package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
  103. package/src/components/ui/item-catalog/catalog-items.tsx +1 -0
  104. package/src/components/ui/item-catalog/item-catalog.tsx +3 -0
  105. package/src/components/ui/level-duplicate-dialog.tsx +2 -1
  106. package/src/components/ui/panels/node-display.ts +1 -0
  107. package/src/components/ui/panels/panel-manager.tsx +35 -47
  108. package/src/components/ui/panels/panel-wrapper.tsx +217 -12
  109. package/src/components/ui/panels/parametric-inspector.tsx +469 -0
  110. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  111. package/src/components/ui/scene-loader.tsx +1 -3
  112. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  113. package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
  114. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
  115. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
  116. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
  117. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  118. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
  119. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
  120. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
  121. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  122. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
  123. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
  124. package/src/components/ui/sidebar/panels/site-panel/index.tsx +4 -6
  125. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  126. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
  127. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
  128. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
  129. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
  130. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
  131. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +7 -1
  132. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -36
  133. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +51 -2
  134. package/src/components/ui/sidebar/tab-bar.tsx +61 -1
  135. package/src/components/viewer-overlay.tsx +169 -52
  136. package/src/components/viewer-zone-system.tsx +1 -1
  137. package/src/hooks/use-auto-save.ts +10 -3
  138. package/src/hooks/use-ceiling-events.ts +175 -0
  139. package/src/hooks/use-drag-action.ts +124 -0
  140. package/src/hooks/use-keyboard.ts +115 -62
  141. package/src/hooks/use-selection.ts +64 -0
  142. package/src/index.tsx +237 -5
  143. package/src/lib/constants.ts +6 -2
  144. package/src/lib/editor-api.ts +62 -0
  145. package/src/lib/elevator-support.ts +96 -0
  146. package/src/lib/floorplan/apply-alignment.ts +111 -0
  147. package/src/lib/floorplan/index.ts +7 -0
  148. package/src/lib/floorplan/selection-tool.ts +40 -0
  149. package/src/lib/floorplan/stairs.ts +12 -1
  150. package/src/lib/fresh-planar-placement.test.ts +102 -0
  151. package/src/lib/fresh-planar-placement.ts +63 -0
  152. package/src/lib/history.ts +2 -1
  153. package/src/lib/level-duplication.ts +54 -42
  154. package/src/lib/level-name.ts +11 -0
  155. package/src/lib/material-paint.ts +211 -19
  156. package/src/lib/placement-metadata.ts +29 -0
  157. package/src/lib/planar-cursor-placement.test.ts +43 -0
  158. package/src/lib/planar-cursor-placement.ts +42 -0
  159. package/src/lib/roof-duplication.ts +1 -1
  160. package/src/lib/scene-clipboard.ts +267 -0
  161. package/src/lib/scene.ts +13 -4
  162. package/src/lib/sfx-bus.ts +23 -1
  163. package/src/lib/sfx-player.ts +70 -22
  164. package/src/lib/site-boundary.ts +1 -0
  165. package/src/lib/stair-levels.test.ts +146 -0
  166. package/src/lib/stair-levels.ts +177 -0
  167. package/src/lib/world-grid-snap.ts +199 -0
  168. package/src/lib/zone-content.ts +130 -0
  169. package/src/store/use-alignment-guides.ts +21 -0
  170. package/src/store/use-editor.tsx +320 -37
  171. package/src/store/use-placement-preview.ts +31 -0
  172. package/src/store/use-wall-move-ghosts.ts +36 -0
  173. package/src/store/use-wall-snap-indicator.ts +32 -0
  174. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  175. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  176. package/src/components/tools/building/move-building-tool.tsx +0 -157
  177. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  178. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  179. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  180. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -264
  181. package/src/components/tools/column/column-tool.tsx +0 -97
  182. package/src/components/tools/column/move-column-tool.tsx +0 -105
  183. package/src/components/tools/door/door-math.ts +0 -110
  184. package/src/components/tools/door/door-tool.tsx +0 -324
  185. package/src/components/tools/door/move-door-tool.tsx +0 -412
  186. package/src/components/tools/fence/curve-fence-tool.tsx +0 -178
  187. package/src/components/tools/fence/fence-tool.tsx +0 -346
  188. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
  189. package/src/components/tools/fence/move-fence-tool.tsx +0 -302
  190. package/src/components/tools/item/item-tool.tsx +0 -26
  191. package/src/components/tools/roof/move-roof-tool.tsx +0 -364
  192. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  193. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  194. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  195. package/src/components/tools/slab/slab-tool.tsx +0 -322
  196. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  197. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  198. package/src/components/tools/wall/curve-wall-tool.tsx +0 -178
  199. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
  200. package/src/components/tools/wall/move-wall-tool.tsx +0 -358
  201. package/src/components/tools/wall/wall-tool.tsx +0 -332
  202. package/src/components/tools/window/move-window-tool.tsx +0 -447
  203. package/src/components/tools/window/window-math.ts +0 -117
  204. package/src/components/tools/window/window-tool.tsx +0 -332
  205. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  206. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  207. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  208. package/src/components/ui/panels/ceiling-panel.tsx +0 -249
  209. package/src/components/ui/panels/column-panel.tsx +0 -759
  210. package/src/components/ui/panels/door-panel.tsx +0 -1299
  211. package/src/components/ui/panels/fence-panel.tsx +0 -222
  212. package/src/components/ui/panels/item-panel.tsx +0 -306
  213. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  214. package/src/components/ui/panels/roof-panel.tsx +0 -280
  215. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  216. package/src/components/ui/panels/slab-panel.tsx +0 -250
  217. package/src/components/ui/panels/spawn-panel.tsx +0 -161
  218. package/src/components/ui/panels/stair-panel.tsx +0 -577
  219. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  220. package/src/components/ui/panels/wall-panel.tsx +0 -273
  221. package/src/components/ui/panels/window-panel.tsx +0 -968
  222. package/src/contexts/presets-context.tsx +0 -121
  223. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -0,0 +1,270 @@
1
+ // Pure geometry for "magnetic" wall-draft snapping — no store / viewer / React
2
+ // deps, so it's unit-testable in isolation. Coordinates are XZ plan points
3
+ // (building-local meters). `wall-drafting.ts` layers grid/angle snapping and
4
+ // scene access on top of these primitives.
5
+
6
+ import {
7
+ getWallCurveFrameAt,
8
+ getWallCurveLength,
9
+ isCurvedWall,
10
+ type WallNode,
11
+ } from '@pascal-app/core'
12
+
13
+ export type WallPlanPoint = [number, number]
14
+
15
+ /** Which kind of existing-geometry snap produced a drafted point. */
16
+ export type WallDraftSnapKind = 'endpoint' | 'midpoint' | 'intersection' | 'wall'
17
+
18
+ export type WallDraftSnapResult = {
19
+ point: WallPlanPoint
20
+ /**
21
+ * Set when `point` locked onto existing wall geometry (a corner, midpoint,
22
+ * crossing, or wall body) rather than a plain grid/angle position. This is
23
+ * the "magnetic" snap the beacon visualises; `null` for grid/angle-only.
24
+ */
25
+ snap: WallDraftSnapKind | null
26
+ }
27
+
28
+ export const WALL_JOIN_SNAP_RADIUS = 0.35
29
+ // Generous radius for snapping to an *existing* wall's endpoint while
30
+ // drafting. Larger than `WALL_JOIN_SNAP_RADIUS` because endpoint snap
31
+ // is the strongest user intent (closing a polygon, attaching to a
32
+ // corner) and the cursor never lands pixel-perfect on a corner.
33
+ export const WALL_ENDPOINT_SNAP_RADIUS = 0.7
34
+ // Discrete "special point" snaps taken from the raw cursor (like the
35
+ // endpoint snap) but slightly tighter — a corner is the strongest intent,
36
+ // a midpoint / crossing is the next tier down.
37
+ export const WALL_MIDPOINT_SNAP_RADIUS = 0.5
38
+ export const WALL_INTERSECTION_SNAP_RADIUS = 0.5
39
+
40
+ export function distanceSquared(a: WallPlanPoint, b: WallPlanPoint): number {
41
+ const dx = a[0] - b[0]
42
+ const dz = a[1] - b[1]
43
+ return dx * dx + dz * dz
44
+ }
45
+
46
+ export function projectPointOntoWall(point: WallPlanPoint, wall: WallNode): WallPlanPoint | null {
47
+ const [x1, z1] = wall.start
48
+ const [x2, z2] = wall.end
49
+ const dx = x2 - x1
50
+ const dz = z2 - z1
51
+ const lengthSquared = dx * dx + dz * dz
52
+ if (lengthSquared < 1e-9) {
53
+ return null
54
+ }
55
+
56
+ const t = ((point[0] - x1) * dx + (point[1] - z1) * dz) / lengthSquared
57
+ if (t <= 0 || t >= 1) {
58
+ return null
59
+ }
60
+
61
+ return [x1 + dx * t, z1 + dz * t]
62
+ }
63
+
64
+ export function findWallSnapTarget(
65
+ point: WallPlanPoint,
66
+ walls: WallNode[],
67
+ options?: { ignoreWallIds?: string[]; radius?: number },
68
+ ): WallPlanPoint | null {
69
+ const ignoreWallIds = new Set(options?.ignoreWallIds ?? [])
70
+ const radiusSquared = (options?.radius ?? WALL_JOIN_SNAP_RADIUS) ** 2
71
+ let bestTarget: WallPlanPoint | null = null
72
+ let bestDistanceSquared = Number.POSITIVE_INFINITY
73
+
74
+ for (const wall of walls) {
75
+ if (ignoreWallIds.has(wall.id)) {
76
+ continue
77
+ }
78
+
79
+ const candidates: Array<WallPlanPoint | null> = [wall.start, wall.end]
80
+
81
+ if (isCurvedWall(wall)) {
82
+ const sampleCount = Math.max(8, Math.ceil(getWallCurveLength(wall) / 0.3))
83
+ for (let index = 0; index <= sampleCount; index += 1) {
84
+ const frame = getWallCurveFrameAt(wall, index / sampleCount)
85
+ candidates.push([frame.point.x, frame.point.y])
86
+ }
87
+ } else {
88
+ candidates.push(projectPointOntoWall(point, wall))
89
+ }
90
+ for (const candidate of candidates) {
91
+ if (!candidate) {
92
+ continue
93
+ }
94
+
95
+ const candidateDistanceSquared = distanceSquared(point, candidate)
96
+ if (
97
+ candidateDistanceSquared > radiusSquared ||
98
+ candidateDistanceSquared >= bestDistanceSquared
99
+ ) {
100
+ continue
101
+ }
102
+
103
+ bestTarget = candidate
104
+ bestDistanceSquared = candidateDistanceSquared
105
+ }
106
+ }
107
+
108
+ return bestTarget
109
+ }
110
+
111
+ /**
112
+ * Endpoint-only snap from the *raw* cursor (no grid pre-snap), with a
113
+ * generous radius. Use this before `findWallSnapTarget` so the strong
114
+ * "attach to an existing wall corner" intent isn't accidentally pushed
115
+ * out of range by an interim grid snap that moved the cursor away from
116
+ * the endpoint.
117
+ */
118
+ export function findWallEndpointFromRaw(
119
+ point: WallPlanPoint,
120
+ walls: WallNode[],
121
+ ignoreWallIds?: string[],
122
+ ): WallPlanPoint | null {
123
+ const ignored = new Set(ignoreWallIds ?? [])
124
+ const radiusSquared = WALL_ENDPOINT_SNAP_RADIUS ** 2
125
+ let best: WallPlanPoint | null = null
126
+ let bestDistSq = Number.POSITIVE_INFINITY
127
+
128
+ for (const wall of walls) {
129
+ if (ignored.has(wall.id)) continue
130
+ for (const corner of [wall.start, wall.end] as WallPlanPoint[]) {
131
+ const d = distanceSquared(point, corner)
132
+ if (d <= radiusSquared && d < bestDistSq) {
133
+ best = corner
134
+ bestDistSq = d
135
+ }
136
+ }
137
+ }
138
+ return best
139
+ }
140
+
141
+ /** Midpoint of a wall — curve midpoint for curved walls, segment midpoint otherwise. */
142
+ function wallMidpoint(wall: WallNode): WallPlanPoint {
143
+ if (isCurvedWall(wall)) {
144
+ const frame = getWallCurveFrameAt(wall, 0.5)
145
+ return [frame.point.x, frame.point.y]
146
+ }
147
+ return [(wall.start[0] + wall.end[0]) / 2, (wall.start[1] + wall.end[1]) / 2]
148
+ }
149
+
150
+ /** Nearest wall midpoint to the raw cursor, within `WALL_MIDPOINT_SNAP_RADIUS`. */
151
+ export function findWallMidpointFromRaw(
152
+ point: WallPlanPoint,
153
+ walls: WallNode[],
154
+ ignoreWallIds?: string[],
155
+ ): WallPlanPoint | null {
156
+ const ignored = new Set(ignoreWallIds ?? [])
157
+ const radiusSquared = WALL_MIDPOINT_SNAP_RADIUS ** 2
158
+ let best: WallPlanPoint | null = null
159
+ let bestDistSq = Number.POSITIVE_INFINITY
160
+
161
+ for (const wall of walls) {
162
+ if (ignored.has(wall.id)) continue
163
+ const mid = wallMidpoint(wall)
164
+ const d = distanceSquared(point, mid)
165
+ if (d <= radiusSquared && d < bestDistSq) {
166
+ best = mid
167
+ bestDistSq = d
168
+ }
169
+ }
170
+ return best
171
+ }
172
+
173
+ /** Crossing point of two straight segments, or null if they don't intersect within both. */
174
+ function segmentIntersection(
175
+ a1: WallPlanPoint,
176
+ a2: WallPlanPoint,
177
+ b1: WallPlanPoint,
178
+ b2: WallPlanPoint,
179
+ ): WallPlanPoint | null {
180
+ const rx = a2[0] - a1[0]
181
+ const rz = a2[1] - a1[1]
182
+ const sx = b2[0] - b1[0]
183
+ const sz = b2[1] - b1[1]
184
+ const denom = rx * sz - rz * sx
185
+ if (Math.abs(denom) < 1e-9) return null // parallel / collinear
186
+
187
+ const qpx = b1[0] - a1[0]
188
+ const qpz = b1[1] - a1[1]
189
+ const t = (qpx * sz - qpz * sx) / denom
190
+ const u = (qpx * rz - qpz * rx) / denom
191
+ if (t < 0 || t > 1 || u < 0 || u > 1) return null
192
+
193
+ return [a1[0] + t * rx, a1[1] + t * rz]
194
+ }
195
+
196
+ /**
197
+ * Nearest point where two existing straight walls cross, within
198
+ * `WALL_INTERSECTION_SNAP_RADIUS`. Curved walls are skipped. O(n²) over the
199
+ * level's walls — fine at editor scale.
200
+ */
201
+ export function findWallIntersectionFromRaw(
202
+ point: WallPlanPoint,
203
+ walls: WallNode[],
204
+ ignoreWallIds?: string[],
205
+ ): WallPlanPoint | null {
206
+ const ignored = new Set(ignoreWallIds ?? [])
207
+ const straight = walls.filter((wall) => !ignored.has(wall.id) && !isCurvedWall(wall))
208
+ const radiusSquared = WALL_INTERSECTION_SNAP_RADIUS ** 2
209
+ let best: WallPlanPoint | null = null
210
+ let bestDistSq = Number.POSITIVE_INFINITY
211
+
212
+ for (let i = 0; i < straight.length; i += 1) {
213
+ for (let j = i + 1; j < straight.length; j += 1) {
214
+ const crossing = segmentIntersection(
215
+ straight[i]!.start,
216
+ straight[i]!.end,
217
+ straight[j]!.start,
218
+ straight[j]!.end,
219
+ )
220
+ if (!crossing) continue
221
+ const d = distanceSquared(point, crossing)
222
+ if (d <= radiusSquared && d < bestDistSq) {
223
+ best = crossing
224
+ bestDistSq = d
225
+ }
226
+ }
227
+ }
228
+ return best
229
+ }
230
+
231
+ /** Pick the candidate nearest to `point`, ignoring nulls. */
232
+ function nearestCandidate(
233
+ point: WallPlanPoint,
234
+ candidates: Array<WallDraftSnapResult | null | false>,
235
+ ): WallDraftSnapResult | null {
236
+ let best: WallDraftSnapResult | null = null
237
+ let bestDistSq = Number.POSITIVE_INFINITY
238
+ for (const candidate of candidates) {
239
+ if (!candidate) continue
240
+ const d = distanceSquared(point, candidate.point)
241
+ if (d < bestDistSq) {
242
+ best = candidate
243
+ bestDistSq = d
244
+ }
245
+ }
246
+ return best
247
+ }
248
+
249
+ /**
250
+ * Discrete "special point" snap from the raw cursor, in priority order:
251
+ * 1. corners (endpoints) — strongest intent, largest radius
252
+ * 2. midpoints / crossings — next tier; the nearer of the two wins
253
+ * A corner within range always wins over a midpoint/crossing. Returns null
254
+ * when no special point is in range (caller falls back to grid/edge snap).
255
+ */
256
+ export function findWallSpecialPointSnap(
257
+ point: WallPlanPoint,
258
+ walls: WallNode[],
259
+ ignoreWallIds?: string[],
260
+ ): WallDraftSnapResult | null {
261
+ const endpoint = findWallEndpointFromRaw(point, walls, ignoreWallIds)
262
+ if (endpoint) return { point: endpoint, snap: 'endpoint' }
263
+
264
+ const midpoint = findWallMidpointFromRaw(point, walls, ignoreWallIds)
265
+ const intersection = findWallIntersectionFromRaw(point, walls, ignoreWallIds)
266
+ return nearestCandidate(point, [
267
+ midpoint && { point: midpoint, snap: 'midpoint' },
268
+ intersection && { point: intersection, snap: 'intersection' },
269
+ ])
270
+ }
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
4
4
  import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three'
5
5
  import { EDITOR_LAYER } from './../../../lib/constants'
6
6
  import { sfxEmitter } from './../../../lib/sfx-bus'
7
+ import { snapWorldXZForActiveBuilding } from './../../../lib/world-grid-snap'
7
8
  import useEditor from './../../../store/use-editor'
8
9
  import { CursorSphere } from '../shared/cursor-sphere'
9
10
 
@@ -178,9 +179,13 @@ export const ZoneTool: React.FC = () => {
178
179
  const onGridMove = (event: GridEvent) => {
179
180
  if (!cursorRef.current) return
180
181
 
181
- // Snap to 0.5 grid
182
- const gridX = Math.round(event.localPosition[0] * 2) / 2
183
- const gridZ = Math.round(event.localPosition[2] * 2) / 2
182
+ // World-grid snap projected into building-local; rotated buildings
183
+ // used to pull the snap off the visible grid lines.
184
+ const [gridX, gridZ] = snapWorldXZForActiveBuilding(
185
+ event.position[0],
186
+ event.position[2],
187
+ 0.5,
188
+ ).local
184
189
  cursorPosition = [gridX, gridZ]
185
190
  levelYRef.current = event.localPosition[1]
186
191
 
@@ -209,8 +214,11 @@ export const ZoneTool: React.FC = () => {
209
214
  const onGridClick = (event: GridEvent) => {
210
215
  if (!currentLevelId) return
211
216
 
212
- const gridX = Math.round(event.localPosition[0] * 2) / 2
213
- const gridZ = Math.round(event.localPosition[2] * 2) / 2
217
+ const [gridX, gridZ] = snapWorldXZForActiveBuilding(
218
+ event.position[0],
219
+ event.position[2],
220
+ 0.5,
221
+ ).local
214
222
  let clickPoint: [number, number] = [gridX, gridZ]
215
223
 
216
224
  // Snap to axis from last point
@@ -236,7 +244,9 @@ export const ZoneTool: React.FC = () => {
236
244
  mainLineRef.current.visible = false
237
245
  closingLineRef.current.visible = false
238
246
  } else {
239
- // Add point to polygon
247
+ // Add point to polygon. Every non-closing vertex is a "start" tick;
248
+ // closing the polygon above fires the structure-build (end) cue.
249
+ sfxEmitter.emit('sfx:structure-build-start')
240
250
  pointsRef.current = [...pointsRef.current, clickPoint]
241
251
  updatePreview()
242
252
  }
@@ -5,6 +5,7 @@ import {
5
5
  TooltipContent,
6
6
  TooltipTrigger,
7
7
  } from './../../../components/ui/primitives/tooltip'
8
+ import { triggerSFX } from './../../../lib/sfx-bus'
8
9
  import { cn } from './../../../lib/utils'
9
10
 
10
11
  interface ActionButtonProps extends React.ComponentProps<typeof Button> {
@@ -17,7 +18,18 @@ interface ActionButtonProps extends React.ComponentProps<typeof Button> {
17
18
 
18
19
  export const ActionButton = React.forwardRef<HTMLButtonElement, ActionButtonProps>(
19
20
  (
20
- { className, children, label, shortcut, isActive, tooltipContent, tooltipSide, ...props },
21
+ {
22
+ className,
23
+ children,
24
+ label,
25
+ shortcut,
26
+ isActive,
27
+ tooltipContent,
28
+ tooltipSide,
29
+ onClick,
30
+ onMouseEnter,
31
+ ...props
32
+ },
21
33
  ref,
22
34
  ) => {
23
35
  return (
@@ -25,6 +37,14 @@ export const ActionButton = React.forwardRef<HTMLButtonElement, ActionButtonProp
25
37
  <TooltipTrigger asChild>
26
38
  <Button
27
39
  className={cn('relative h-11 w-11 transition-all', className)}
40
+ onClick={(event) => {
41
+ triggerSFX('sfx:menu-click')
42
+ onClick?.(event)
43
+ }}
44
+ onMouseEnter={(event) => {
45
+ triggerSFX('sfx:menu-hover')
46
+ onMouseEnter?.(event)
47
+ }}
28
48
  ref={ref}
29
49
  {...props}
30
50
  >
@@ -6,18 +6,10 @@ import { useViewer } from '@pascal-app/viewer'
6
6
  import { type LucideIcon, Trash2 } from 'lucide-react'
7
7
  import Image from 'next/image'
8
8
  import { cn } from './../../../lib/utils'
9
- import useEditor from './../../../store/use-editor'
9
+ import useEditor, { selectSiteFloorplanContext } from './../../../store/use-editor'
10
10
  import { ActionButton } from './action-button'
11
11
 
12
- type ControlId =
13
- | 'select'
14
- | 'box-select'
15
- | 'site-edit'
16
- | 'build'
17
- | 'material-paint'
18
- | 'furnish'
19
- | 'zone'
20
- | 'delete'
12
+ type ControlId = 'select' | 'box-select' | 'site-edit' | 'zone' | 'delete'
21
13
 
22
14
  type ControlConfig = {
23
15
  id: ControlId
@@ -40,44 +32,13 @@ const controls: ControlConfig[] = [
40
32
  color: 'hover:bg-blue-500/20 hover:text-blue-400',
41
33
  activeColor: 'bg-blue-500/20 text-blue-400',
42
34
  },
43
- {
44
- id: 'box-select',
45
- iconifyIcon: 'mdi:select-drag',
46
- label: 'Box select',
47
- color: 'hover:bg-white/5',
48
- activeColor: 'bg-white/10 hover:bg-white/10',
49
- },
50
35
  {
51
36
  id: 'site-edit',
52
- imageSrc: '/icons/site.png',
37
+ imageSrc: '/icons/site-flag.png',
53
38
  label: 'Edit site',
54
39
  color: 'hover:bg-white/5',
55
40
  activeColor: 'bg-white/10 hover:bg-white/10',
56
41
  },
57
- {
58
- id: 'build',
59
- imageSrc: '/icons/build.png',
60
- label: 'Build',
61
- shortcut: 'B',
62
- color: 'hover:bg-green-500/20 hover:text-green-400',
63
- activeColor: 'bg-green-500/20 text-green-400',
64
- },
65
- {
66
- id: 'material-paint',
67
- imageSrc: '/icons/paint.png',
68
- label: 'Material Paint',
69
- shortcut: 'P',
70
- color: 'hover:bg-amber-500/20 hover:text-amber-400',
71
- activeColor: 'bg-amber-500/20 text-amber-400',
72
- },
73
- {
74
- id: 'furnish',
75
- imageSrc: '/icons/couch.png',
76
- label: 'Furnish',
77
- shortcut: 'F',
78
- color: 'hover:bg-green-500/20 hover:text-green-400',
79
- activeColor: 'bg-green-500/20 text-green-400',
80
- },
81
42
  {
82
43
  id: 'zone',
83
44
  imageSrc: '/icons/zone.png',
@@ -90,7 +51,7 @@ const controls: ControlConfig[] = [
90
51
  id: 'delete',
91
52
  icon: Trash2,
92
53
  label: 'Delete',
93
- shortcut: 'D',
54
+ shortcut: 'X',
94
55
  color: 'hover:bg-red-500/20 hover:text-red-400',
95
56
  activeColor: 'bg-red-500/20 text-red-400',
96
57
  },
@@ -104,9 +65,6 @@ export function ControlModes() {
104
65
  const setPhase = useEditor((state) => state.setPhase)
105
66
  const setStructureLayer = useEditor((state) => state.setStructureLayer)
106
67
  const setSelectionTool = useEditor((state) => state.setFloorplanSelectionTool)
107
- const primeMaterialPaintFromSelection = useEditor(
108
- (state) => state.primeMaterialPaintFromSelection,
109
- )
110
68
  const levelId = useViewer((s) => s.selection.levelId)
111
69
 
112
70
  // Only subscribe to the primitive `level` number — when walls are added to
@@ -129,10 +87,6 @@ export function ControlModes() {
129
87
  if (id === 'select') return mode === 'select' && selectionTool === 'click'
130
88
  if (id === 'box-select') return mode === 'select' && selectionTool === 'marquee'
131
89
  if (id === 'site-edit') return false
132
- if (id === 'build')
133
- return mode === 'build' && phase === 'structure' && structureLayer === 'elements'
134
- if (id === 'material-paint') return mode === 'material-paint'
135
- if (id === 'furnish') return mode === 'build' && phase === 'furnish'
136
90
  if (id === 'zone')
137
91
  return mode === 'build' && phase === 'structure' && structureLayer === 'zones'
138
92
  return mode === id
@@ -146,12 +100,8 @@ export function ControlModes() {
146
100
  setMode('select')
147
101
  setStructureLayer('elements')
148
102
  } else if (isGroundFloor) {
149
- // Enter site editing — set state directly to preserve level selection.
150
- // setPhase('site') calls viewer.resetSelection() which clears levelId,
151
- // breaking the 2D floorplan (it needs a level to render the SVG).
152
103
  useEditor.setState({ phase: 'site', mode: 'select', tool: null, catalogCategory: null })
153
- // Clear object selection so the polygon editor handles receive pointer events
154
- useViewer.getState().setSelection({ selectedIds: [] })
104
+ selectSiteFloorplanContext()
155
105
  }
156
106
  return
157
107
  }
@@ -168,33 +118,6 @@ export function ControlModes() {
168
118
  } else if (id === 'box-select') {
169
119
  setMode('select')
170
120
  setSelectionTool('marquee')
171
- } else if (id === 'build') {
172
- // Toggle: if already in structure build, go back to select
173
- if (getIsActive('build')) {
174
- setMode('select')
175
- } else {
176
- setPhase('structure')
177
- setStructureLayer('elements')
178
- setMode('build')
179
- }
180
- } else if (id === 'material-paint') {
181
- if (getIsActive('material-paint')) {
182
- setMode('select')
183
- } else {
184
- primeMaterialPaintFromSelection()
185
- setPhase('structure')
186
- setStructureLayer('elements')
187
- setMode('material-paint')
188
- }
189
- } else if (id === 'furnish') {
190
- if (getIsActive('furnish')) {
191
- setMode('select')
192
- } else {
193
- setPhase('furnish')
194
- setMode('build')
195
- // Auto-switch sidebar to the items panel so the user can pick furniture
196
- useEditor.getState().setActiveSidebarPanel('items')
197
- }
198
121
  } else if (id === 'zone') {
199
122
  if (getIsActive('zone')) {
200
123
  setMode('select')
@@ -1,19 +1,14 @@
1
1
  'use client'
2
2
 
3
- import { useScene } from '@pascal-app/core'
4
3
  import { useViewer } from '@pascal-app/viewer'
5
- import { AnimatePresence, motion } from 'motion/react'
6
- import { useEffect, useMemo } from 'react'
7
- import { MaterialPicker } from './../../../components/ui/controls/material-picker'
4
+ import { motion } from 'motion/react'
8
5
  import { TooltipProvider } from './../../../components/ui/primitives/tooltip'
9
6
  import { useIsMobile } from './../../../hooks/use-mobile'
10
7
  import { useReducedMotion } from './../../../hooks/use-reduced-motion'
11
- import { resolvePaintTargetFromSelection } from './../../../lib/material-paint'
12
8
  import { cn } from './../../../lib/utils'
13
9
  import useEditor from './../../../store/use-editor'
14
10
  import { CameraActions } from './camera-actions'
15
11
  import { ControlModes } from './control-modes'
16
- import { StructureTools } from './structure-tools'
17
12
  import { GridSnapControl, SecondaryToggles } from './view-toggles'
18
13
 
19
14
  // Mobile bottom offset matches the viewer's overlap behind the sheet's
@@ -21,47 +16,7 @@ import { GridSnapControl, SecondaryToggles } from './view-toggles'
21
16
  // just above that strip instead of inside it.
22
17
  const MOBILE_BOTTOM_OFFSET = 24
23
18
 
24
- function PaintMaterialTray() {
25
- const activePaintMaterial = useEditor((state) => state.activePaintMaterial)
26
- const activePaintTarget = useEditor((state) => state.activePaintTarget)
27
- const setActivePaintMaterial = useEditor((state) => state.setActivePaintMaterial)
28
- const setActivePaintTarget = useEditor((state) => state.setActivePaintTarget)
29
- const selectedIds = useViewer((state) => state.selection.selectedIds)
30
- const nodes = useScene((state) => state.nodes)
31
- const selectedId = selectedIds.length === 1 ? (selectedIds[0] ?? null) : null
32
-
33
- useEffect(() => {
34
- const selectedPaintTarget = resolvePaintTargetFromSelection({
35
- nodes,
36
- selectedId,
37
- })
38
-
39
- if (selectedPaintTarget) {
40
- setActivePaintTarget(selectedPaintTarget)
41
- }
42
- }, [nodes, selectedId, setActivePaintTarget])
43
-
44
- return (
45
- <div className="w-[42rem] max-w-[calc(100vw-2rem)]">
46
- <MaterialPicker
47
- onChange={(material) => {
48
- setActivePaintMaterial({ material, sourceTarget: activePaintTarget })
49
- }}
50
- onSelectMaterialPreset={(materialPreset) => {
51
- setActivePaintMaterial({ materialPreset, sourceTarget: activePaintTarget })
52
- }}
53
- selectedMaterialPreset={activePaintMaterial?.materialPreset}
54
- value={activePaintMaterial?.material}
55
- />
56
- </div>
57
- )
58
- }
59
-
60
19
  export function ActionMenu({ className }: { className?: string }) {
61
- const phase = useEditor((state) => state.phase)
62
- const mode = useEditor((state) => state.mode)
63
- const tool = useEditor((state) => state.tool)
64
- const catalogCategory = useEditor((state) => state.catalogCategory)
65
20
  const isMobile = useIsMobile()
66
21
  const hasSelectionOnMobile = useViewer((s) => isMobile && s.selection.selectedIds.length > 0)
67
22
  const hasReferenceOnMobile = useEditor((s) => isMobile && Boolean(s.selectedReferenceId))
@@ -70,7 +25,6 @@ export function ActionMenu({ className }: { className?: string }) {
70
25
  (s) => isMobile && CONTEXTUAL_TABS.has(s.activeSidebarPanel),
71
26
  )
72
27
  const reducedMotion = useReducedMotion()
73
- const showPaintTray = useMemo(() => mode === 'material-paint', [mode])
74
28
 
75
29
  // On mobile, defer the bottom rail to the selection bar when something
76
30
  // is selected — the contextual actions take priority over mode controls.
@@ -97,72 +51,6 @@ export function ActionMenu({ className }: { className?: string }) {
97
51
  style={isMobile ? { bottom: MOBILE_BOTTOM_OFFSET } : undefined}
98
52
  transition={transition}
99
53
  >
100
- {/* Structure Tools Row - Animated */}
101
- <AnimatePresence>
102
- {phase === 'structure' && mode === 'build' && (
103
- <motion.div
104
- animate={{
105
- opacity: 1,
106
- maxHeight: 80,
107
- paddingTop: 8,
108
- paddingBottom: 8,
109
- borderBottomWidth: 1,
110
- }}
111
- className={cn('max-h-20 overflow-hidden border-border border-b px-2 py-2')}
112
- exit={{
113
- opacity: 0,
114
- maxHeight: 0,
115
- paddingTop: 0,
116
- paddingBottom: 0,
117
- borderBottomWidth: 0,
118
- }}
119
- initial={{
120
- opacity: 0,
121
- maxHeight: 0,
122
- paddingTop: 0,
123
- paddingBottom: 0,
124
- borderBottomWidth: 0,
125
- }}
126
- transition={transition}
127
- >
128
- <div className="w-max">
129
- <StructureTools />
130
- </div>
131
- </motion.div>
132
- )}
133
- </AnimatePresence>
134
-
135
- <AnimatePresence>
136
- {showPaintTray && (
137
- <motion.div
138
- animate={{
139
- opacity: 1,
140
- maxHeight: 96,
141
- paddingTop: 8,
142
- paddingBottom: 8,
143
- borderBottomWidth: 1,
144
- }}
145
- className={cn('overflow-hidden border-border border-b px-3')}
146
- exit={{
147
- opacity: 0,
148
- maxHeight: 0,
149
- paddingTop: 0,
150
- paddingBottom: 0,
151
- borderBottomWidth: 0,
152
- }}
153
- initial={{
154
- opacity: 0,
155
- maxHeight: 0,
156
- paddingTop: 0,
157
- paddingBottom: 0,
158
- borderBottomWidth: 0,
159
- }}
160
- transition={transition}
161
- >
162
- <PaintMaterialTray />
163
- </motion.div>
164
- )}
165
- </AnimatePresence>
166
54
  {isMobile ? (
167
55
  <div className="flex flex-col items-stretch gap-0.5 px-2 py-1.5">
168
56
  {/* Row 1: control modes only */}