@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
@@ -1,358 +0,0 @@
1
- 'use client'
2
-
3
- import {
4
- type AnyNodeId,
5
- emitter,
6
- type GridEvent,
7
- pauseSceneHistory,
8
- resumeSceneHistory,
9
- useScene,
10
- type WallNode,
11
- } from '@pascal-app/core'
12
- import { useViewer } from '@pascal-app/viewer'
13
- import { useCallback, useEffect, useRef, useState } from 'react'
14
- import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
15
- import { sfxEmitter } from '../../../lib/sfx-bus'
16
- import useEditor from '../../../store/use-editor'
17
- import { CursorSphere } from '../shared/cursor-sphere'
18
- import { getWallGridStep, snapScalarToGrid } from './wall-drafting'
19
-
20
- function rotateVector([x, z]: [number, number], angle: number): [number, number] {
21
- const cos = Math.cos(angle)
22
- const sin = Math.sin(angle)
23
- return [x * cos - z * sin, x * sin + z * cos]
24
- }
25
-
26
- function samePoint(a: [number, number], b: [number, number]) {
27
- return a[0] === b[0] && a[1] === b[1]
28
- }
29
-
30
- function stripWallIsNewMetadata(meta: WallNode['metadata']): WallNode['metadata'] {
31
- if (!meta || typeof meta !== 'object' || Array.isArray(meta)) {
32
- return meta
33
- }
34
-
35
- const nextMeta = { ...(meta as Record<string, unknown>) } as Record<string, unknown>
36
- delete nextMeta.isNew
37
- return nextMeta as WallNode['metadata']
38
- }
39
-
40
- type LinkedWallSnapshot = {
41
- id: WallNode['id']
42
- start: [number, number]
43
- end: [number, number]
44
- }
45
-
46
- function getLinkedWallSnapshots(args: {
47
- wallId: WallNode['id']
48
- wallParentId: string | null
49
- originalStart: [number, number]
50
- originalEnd: [number, number]
51
- }) {
52
- const { wallId, wallParentId, originalStart, originalEnd } = args
53
- const { nodes } = useScene.getState()
54
- const snapshots: LinkedWallSnapshot[] = []
55
-
56
- for (const node of Object.values(nodes)) {
57
- if (!(node?.type === 'wall' && node.id !== wallId)) {
58
- continue
59
- }
60
-
61
- if ((node.parentId ?? null) !== wallParentId) {
62
- continue
63
- }
64
-
65
- if (
66
- !(
67
- samePoint(node.start, originalStart) ||
68
- samePoint(node.start, originalEnd) ||
69
- samePoint(node.end, originalStart) ||
70
- samePoint(node.end, originalEnd)
71
- )
72
- ) {
73
- continue
74
- }
75
-
76
- snapshots.push({
77
- id: node.id,
78
- start: [...node.start] as [number, number],
79
- end: [...node.end] as [number, number],
80
- })
81
- }
82
-
83
- return snapshots
84
- }
85
-
86
- function getLinkedWallUpdates(
87
- linkedWalls: LinkedWallSnapshot[],
88
- originalStart: [number, number],
89
- originalEnd: [number, number],
90
- nextStart: [number, number],
91
- nextEnd: [number, number],
92
- ) {
93
- return linkedWalls.map((wall) => ({
94
- id: wall.id,
95
- start: samePoint(wall.start, originalStart)
96
- ? nextStart
97
- : samePoint(wall.start, originalEnd)
98
- ? nextEnd
99
- : wall.start,
100
- end: samePoint(wall.end, originalStart)
101
- ? nextStart
102
- : samePoint(wall.end, originalEnd)
103
- ? nextEnd
104
- : wall.end,
105
- }))
106
- }
107
-
108
- export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
109
- const meta =
110
- typeof node.metadata === 'object' && node.metadata !== null && !Array.isArray(node.metadata)
111
- ? (node.metadata as Record<string, unknown>)
112
- : {}
113
- const isNew = !!meta.isNew
114
- const activatedAtRef = useRef<number>(Date.now())
115
- const previousGridPosRef = useRef<[number, number] | null>(null)
116
- const originalStartRef = useRef<[number, number]>([...node.start] as [number, number])
117
- const originalEndRef = useRef<[number, number]>([...node.end] as [number, number])
118
- const originalCenterRef = useRef<[number, number]>([
119
- (node.start[0] + node.end[0]) / 2,
120
- (node.start[1] + node.end[1]) / 2,
121
- ])
122
- const originalHalfVectorRef = useRef<[number, number]>([
123
- (node.end[0] - node.start[0]) / 2,
124
- (node.end[1] - node.start[1]) / 2,
125
- ])
126
- const linkedOriginalsRef = useRef(
127
- isNew
128
- ? []
129
- : getLinkedWallSnapshots({
130
- wallId: node.id,
131
- wallParentId: node.parentId ?? null,
132
- originalStart: node.start,
133
- originalEnd: node.end,
134
- }),
135
- )
136
- const dragAnchorRef = useRef<[number, number] | null>(null)
137
- const nodeIdRef = useRef(node.id)
138
- const previewRef = useRef<{ start: [number, number]; end: [number, number] } | null>(null)
139
- const pendingRotationRef = useRef(0)
140
- const shiftPressedRef = useRef(false)
141
-
142
- const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
143
- const centerX = (node.start[0] + node.end[0]) / 2
144
- const centerZ = (node.start[1] + node.end[1]) / 2
145
- return [centerX, 0, centerZ]
146
- })
147
-
148
- const exitMoveMode = useCallback(() => {
149
- useEditor.getState().setMovingNode(null)
150
- }, [])
151
-
152
- useEffect(() => {
153
- const nodeId = nodeIdRef.current
154
- const originalStart = originalStartRef.current
155
- const originalEnd = originalEndRef.current
156
- const originalCenter = originalCenterRef.current
157
- const originalHalfVector = originalHalfVectorRef.current
158
-
159
- pauseSceneHistory(useScene)
160
- let wasCommitted = false
161
-
162
- const applyNodePreview = (
163
- updates: Array<{ id: WallNode['id']; start: [number, number]; end: [number, number] }>,
164
- ) => {
165
- useScene.getState().updateNodes(
166
- updates.map((entry) => ({
167
- id: entry.id as AnyNodeId,
168
- data: { start: entry.start, end: entry.end },
169
- })),
170
- )
171
- for (const entry of updates) {
172
- useScene.getState().markDirty(entry.id as AnyNodeId)
173
- }
174
- }
175
-
176
- const buildWallFromCenter = (center: [number, number]) => {
177
- const rotatedHalf = rotateVector(originalHalfVector, pendingRotationRef.current)
178
- const nextStart: [number, number] = [center[0] - rotatedHalf[0], center[1] - rotatedHalf[1]]
179
- const nextEnd: [number, number] = [center[0] + rotatedHalf[0], center[1] + rotatedHalf[1]]
180
- return { start: nextStart, end: nextEnd }
181
- }
182
-
183
- const applyPreview = (nextStart: [number, number], nextEnd: [number, number]) => {
184
- previewRef.current = { start: nextStart, end: nextEnd }
185
- const centerX = (nextStart[0] + nextEnd[0]) / 2
186
- const centerZ = (nextStart[1] + nextEnd[1]) / 2
187
- setCursorLocalPos([centerX, 0, centerZ])
188
- applyNodePreview([
189
- { id: nodeId, start: nextStart, end: nextEnd },
190
- ...getLinkedWallUpdates(
191
- linkedOriginalsRef.current,
192
- originalStart,
193
- originalEnd,
194
- nextStart,
195
- nextEnd,
196
- ),
197
- ])
198
- }
199
-
200
- const restoreOriginal = () => {
201
- applyNodePreview([
202
- { id: nodeId, start: originalStart, end: originalEnd },
203
- ...linkedOriginalsRef.current,
204
- ])
205
- }
206
-
207
- const onGridMove = (event: GridEvent) => {
208
- const rawX = event.localPosition[0]
209
- const rawZ = event.localPosition[2]
210
- const snapStep = getWallGridStep()
211
- const localX = shiftPressedRef.current ? rawX : snapScalarToGrid(rawX, snapStep)
212
- const localZ = shiftPressedRef.current ? rawZ : snapScalarToGrid(rawZ, snapStep)
213
-
214
- if (
215
- previousGridPosRef.current &&
216
- (localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1])
217
- ) {
218
- sfxEmitter.emit('sfx:grid-snap')
219
- }
220
- previousGridPosRef.current = [localX, localZ]
221
-
222
- const anchor = dragAnchorRef.current ?? [localX, localZ]
223
- dragAnchorRef.current = anchor
224
-
225
- const deltaX = localX - anchor[0]
226
- const deltaZ = localZ - anchor[1]
227
-
228
- const nextCenter: [number, number] = [originalCenter[0] + deltaX, originalCenter[1] + deltaZ]
229
- const nextWall = buildWallFromCenter(nextCenter)
230
- applyPreview(nextWall.start, nextWall.end)
231
- }
232
-
233
- const onGridClick = (event: GridEvent) => {
234
- if (Date.now() - activatedAtRef.current < 150) {
235
- event.nativeEvent?.stopPropagation?.()
236
- return
237
- }
238
-
239
- const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
240
-
241
- wasCommitted = true
242
-
243
- // Restore original baseline while paused so the next resume+update
244
- // registers as a single tracked change (undo reverts to original).
245
- applyNodePreview([
246
- { id: nodeId, start: originalStart, end: originalEnd },
247
- ...linkedOriginalsRef.current,
248
- ])
249
-
250
- resumeSceneHistory(useScene)
251
-
252
- const commitUpdates = [
253
- {
254
- id: nodeId as AnyNodeId,
255
- data: isNew
256
- ? {
257
- start: preview.start,
258
- end: preview.end,
259
- metadata: stripWallIsNewMetadata(node.metadata),
260
- }
261
- : { start: preview.start, end: preview.end },
262
- },
263
- ...getLinkedWallUpdates(
264
- linkedOriginalsRef.current,
265
- originalStart,
266
- originalEnd,
267
- preview.start,
268
- preview.end,
269
- ).map((entry) => ({
270
- id: entry.id as AnyNodeId,
271
- data: { start: entry.start, end: entry.end },
272
- })),
273
- ]
274
- useScene.getState().updateNodes(commitUpdates)
275
- for (const { id } of commitUpdates) {
276
- useScene.getState().markDirty(id)
277
- }
278
-
279
- pauseSceneHistory(useScene)
280
-
281
- sfxEmitter.emit('sfx:item-place')
282
- useViewer.getState().setSelection({ selectedIds: [nodeId] })
283
- exitMoveMode()
284
- event.nativeEvent?.stopPropagation?.()
285
- }
286
-
287
- const onKeyDown = (event: KeyboardEvent) => {
288
- if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
289
- return
290
- }
291
-
292
- if (event.key === 'Shift') {
293
- shiftPressedRef.current = true
294
- return
295
- }
296
-
297
- const ROTATION_STEP = Math.PI / 4
298
- let rotationDelta = 0
299
- if (event.key === 'r' || event.key === 'R') rotationDelta = ROTATION_STEP
300
- else if (event.key === 't' || event.key === 'T') rotationDelta = -ROTATION_STEP
301
-
302
- if (rotationDelta === 0) {
303
- return
304
- }
305
-
306
- event.preventDefault()
307
- pendingRotationRef.current += rotationDelta
308
- sfxEmitter.emit('sfx:item-rotate')
309
-
310
- const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
311
- const currentCenter: [number, number] = [
312
- (preview.start[0] + preview.end[0]) / 2,
313
- (preview.start[1] + preview.end[1]) / 2,
314
- ]
315
- const nextWall = buildWallFromCenter(currentCenter)
316
- applyPreview(nextWall.start, nextWall.end)
317
- }
318
-
319
- const onKeyUp = (event: KeyboardEvent) => {
320
- if (event.key === 'Shift') {
321
- shiftPressedRef.current = false
322
- }
323
- }
324
-
325
- const onCancel = () => {
326
- restoreOriginal()
327
- useViewer.getState().setSelection({ selectedIds: [nodeId] })
328
- resumeSceneHistory(useScene)
329
- markToolCancelConsumed()
330
- exitMoveMode()
331
- }
332
-
333
- emitter.on('grid:move', onGridMove)
334
- emitter.on('grid:click', onGridClick)
335
- emitter.on('tool:cancel', onCancel)
336
- window.addEventListener('keydown', onKeyDown)
337
- window.addEventListener('keyup', onKeyUp)
338
-
339
- return () => {
340
- if (!wasCommitted) {
341
- restoreOriginal()
342
- }
343
- shiftPressedRef.current = false
344
- resumeSceneHistory(useScene)
345
- emitter.off('grid:move', onGridMove)
346
- emitter.off('grid:click', onGridClick)
347
- emitter.off('tool:cancel', onCancel)
348
- window.removeEventListener('keydown', onKeyDown)
349
- window.removeEventListener('keyup', onKeyUp)
350
- }
351
- }, [exitMoveMode, isNew, node.metadata])
352
-
353
- return (
354
- <group>
355
- <CursorSphere position={cursorLocalPos} showTooltip={false} />
356
- </group>
357
- )
358
- }
@@ -1,332 +0,0 @@
1
- import { emitter, type GridEvent, type LevelNode, useScene, type WallNode } from '@pascal-app/core'
2
- import { useViewer } from '@pascal-app/viewer'
3
- import { Html } from '@react-three/drei'
4
- import { useEffect, useRef, useState } from 'react'
5
- import { DoubleSide, type Group, type Mesh, Shape, ShapeGeometry, Vector3 } from 'three'
6
- import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
7
- import { EDITOR_LAYER } from '../../../lib/constants'
8
- import { sfxEmitter } from '../../../lib/sfx-bus'
9
- import { CursorSphere } from '../shared/cursor-sphere'
10
- import {
11
- formatAngleRadians,
12
- getAngleToSegmentReference,
13
- getSegmentAngleReferenceAtPoint,
14
- } from '../shared/segment-angle'
15
- import { createWallOnCurrentLevel, snapWallDraftPoint, type WallPlanPoint } from './wall-drafting'
16
-
17
- const WALL_HEIGHT = 2.5
18
- const DRAFT_LABEL_Y = WALL_HEIGHT + 0.22
19
- const DRAFT_ANGLE_LABEL_Y = 0.28
20
-
21
- type DraftAngleLabel = {
22
- id: string
23
- label: string
24
- position: [number, number, number]
25
- }
26
-
27
- type DraftMeasurementState = {
28
- lengthLabel: string
29
- lengthPosition: [number, number, number]
30
- angleLabels: DraftAngleLabel[]
31
- } | null
32
-
33
- function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
34
- if (unit === 'imperial') {
35
- const feet = value * 3.280_84
36
- const wholeFeet = Math.floor(feet)
37
- const inches = Math.round((feet - wholeFeet) * 12)
38
- if (inches === 12) return `${wholeFeet + 1}'0"`
39
- return `${wholeFeet}'${inches}"`
40
- }
41
-
42
- return `${Number.parseFloat(value.toFixed(2))}m`
43
- }
44
-
45
- function getDraftAngleLabels(
46
- start: WallPlanPoint,
47
- end: WallPlanPoint,
48
- walls: WallNode[],
49
- ): DraftAngleLabel[] {
50
- const draftFromStart: WallPlanPoint = [end[0] - start[0], end[1] - start[1]]
51
- const draftFromEnd: WallPlanPoint = [start[0] - end[0], start[1] - end[1]]
52
- const endpoints = [
53
- { id: 'start', point: start, draftVector: draftFromStart },
54
- { id: 'end', point: end, draftVector: draftFromEnd },
55
- ]
56
- const labels: DraftAngleLabel[] = []
57
-
58
- for (const endpoint of endpoints) {
59
- const connectedWall = walls.find((wall) =>
60
- Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, wall)),
61
- )
62
- if (!connectedWall) continue
63
-
64
- const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedWall)
65
- if (!connectedReference) continue
66
-
67
- const angle = getAngleToSegmentReference(endpoint.draftVector, connectedReference)
68
- if (angle === null) continue
69
-
70
- labels.push({
71
- id: endpoint.id,
72
- label: formatAngleRadians(angle),
73
- position: [endpoint.point[0], DRAFT_ANGLE_LABEL_Y, endpoint.point[1]],
74
- })
75
- }
76
-
77
- return labels
78
- }
79
-
80
- function getDraftMeasurementState(
81
- start: WallPlanPoint,
82
- end: WallPlanPoint,
83
- walls: WallNode[],
84
- unit: 'metric' | 'imperial',
85
- ): DraftMeasurementState {
86
- const dx = end[0] - start[0]
87
- const dz = end[1] - start[1]
88
- const length = Math.hypot(dx, dz)
89
-
90
- if (length < 0.01) return null
91
-
92
- return {
93
- lengthLabel: formatMeasurement(length, unit),
94
- lengthPosition: [(start[0] + end[0]) / 2, DRAFT_LABEL_Y, (start[1] + end[1]) / 2],
95
- angleLabels: getDraftAngleLabels(start, end, walls),
96
- }
97
- }
98
-
99
- /**
100
- * Update wall preview mesh geometry to create a vertical plane between two points
101
- */
102
- const updateWallPreview = (mesh: Mesh, start: Vector3, end: Vector3) => {
103
- // Calculate direction and perpendicular for wall thickness
104
- const direction = new Vector3(end.x - start.x, 0, end.z - start.z)
105
- const length = direction.length()
106
-
107
- if (length < 0.01) {
108
- mesh.visible = false
109
- return
110
- }
111
-
112
- mesh.visible = true
113
- direction.normalize()
114
-
115
- // Create wall shape (vertical rectangle in XY plane)
116
- const shape = new Shape()
117
- shape.moveTo(0, 0)
118
- shape.lineTo(length, 0)
119
- shape.lineTo(length, WALL_HEIGHT)
120
- shape.lineTo(0, WALL_HEIGHT)
121
- shape.closePath()
122
-
123
- // Create geometry
124
- const geometry = new ShapeGeometry(shape)
125
-
126
- // Calculate rotation angle
127
- // Negate the angle to fix the opposite direction issue
128
- const angle = -Math.atan2(direction.z, direction.x)
129
-
130
- // Position at start point and rotate
131
- mesh.position.set(start.x, start.y, start.z)
132
- mesh.rotation.y = angle
133
-
134
- // Dispose old geometry and assign new one
135
- if (mesh.geometry) {
136
- mesh.geometry.dispose()
137
- }
138
- mesh.geometry = geometry
139
- }
140
-
141
- const getCurrentLevelWalls = (): WallNode[] => {
142
- const currentLevelId = useViewer.getState().selection.levelId
143
- const { nodes } = useScene.getState()
144
-
145
- if (!currentLevelId) return []
146
-
147
- const levelNode = nodes[currentLevelId]
148
- if (!levelNode || levelNode.type !== 'level') return []
149
-
150
- return (levelNode as LevelNode).children
151
- .map((childId) => nodes[childId])
152
- .filter((node): node is WallNode => node?.type === 'wall')
153
- }
154
-
155
- export const WallTool: React.FC = () => {
156
- const unit = useViewer((state) => state.unit)
157
- const cursorRef = useRef<Group>(null)
158
- const wallPreviewRef = useRef<Mesh>(null!)
159
- // All positions are building-local: this tool is inside the ToolManager building group,
160
- // so local coords are used for both data and visual positioning.
161
- const startingPoint = useRef(new Vector3(0, 0, 0))
162
- const endingPoint = useRef(new Vector3(0, 0, 0))
163
- const buildingState = useRef(0)
164
- const shiftPressed = useRef(false)
165
- const [draftMeasurement, setDraftMeasurement] = useState<DraftMeasurementState>(null)
166
-
167
- useEffect(() => {
168
- let gridPosition: WallPlanPoint = [0, 0]
169
- let previousWallEnd: [number, number] | null = null
170
-
171
- const onGridMove = (event: GridEvent) => {
172
- if (!(cursorRef.current && wallPreviewRef.current)) return
173
-
174
- const walls = getCurrentLevelWalls()
175
- // event.localPosition is building-local — consistent with stored wall start/end
176
- const localPoint: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
177
- gridPosition = snapWallDraftPoint({ point: localPoint, walls })
178
-
179
- if (buildingState.current === 1) {
180
- const snappedLocal = snapWallDraftPoint({
181
- point: localPoint,
182
- walls,
183
- start: [startingPoint.current.x, startingPoint.current.z],
184
- angleSnap: !shiftPressed.current,
185
- })
186
- endingPoint.current.set(snappedLocal[0], event.localPosition[1], snappedLocal[1])
187
- cursorRef.current.position.copy(endingPoint.current)
188
-
189
- // Play snap sound only when the actual wall end position changes
190
- const currentWallEnd: [number, number] = [snappedLocal[0], snappedLocal[1]]
191
- if (
192
- previousWallEnd &&
193
- (currentWallEnd[0] !== previousWallEnd[0] || currentWallEnd[1] !== previousWallEnd[1])
194
- ) {
195
- sfxEmitter.emit('sfx:grid-snap')
196
- }
197
- previousWallEnd = currentWallEnd
198
-
199
- updateWallPreview(wallPreviewRef.current, startingPoint.current, endingPoint.current)
200
- setDraftMeasurement(
201
- getDraftMeasurementState(
202
- [startingPoint.current.x, startingPoint.current.z],
203
- snappedLocal,
204
- walls,
205
- unit,
206
- ),
207
- )
208
- } else {
209
- // Not drawing a wall yet, show the snapped anchor point.
210
- cursorRef.current.position.set(gridPosition[0], event.localPosition[1], gridPosition[1])
211
- setDraftMeasurement(null)
212
- }
213
- }
214
-
215
- const onGridClick = (event: GridEvent) => {
216
- const walls = getCurrentLevelWalls()
217
- const localClick: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
218
-
219
- if (buildingState.current === 0) {
220
- const snappedStart = snapWallDraftPoint({ point: localClick, walls })
221
- gridPosition = snappedStart
222
- startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1])
223
- endingPoint.current.copy(startingPoint.current)
224
- buildingState.current = 1
225
- wallPreviewRef.current.visible = true
226
- setDraftMeasurement(null)
227
- } else if (buildingState.current === 1) {
228
- const snappedEnd = snapWallDraftPoint({
229
- point: localClick,
230
- walls,
231
- start: [startingPoint.current.x, startingPoint.current.z],
232
- angleSnap: !shiftPressed.current,
233
- })
234
- const dx = snappedEnd[0] - startingPoint.current.x
235
- const dz = snappedEnd[1] - startingPoint.current.z
236
- if (dx * dx + dz * dz < 0.01 * 0.01) return
237
- // Both start and end are building-local ✓
238
- createWallOnCurrentLevel([startingPoint.current.x, startingPoint.current.z], snappedEnd)
239
- wallPreviewRef.current.visible = false
240
- buildingState.current = 0
241
- setDraftMeasurement(null)
242
- }
243
- }
244
-
245
- const onKeyDown = (e: KeyboardEvent) => {
246
- if (e.key === 'Shift') {
247
- shiftPressed.current = true
248
- }
249
- }
250
-
251
- const onKeyUp = (e: KeyboardEvent) => {
252
- if (e.key === 'Shift') {
253
- shiftPressed.current = false
254
- }
255
- }
256
-
257
- const onCancel = () => {
258
- if (buildingState.current === 1) {
259
- markToolCancelConsumed()
260
- buildingState.current = 0
261
- wallPreviewRef.current.visible = false
262
- setDraftMeasurement(null)
263
- }
264
- }
265
-
266
- emitter.on('grid:move', onGridMove)
267
- emitter.on('grid:click', onGridClick)
268
- emitter.on('tool:cancel', onCancel)
269
- window.addEventListener('keydown', onKeyDown)
270
- window.addEventListener('keyup', onKeyUp)
271
-
272
- return () => {
273
- emitter.off('grid:move', onGridMove)
274
- emitter.off('grid:click', onGridClick)
275
- emitter.off('tool:cancel', onCancel)
276
- window.removeEventListener('keydown', onKeyDown)
277
- window.removeEventListener('keyup', onKeyUp)
278
- }
279
- }, [unit])
280
-
281
- return (
282
- <group>
283
- {/* Cursor indicator */}
284
- <CursorSphere ref={cursorRef} />
285
-
286
- {/* Wall preview */}
287
- <mesh layers={EDITOR_LAYER} ref={wallPreviewRef} renderOrder={1} visible={false}>
288
- <shapeGeometry />
289
- <meshBasicMaterial
290
- color="#818cf8"
291
- depthTest={false}
292
- depthWrite={false}
293
- opacity={0.5}
294
- side={DoubleSide}
295
- transparent
296
- />
297
- </mesh>
298
-
299
- {draftMeasurement && (
300
- <>
301
- <DraftMeasurementLabel
302
- label={draftMeasurement.lengthLabel}
303
- position={draftMeasurement.lengthPosition}
304
- />
305
- {draftMeasurement.angleLabels.map((angleLabel) => (
306
- <DraftMeasurementLabel
307
- key={angleLabel.id}
308
- label={angleLabel.label}
309
- position={angleLabel.position}
310
- />
311
- ))}
312
- </>
313
- )}
314
- </group>
315
- )
316
- }
317
-
318
- function DraftMeasurementLabel({
319
- label,
320
- position,
321
- }: {
322
- label: string
323
- position: [number, number, number]
324
- }) {
325
- return (
326
- <Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
327
- <div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono font-semibold text-[11px] text-foreground shadow-lg backdrop-blur-md">
328
- {label}
329
- </div>
330
- </Html>
331
- )
332
- }