@pascal-app/editor 0.7.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 (244) hide show
  1. package/package.json +12 -13
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
  3. package/src/components/editor/custom-camera-controls.tsx +755 -25
  4. package/src/components/editor/editor-layout-mobile.tsx +3 -3
  5. package/src/components/editor/editor-layout-v2.tsx +62 -54
  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 +203 -87
  9. package/src/components/editor/first-person/bvh-ecctrl.tsx +127 -62
  10. package/src/components/editor/first-person-controls.tsx +836 -31
  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 +5837 -12006
  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 +234 -190
  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 +435 -158
  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 +539 -0
  28. package/src/components/editor/thumbnail-generator.tsx +27 -64
  29. package/src/components/editor/use-floorplan-background-placement.ts +122 -35
  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-draft-layer.tsx +6 -1
  45. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
  46. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -61
  47. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
  48. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
  49. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +29 -14
  50. package/src/components/editor-2d/svg-paths.ts +10 -2
  51. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +30 -9
  52. package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
  53. package/src/components/systems/roof/roof-edit-system.tsx +86 -12
  54. package/src/components/systems/stair/stair-edit-system.tsx +2 -2
  55. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
  56. package/src/components/systems/zone/zone-system.tsx +0 -0
  57. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  58. package/src/components/tools/elevator/elevator-tool.tsx +349 -0
  59. package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
  60. package/src/components/tools/fence/fence-drafting.ts +64 -22
  61. package/src/components/tools/item/move-tool.tsx +40 -107
  62. package/src/components/tools/item/placement-math.test.ts +10 -0
  63. package/src/components/tools/item/placement-math.ts +33 -9
  64. package/src/components/tools/item/placement-strategies.ts +201 -25
  65. package/src/components/tools/item/placement-types.ts +10 -1
  66. package/src/components/tools/item/use-draft-node.ts +20 -2
  67. package/src/components/tools/item/use-placement-coordinator.tsx +700 -299
  68. package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
  69. package/src/components/tools/roof/roof-tool.tsx +108 -12
  70. package/src/components/tools/select/box-select-state.ts +77 -0
  71. package/src/components/tools/select/box-select-tool.tsx +324 -543
  72. package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
  73. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  74. package/src/components/tools/select/select-candidates.test.ts +109 -0
  75. package/src/components/tools/select/select-candidates.ts +85 -0
  76. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  77. package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
  78. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  79. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  80. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  81. package/src/components/tools/shared/placement-box.tsx +123 -0
  82. package/src/components/tools/shared/polygon-editor.tsx +617 -167
  83. package/src/components/tools/shared/segment-angle.ts +57 -1
  84. package/src/components/tools/site/site-boundary-editor.tsx +464 -12
  85. package/src/components/tools/site/site-flag-model.ts +18 -0
  86. package/src/components/tools/stair/stair-defaults.ts +1 -1
  87. package/src/components/tools/stair/stair-tool.tsx +269 -35
  88. package/src/components/tools/tool-manager.tsx +172 -56
  89. package/src/components/tools/wall/wall-drafting.ts +98 -98
  90. package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
  91. package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
  92. package/src/components/tools/zone/zone-tool.tsx +36 -11
  93. package/src/components/ui/action-menu/action-button.tsx +21 -1
  94. package/src/components/ui/action-menu/camera-actions.tsx +0 -0
  95. package/src/components/ui/action-menu/control-modes.tsx +5 -76
  96. package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
  97. package/src/components/ui/action-menu/index.tsx +33 -196
  98. package/src/components/ui/action-menu/structure-tools.tsx +13 -88
  99. package/src/components/ui/action-menu/view-toggles.tsx +262 -37
  100. package/src/components/ui/command-palette/editor-commands.tsx +24 -17
  101. package/src/components/ui/command-palette/index.tsx +6 -256
  102. package/src/components/ui/controls/material-paint-panel.tsx +83 -0
  103. package/src/components/ui/controls/material-picker.tsx +17 -35
  104. package/src/components/ui/controls/metric-control.tsx +33 -11
  105. package/src/components/ui/floating-level-selector.tsx +48 -11
  106. package/src/components/ui/helpers/helper-manager.tsx +21 -17
  107. package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
  108. package/src/components/ui/item-catalog/catalog-items.tsx +1743 -315
  109. package/src/components/ui/item-catalog/item-catalog.tsx +91 -46
  110. package/src/components/ui/level-duplicate-dialog.tsx +5 -6
  111. package/src/components/ui/panels/node-display.ts +1 -0
  112. package/src/components/ui/panels/paint-panel.tsx +66 -41
  113. package/src/components/ui/panels/panel-manager.tsx +33 -74
  114. package/src/components/ui/panels/panel-wrapper.tsx +217 -12
  115. package/src/components/ui/panels/parametric-inspector.tsx +469 -0
  116. package/src/components/ui/panels/reference-panel.tsx +28 -13
  117. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  118. package/src/components/ui/primitives/number-input.tsx +1 -1
  119. package/src/components/ui/primitives/sidebar.tsx +0 -0
  120. package/src/components/ui/scene-loader.tsx +1 -3
  121. package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
  122. package/src/components/ui/sidebar/icon-rail.tsx +0 -0
  123. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  124. package/src/components/ui/sidebar/panels/items-panel/index.tsx +404 -0
  125. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
  126. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
  127. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
  128. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  129. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
  130. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
  131. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
  132. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  133. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
  134. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
  135. package/src/components/ui/sidebar/panels/site-panel/index.tsx +8 -12
  136. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  137. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
  138. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
  139. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
  140. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
  141. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
  142. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  143. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -34
  144. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -1
  145. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +52 -3
  146. package/src/components/ui/sidebar/tab-bar.tsx +61 -1
  147. package/src/components/ui/slider.tsx +1 -1
  148. package/src/components/viewer-overlay.tsx +169 -52
  149. package/src/components/viewer-zone-system.tsx +1 -1
  150. package/src/hooks/use-auto-save.ts +24 -3
  151. package/src/hooks/use-ceiling-events.ts +175 -0
  152. package/src/hooks/use-drag-action.ts +124 -0
  153. package/src/hooks/use-keyboard.ts +125 -62
  154. package/src/hooks/use-selection.ts +64 -0
  155. package/src/index.tsx +243 -4
  156. package/src/lib/constants.ts +6 -2
  157. package/src/lib/editor-api.ts +62 -0
  158. package/src/lib/elevator-support.ts +96 -0
  159. package/src/lib/floorplan/apply-alignment.ts +111 -0
  160. package/src/lib/floorplan/index.ts +7 -0
  161. package/src/lib/floorplan/selection-tool.ts +40 -0
  162. package/src/lib/floorplan/stairs.ts +12 -1
  163. package/src/lib/fresh-planar-placement.test.ts +102 -0
  164. package/src/lib/fresh-planar-placement.ts +63 -0
  165. package/src/lib/history.ts +2 -1
  166. package/src/lib/level-duplication.test.ts +0 -2
  167. package/src/lib/level-duplication.ts +55 -43
  168. package/src/lib/level-name.ts +11 -0
  169. package/src/lib/material-paint.ts +212 -20
  170. package/src/lib/placement-metadata.ts +29 -0
  171. package/src/lib/planar-cursor-placement.test.ts +43 -0
  172. package/src/lib/planar-cursor-placement.ts +42 -0
  173. package/src/lib/roof-duplication.ts +2 -2
  174. package/src/lib/scene-bounds.ts +1 -1
  175. package/src/lib/scene-clipboard.ts +267 -0
  176. package/src/lib/scene.ts +13 -4
  177. package/src/lib/sfx-bus.ts +25 -1
  178. package/src/lib/sfx-player.ts +75 -27
  179. package/src/lib/site-boundary.ts +1 -0
  180. package/src/lib/stair-duplication.ts +2 -2
  181. package/src/lib/stair-levels.test.ts +146 -0
  182. package/src/lib/stair-levels.ts +177 -0
  183. package/src/lib/world-grid-snap.ts +199 -0
  184. package/src/lib/zone-content.ts +130 -0
  185. package/src/store/use-alignment-guides.ts +21 -0
  186. package/src/store/use-editor.tsx +342 -91
  187. package/src/store/use-placement-preview.ts +31 -0
  188. package/src/store/use-wall-move-ghosts.ts +36 -0
  189. package/src/store/use-wall-snap-indicator.ts +32 -0
  190. package/tsconfig.json +2 -1
  191. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  192. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  193. package/src/components/feedback-dialog.tsx +0 -265
  194. package/src/components/pascal-radio.tsx +0 -280
  195. package/src/components/preview-button.tsx +0 -16
  196. package/src/components/tools/building/move-building-tool.tsx +0 -157
  197. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  198. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  199. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  200. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -257
  201. package/src/components/tools/column/column-tool.tsx +0 -97
  202. package/src/components/tools/column/move-column-tool.tsx +0 -105
  203. package/src/components/tools/door/door-math.ts +0 -110
  204. package/src/components/tools/door/door-tool.tsx +0 -324
  205. package/src/components/tools/door/move-door-tool.tsx +0 -412
  206. package/src/components/tools/fence/curve-fence-tool.tsx +0 -179
  207. package/src/components/tools/fence/fence-tool.tsx +0 -346
  208. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -438
  209. package/src/components/tools/fence/move-fence-tool.tsx +0 -298
  210. package/src/components/tools/item/item-tool.tsx +0 -26
  211. package/src/components/tools/roof/move-roof-tool.tsx +0 -359
  212. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  213. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  214. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  215. package/src/components/tools/slab/slab-tool.tsx +0 -322
  216. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  217. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  218. package/src/components/tools/wall/curve-wall-tool.tsx +0 -176
  219. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -423
  220. package/src/components/tools/wall/move-wall-tool.tsx +0 -356
  221. package/src/components/tools/wall/wall-tool.tsx +0 -332
  222. package/src/components/tools/window/move-window-tool.tsx +0 -447
  223. package/src/components/tools/window/window-math.ts +0 -117
  224. package/src/components/tools/window/window-tool.tsx +0 -332
  225. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  226. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  227. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  228. package/src/components/ui/panels/ceiling-panel.tsx +0 -250
  229. package/src/components/ui/panels/column-panel.tsx +0 -715
  230. package/src/components/ui/panels/door-panel.tsx +0 -1292
  231. package/src/components/ui/panels/fence-panel.tsx +0 -227
  232. package/src/components/ui/panels/item-panel.tsx +0 -306
  233. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  234. package/src/components/ui/panels/roof-panel.tsx +0 -230
  235. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  236. package/src/components/ui/panels/slab-panel.tsx +0 -250
  237. package/src/components/ui/panels/spawn-panel.tsx +0 -155
  238. package/src/components/ui/panels/stair-panel.tsx +0 -525
  239. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  240. package/src/components/ui/panels/wall-panel.tsx +0 -177
  241. package/src/components/ui/panels/window-panel.tsx +0 -960
  242. package/src/components/ui/viewer-toolbar.tsx +0 -436
  243. package/src/contexts/presets-context.tsx +0 -121
  244. 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
+ }
@@ -3,6 +3,8 @@ import { useViewer } from '@pascal-app/viewer'
3
3
  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
+ import { sfxEmitter } from './../../../lib/sfx-bus'
7
+ import { snapWorldXZForActiveBuilding } from './../../../lib/world-grid-snap'
6
8
  import useEditor from './../../../store/use-editor'
7
9
  import { CursorSphere } from '../shared/cursor-sphere'
8
10
 
@@ -67,6 +69,9 @@ const commitZoneDrawing = (levelId: LevelNode['id'], points: Array<[number, numb
67
69
 
68
70
  // Select the newly created zone
69
71
  useViewer.getState().setSelection({ zoneId: zone.id })
72
+
73
+ // Play structure build sound
74
+ sfxEmitter.emit('sfx:structure-build')
70
75
  }
71
76
 
72
77
  type PreviewState = {
@@ -86,6 +91,7 @@ export const ZoneTool: React.FC = () => {
86
91
  const mainLineRef = useRef<Line>(null!)
87
92
  const closingLineRef = useRef<Line>(null!)
88
93
  const pointsRef = useRef<Array<[number, number]>>([])
94
+ const previousSnappedPointRef = useRef<[number, number] | null>(null)
89
95
  const levelYRef = useRef(0) // Track current level Y position
90
96
  const currentLevelId = useViewer((state) => state.selection.levelId)
91
97
  const setTool = useEditor((state) => state.setTool)
@@ -173,20 +179,34 @@ export const ZoneTool: React.FC = () => {
173
179
  const onGridMove = (event: GridEvent) => {
174
180
  if (!cursorRef.current) return
175
181
 
176
- // Snap to 0.5 grid
177
- const gridX = Math.round(event.localPosition[0] * 2) / 2
178
- 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
179
189
  cursorPosition = [gridX, gridZ]
180
190
  levelYRef.current = event.localPosition[1]
181
191
 
182
192
  // If we have points, snap to axis from last point
183
193
  const lastPoint = pointsRef.current[pointsRef.current.length - 1]
184
- if (lastPoint) {
185
- const snapped = calculateSnapPoint(lastPoint, cursorPosition)
186
- cursorRef.current.position.set(snapped[0], event.localPosition[1], snapped[1])
187
- } else {
188
- cursorRef.current.position.set(gridX, event.localPosition[1], gridZ)
194
+ const displayPoint = lastPoint
195
+ ? calculateSnapPoint(lastPoint, cursorPosition)
196
+ : cursorPosition
197
+
198
+ // Play snap sound when the snapped position changes during drawing
199
+ if (
200
+ pointsRef.current.length > 0 &&
201
+ previousSnappedPointRef.current &&
202
+ (displayPoint[0] !== previousSnappedPointRef.current[0] ||
203
+ displayPoint[1] !== previousSnappedPointRef.current[1])
204
+ ) {
205
+ sfxEmitter.emit('sfx:grid-snap')
189
206
  }
207
+ previousSnappedPointRef.current = displayPoint
208
+
209
+ cursorRef.current.position.set(displayPoint[0], event.localPosition[1], displayPoint[1])
190
210
 
191
211
  updatePreview()
192
212
  }
@@ -194,8 +214,11 @@ export const ZoneTool: React.FC = () => {
194
214
  const onGridClick = (event: GridEvent) => {
195
215
  if (!currentLevelId) return
196
216
 
197
- const gridX = Math.round(event.localPosition[0] * 2) / 2
198
- 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
199
222
  let clickPoint: [number, number] = [gridX, gridZ]
200
223
 
201
224
  // Snap to axis from last point
@@ -221,7 +244,9 @@ export const ZoneTool: React.FC = () => {
221
244
  mainLineRef.current.visible = false
222
245
  closingLineRef.current.visible = false
223
246
  } else {
224
- // 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')
225
250
  pointsRef.current = [...pointsRef.current, clickPoint]
226
251
  updatePreview()
227
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
  >
File without changes
@@ -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,7 +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((state) => state.primeMaterialPaintFromSelection)
108
68
  const levelId = useViewer((s) => s.selection.levelId)
109
69
 
110
70
  // Only subscribe to the primitive `level` number — when walls are added to
@@ -127,10 +87,6 @@ export function ControlModes() {
127
87
  if (id === 'select') return mode === 'select' && selectionTool === 'click'
128
88
  if (id === 'box-select') return mode === 'select' && selectionTool === 'marquee'
129
89
  if (id === 'site-edit') return false
130
- if (id === 'build')
131
- return mode === 'build' && phase === 'structure' && structureLayer === 'elements'
132
- if (id === 'material-paint') return mode === 'material-paint'
133
- if (id === 'furnish') return mode === 'build' && phase === 'furnish'
134
90
  if (id === 'zone')
135
91
  return mode === 'build' && phase === 'structure' && structureLayer === 'zones'
136
92
  return mode === id
@@ -144,10 +100,8 @@ export function ControlModes() {
144
100
  setMode('select')
145
101
  setStructureLayer('elements')
146
102
  } else if (isGroundFloor) {
147
- // Enter site editing — set state directly to preserve level selection.
148
- // setPhase('site') calls viewer.resetSelection() which clears levelId,
149
- // breaking the 2D floorplan (it needs a level to render the SVG).
150
103
  useEditor.setState({ phase: 'site', mode: 'select', tool: null, catalogCategory: null })
104
+ selectSiteFloorplanContext()
151
105
  }
152
106
  return
153
107
  }
@@ -164,31 +118,6 @@ export function ControlModes() {
164
118
  } else if (id === 'box-select') {
165
119
  setMode('select')
166
120
  setSelectionTool('marquee')
167
- } else if (id === 'build') {
168
- // Toggle: if already in structure build, go back to select
169
- if (getIsActive('build')) {
170
- setMode('select')
171
- } else {
172
- setPhase('structure')
173
- setStructureLayer('elements')
174
- setMode('build')
175
- }
176
- } else if (id === 'material-paint') {
177
- if (getIsActive('material-paint')) {
178
- setMode('select')
179
- } else {
180
- primeMaterialPaintFromSelection()
181
- setPhase('structure')
182
- setStructureLayer('elements')
183
- setMode('material-paint')
184
- }
185
- } else if (id === 'furnish') {
186
- if (getIsActive('furnish')) {
187
- setMode('select')
188
- } else {
189
- setPhase('furnish')
190
- setMode('build')
191
- }
192
121
  } else if (id === 'zone') {
193
122
  if (getIsActive('zone')) {
194
123
  setMode('select')
@@ -1,9 +1,4 @@
1
- 'use client'
2
-
3
- import NextImage from 'next/image'
4
- import { cn } from './../../../lib/utils'
5
- import useEditor, { type CatalogCategory } from './../../../store/use-editor'
6
- import { ActionButton } from './action-button'
1
+ import type { CatalogCategory } from './../../../store/use-editor'
7
2
 
8
3
  export type FurnishToolConfig = {
9
4
  id: 'item'
@@ -12,91 +7,10 @@ export type FurnishToolConfig = {
12
7
  catalogCategory: CatalogCategory
13
8
  }
14
9
 
15
- // Furnish mode tools: furniture, appliances, decoration (painting is now a control mode)
16
10
  export const furnishTools: FurnishToolConfig[] = [
17
- {
18
- id: 'item',
19
- iconSrc: '/icons/couch.png',
20
- label: 'Furniture',
21
- catalogCategory: 'furniture',
22
- },
23
- {
24
- id: 'item',
25
- iconSrc: '/icons/appliance.png',
26
- label: 'Appliance',
27
- catalogCategory: 'appliance',
28
- },
29
- {
30
- id: 'item',
31
- iconSrc: '/icons/kitchen.png',
32
- label: 'Kitchen',
33
- catalogCategory: 'kitchen',
34
- },
35
- {
36
- id: 'item',
37
- iconSrc: '/icons/bathroom.png',
38
- label: 'Bathroom',
39
- catalogCategory: 'bathroom',
40
- },
41
- {
42
- id: 'item',
43
- iconSrc: '/icons/tree.png',
44
- label: 'Outdoor',
45
- catalogCategory: 'outdoor',
46
- },
11
+ { id: 'item', iconSrc: '/icons/couch.png', label: 'Furniture', catalogCategory: 'furniture' },
12
+ { id: 'item', iconSrc: '/icons/appliance.png', label: 'Appliance', catalogCategory: 'appliance' },
13
+ { id: 'item', iconSrc: '/icons/kitchen.png', label: 'Kitchen', catalogCategory: 'kitchen' },
14
+ { id: 'item', iconSrc: '/icons/bathroom.png', label: 'Bathroom', catalogCategory: 'bathroom' },
15
+ { id: 'item', iconSrc: '/icons/tree.png', label: 'Outdoor', catalogCategory: 'outdoor' },
47
16
  ]
48
-
49
- export function FurnishTools() {
50
- const mode = useEditor((state) => state.mode)
51
- const activeTool = useEditor((state) => state.tool)
52
- const setActiveTool = useEditor((state) => state.setTool)
53
- const setMode = useEditor((state) => state.setMode)
54
- const catalogCategory = useEditor((state) => state.catalogCategory)
55
- const setCatalogCategory = useEditor((state) => state.setCatalogCategory)
56
-
57
- const hasActiveTool = furnishTools.some(
58
- (tool) => mode === 'build' && activeTool === 'item' && catalogCategory === tool.catalogCategory,
59
- )
60
-
61
- return (
62
- <div className="flex items-center gap-1.5 px-1">
63
- {furnishTools.map((tool, index) => {
64
- // For item tools with catalog category, check both tool and category match
65
- const isActive =
66
- mode === 'build' && activeTool === 'item' && catalogCategory === tool.catalogCategory
67
-
68
- return (
69
- <ActionButton
70
- className={cn(
71
- 'rounded-lg duration-300',
72
- isActive
73
- ? 'z-10 scale-110 bg-black/40 hover:bg-black/40'
74
- : 'scale-95 bg-transparent opacity-60 grayscale hover:bg-black/20 hover:opacity-100 hover:grayscale-0',
75
- )}
76
- key={`${tool.id}-${tool.catalogCategory ?? index}`}
77
- label={tool.label}
78
- onClick={() => {
79
- if (!isActive) {
80
- setCatalogCategory(tool.catalogCategory)
81
- setActiveTool('item')
82
- if (mode !== 'build') {
83
- setMode('build')
84
- }
85
- }
86
- }}
87
- size="icon"
88
- variant="ghost"
89
- >
90
- <NextImage
91
- alt={tool.label}
92
- className="size-full object-contain"
93
- height={28}
94
- src={tool.iconSrc}
95
- width={28}
96
- />
97
- </ActionButton>
98
- )
99
- })}
100
- </div>
101
- )
102
- }