@pascal-app/editor 0.5.1 → 0.7.0

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 (150) hide show
  1. package/package.json +12 -7
  2. package/src/components/editor/bottom-sheet.tsx +149 -0
  3. package/src/components/editor/custom-camera-controls.tsx +75 -7
  4. package/src/components/editor/editor-layout-mobile.tsx +264 -0
  5. package/src/components/editor/editor-layout-v2.tsx +29 -0
  6. package/src/components/editor/first-person/build-collider-world.ts +365 -0
  7. package/src/components/editor/first-person/bvh-ecctrl.tsx +795 -0
  8. package/src/components/editor/first-person-controls.tsx +496 -143
  9. package/src/components/editor/floating-action-menu.tsx +281 -83
  10. package/src/components/editor/floating-building-action-menu.tsx +4 -3
  11. package/src/components/editor/floorplan-background-selection.ts +113 -0
  12. package/src/components/editor/floorplan-panel.tsx +10442 -3275
  13. package/src/components/editor/index.tsx +270 -20
  14. package/src/components/editor/node-action-menu.tsx +14 -1
  15. package/src/components/editor/selection-manager.tsx +766 -12
  16. package/src/components/editor/site-edge-labels.tsx +9 -3
  17. package/src/components/editor/thumbnail-generator.tsx +350 -157
  18. package/src/components/editor/use-floorplan-background-placement.ts +257 -0
  19. package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
  20. package/src/components/editor/use-floorplan-scene-data.ts +189 -0
  21. package/src/components/editor/wall-measurement-label.tsx +377 -58
  22. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
  23. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
  24. package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
  25. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +119 -0
  26. package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
  27. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +197 -0
  28. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
  29. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
  30. package/src/components/editor-2d/svg-paths.ts +119 -0
  31. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +272 -0
  32. package/src/components/systems/roof/roof-edit-system.tsx +5 -5
  33. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
  34. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +2 -0
  35. package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
  36. package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
  37. package/src/components/tools/column/column-tool.tsx +97 -0
  38. package/src/components/tools/column/move-column-tool.tsx +105 -0
  39. package/src/components/tools/door/door-tool.tsx +19 -0
  40. package/src/components/tools/door/move-door-tool.tsx +38 -8
  41. package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
  42. package/src/components/tools/fence/fence-drafting.ts +27 -8
  43. package/src/components/tools/fence/fence-tool.tsx +159 -3
  44. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +438 -0
  45. package/src/components/tools/fence/move-fence-tool.tsx +102 -27
  46. package/src/components/tools/item/move-tool.tsx +19 -1
  47. package/src/components/tools/item/placement-math.ts +44 -7
  48. package/src/components/tools/item/placement-strategies.ts +111 -33
  49. package/src/components/tools/item/placement-types.ts +7 -0
  50. package/src/components/tools/item/use-draft-node.ts +2 -0
  51. package/src/components/tools/item/use-placement-coordinator.tsx +701 -61
  52. package/src/components/tools/roof/move-roof-tool.tsx +111 -43
  53. package/src/components/tools/shared/polygon-editor.tsx +244 -29
  54. package/src/components/tools/shared/segment-angle.ts +156 -0
  55. package/src/components/tools/slab/move-slab-tool.tsx +182 -0
  56. package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
  57. package/src/components/tools/slab/slab-hole-editor.tsx +2 -0
  58. package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
  59. package/src/components/tools/spawn/spawn-tool.tsx +130 -0
  60. package/src/components/tools/stair/stair-tool.tsx +11 -3
  61. package/src/components/tools/tool-manager.tsx +30 -3
  62. package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
  63. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +423 -0
  64. package/src/components/tools/wall/move-wall-tool.tsx +356 -0
  65. package/src/components/tools/wall/wall-drafting.ts +348 -17
  66. package/src/components/tools/wall/wall-tool.tsx +134 -2
  67. package/src/components/tools/window/move-window-tool.tsx +28 -0
  68. package/src/components/tools/window/window-tool.tsx +17 -0
  69. package/src/components/ui/action-menu/camera-actions.tsx +37 -33
  70. package/src/components/ui/action-menu/control-modes.tsx +37 -5
  71. package/src/components/ui/action-menu/index.tsx +91 -1
  72. package/src/components/ui/action-menu/structure-tools.tsx +2 -0
  73. package/src/components/ui/action-menu/view-toggles.tsx +424 -35
  74. package/src/components/ui/command-palette/editor-commands.tsx +27 -5
  75. package/src/components/ui/command-palette/index.tsx +0 -1
  76. package/src/components/ui/controls/material-picker.tsx +189 -169
  77. package/src/components/ui/controls/slider-control.tsx +88 -26
  78. package/src/components/ui/floating-level-selector.tsx +286 -55
  79. package/src/components/ui/helpers/helper-manager.tsx +5 -0
  80. package/src/components/ui/item-catalog/catalog-items.tsx +1121 -1219
  81. package/src/components/ui/item-catalog/item-catalog.tsx +42 -175
  82. package/src/components/ui/level-duplicate-dialog.tsx +115 -0
  83. package/src/components/ui/panels/ceiling-panel.tsx +47 -27
  84. package/src/components/ui/panels/column-panel.tsx +715 -0
  85. package/src/components/ui/panels/door-panel.tsx +986 -294
  86. package/src/components/ui/panels/fence-panel.tsx +55 -12
  87. package/src/components/ui/panels/item-panel.tsx +5 -5
  88. package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
  89. package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
  90. package/src/components/ui/panels/node-display.ts +39 -0
  91. package/src/components/ui/panels/paint-panel.tsx +138 -0
  92. package/src/components/ui/panels/panel-manager.tsx +241 -30
  93. package/src/components/ui/panels/panel-wrapper.tsx +48 -39
  94. package/src/components/ui/panels/reference-panel.tsx +243 -9
  95. package/src/components/ui/panels/roof-panel.tsx +30 -62
  96. package/src/components/ui/panels/roof-segment-panel.tsx +8 -23
  97. package/src/components/ui/panels/slab-panel.tsx +46 -24
  98. package/src/components/ui/panels/spawn-panel.tsx +155 -0
  99. package/src/components/ui/panels/stair-panel.tsx +117 -69
  100. package/src/components/ui/panels/stair-segment-panel.tsx +13 -27
  101. package/src/components/ui/panels/wall-panel.tsx +71 -17
  102. package/src/components/ui/panels/window-panel.tsx +665 -146
  103. package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
  104. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  105. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +9 -5
  106. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +7 -3
  107. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
  108. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -3
  109. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +7 -3
  110. package/src/components/ui/sidebar/panels/site-panel/index.tsx +138 -56
  111. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +7 -3
  112. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +9 -5
  113. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  114. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +7 -3
  115. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +82 -0
  116. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +7 -3
  117. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +3 -3
  118. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +12 -6
  119. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +7 -3
  120. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -3
  121. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +15 -8
  122. package/src/components/ui/sidebar/tab-bar.tsx +3 -0
  123. package/src/components/ui/viewer-toolbar.tsx +96 -2
  124. package/src/components/viewer-overlay.tsx +25 -19
  125. package/src/hooks/use-auto-frame.ts +45 -0
  126. package/src/hooks/use-contextual-tools.ts +14 -13
  127. package/src/hooks/use-keyboard.ts +67 -9
  128. package/src/hooks/use-mobile.ts +12 -12
  129. package/src/index.tsx +2 -1
  130. package/src/lib/door-interaction.ts +88 -0
  131. package/src/lib/floorplan/geometry.ts +263 -0
  132. package/src/lib/floorplan/index.ts +38 -0
  133. package/src/lib/floorplan/items.ts +179 -0
  134. package/src/lib/floorplan/selection-tool.ts +231 -0
  135. package/src/lib/floorplan/stairs.ts +478 -0
  136. package/src/lib/floorplan/types.ts +57 -0
  137. package/src/lib/floorplan/walls.ts +23 -0
  138. package/src/lib/guide-events.ts +10 -0
  139. package/src/lib/history.ts +20 -0
  140. package/src/lib/level-duplication.test.ts +72 -0
  141. package/src/lib/level-duplication.ts +153 -0
  142. package/src/lib/local-guide-image.ts +42 -0
  143. package/src/lib/material-paint.ts +284 -0
  144. package/src/lib/roof-duplication.ts +214 -0
  145. package/src/lib/scene-bounds.test.ts +183 -0
  146. package/src/lib/scene-bounds.ts +169 -0
  147. package/src/lib/sfx-player.ts +96 -13
  148. package/src/lib/stair-duplication.ts +126 -0
  149. package/src/lib/window-interaction.ts +86 -0
  150. package/src/store/use-editor.tsx +279 -15
@@ -0,0 +1,176 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type AnyNodeId,
5
+ emitter,
6
+ type GridEvent,
7
+ getClampedWallCurveOffset,
8
+ getMaxWallCurveOffset,
9
+ getWallChordFrame,
10
+ getWallMidpointHandlePoint,
11
+ normalizeWallCurveOffset,
12
+ useScene,
13
+ type WallNode,
14
+ } from '@pascal-app/core'
15
+ import { useViewer } from '@pascal-app/viewer'
16
+ import { useCallback, useEffect, useRef, useState } from 'react'
17
+ import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
18
+ import { sfxEmitter } from '../../../lib/sfx-bus'
19
+ import useEditor from '../../../store/use-editor'
20
+ import { CursorSphere } from '../shared/cursor-sphere'
21
+ import { getWallGridStep, snapScalarToGrid } from './wall-drafting'
22
+
23
+ export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
24
+ const activatedAtRef = useRef<number>(Date.now())
25
+ const originalCurveOffsetRef = useRef(getClampedWallCurveOffset(node))
26
+ const previousCurveOffsetRef = useRef<number | null>(null)
27
+ const shiftPressedRef = useRef(false)
28
+ const previewOffsetRef = useRef<number>(originalCurveOffsetRef.current)
29
+
30
+ const initialHandle = getWallMidpointHandlePoint(node)
31
+ const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>([
32
+ initialHandle.x,
33
+ 0,
34
+ initialHandle.y,
35
+ ])
36
+
37
+ const exitCurveMode = useCallback(() => {
38
+ useEditor.getState().setCurvingWall(null)
39
+ }, [])
40
+
41
+ useEffect(() => {
42
+ const nodeId = node.id
43
+ const originalCurveOffset = originalCurveOffsetRef.current
44
+ const chord = getWallChordFrame(node)
45
+ const maxCurveOffset = getMaxWallCurveOffset(node)
46
+
47
+ useScene.temporal.getState().pause()
48
+ let wasCommitted = false
49
+
50
+ const applyPreview = (curveOffset: number) => {
51
+ if (previewOffsetRef.current === curveOffset) {
52
+ return
53
+ }
54
+ previewOffsetRef.current = curveOffset
55
+
56
+ const nextNode = {
57
+ ...node,
58
+ curveOffset,
59
+ }
60
+ const handlePoint = getWallMidpointHandlePoint(nextNode)
61
+ setCursorLocalPos([handlePoint.x, 0, handlePoint.y])
62
+ useScene.getState().updateNode(nodeId, { curveOffset })
63
+ useScene.getState().markDirty(nodeId as AnyNodeId)
64
+ }
65
+
66
+ const restoreOriginal = () => {
67
+ if (previewOffsetRef.current === originalCurveOffset) {
68
+ return
69
+ }
70
+ previewOffsetRef.current = originalCurveOffset
71
+ useScene.getState().updateNode(nodeId, { curveOffset: originalCurveOffset })
72
+ useScene.getState().markDirty(nodeId as AnyNodeId)
73
+ }
74
+
75
+ const onGridMove = (event: GridEvent) => {
76
+ const snapStep = getWallGridStep()
77
+ const localX = shiftPressedRef.current
78
+ ? event.localPosition[0]
79
+ : snapScalarToGrid(event.localPosition[0], snapStep)
80
+ const localZ = shiftPressedRef.current
81
+ ? event.localPosition[2]
82
+ : snapScalarToGrid(event.localPosition[2], snapStep)
83
+
84
+ const offsetFromMidpoint =
85
+ -(
86
+ (localX - chord.midpoint.x) * chord.normal.x +
87
+ (localZ - chord.midpoint.y) * chord.normal.y
88
+ )
89
+ const snappedOffset = shiftPressedRef.current
90
+ ? offsetFromMidpoint
91
+ : snapScalarToGrid(offsetFromMidpoint, snapStep)
92
+ const nextCurveOffset = normalizeWallCurveOffset(node, Math.max(-maxCurveOffset, Math.min(maxCurveOffset, snappedOffset)))
93
+
94
+ if (
95
+ previousCurveOffsetRef.current !== null &&
96
+ nextCurveOffset !== previousCurveOffsetRef.current
97
+ ) {
98
+ sfxEmitter.emit('sfx:grid-snap')
99
+ }
100
+ previousCurveOffsetRef.current = nextCurveOffset
101
+
102
+ applyPreview(nextCurveOffset)
103
+ }
104
+
105
+ const onGridClick = (event: GridEvent) => {
106
+ if (Date.now() - activatedAtRef.current < 150) {
107
+ event.nativeEvent?.stopPropagation?.()
108
+ return
109
+ }
110
+
111
+ const curveOffset = previewOffsetRef.current
112
+ wasCommitted = true
113
+
114
+ if (curveOffset !== originalCurveOffset) {
115
+ // Restore original baseline while paused so the next resume+update
116
+ // registers as a single tracked change (undo reverts to original).
117
+ useScene.getState().updateNode(nodeId, { curveOffset: originalCurveOffset })
118
+ useScene.getState().markDirty(nodeId as AnyNodeId)
119
+
120
+ useScene.temporal.getState().resume()
121
+ useScene.getState().updateNode(nodeId, { curveOffset })
122
+ useScene.getState().markDirty(nodeId as AnyNodeId)
123
+ useScene.temporal.getState().pause()
124
+ }
125
+
126
+ sfxEmitter.emit('sfx:item-place')
127
+ useViewer.getState().setSelection({ selectedIds: [nodeId] })
128
+ exitCurveMode()
129
+ event.nativeEvent?.stopPropagation?.()
130
+ }
131
+
132
+ const onCancel = () => {
133
+ restoreOriginal()
134
+ useViewer.getState().setSelection({ selectedIds: [nodeId] })
135
+ useScene.temporal.getState().resume()
136
+ markToolCancelConsumed()
137
+ exitCurveMode()
138
+ }
139
+
140
+ const onKeyDown = (event: KeyboardEvent) => {
141
+ if (event.key === 'Shift') {
142
+ shiftPressedRef.current = true
143
+ }
144
+ }
145
+
146
+ const onKeyUp = (event: KeyboardEvent) => {
147
+ if (event.key === 'Shift') {
148
+ shiftPressedRef.current = false
149
+ }
150
+ }
151
+
152
+ emitter.on('grid:move', onGridMove)
153
+ emitter.on('grid:click', onGridClick)
154
+ emitter.on('tool:cancel', onCancel)
155
+ window.addEventListener('keydown', onKeyDown)
156
+ window.addEventListener('keyup', onKeyUp)
157
+
158
+ return () => {
159
+ if (!wasCommitted) {
160
+ restoreOriginal()
161
+ }
162
+ useScene.temporal.getState().resume()
163
+ emitter.off('grid:move', onGridMove)
164
+ emitter.off('grid:click', onGridClick)
165
+ emitter.off('tool:cancel', onCancel)
166
+ window.removeEventListener('keydown', onKeyDown)
167
+ window.removeEventListener('keyup', onKeyUp)
168
+ }
169
+ }, [exitCurveMode, node])
170
+
171
+ return (
172
+ <group>
173
+ <CursorSphere position={cursorLocalPos} showTooltip={false} />
174
+ </group>
175
+ )
176
+ }
@@ -0,0 +1,423 @@
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 { Html } from '@react-three/drei'
14
+ import { useCallback, useEffect, useRef, useState } from 'react'
15
+ import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
16
+ import { sfxEmitter } from '../../../lib/sfx-bus'
17
+ import useEditor, { type MovingWallEndpoint } from '../../../store/use-editor'
18
+ import { CursorSphere } from '../shared/cursor-sphere'
19
+ import {
20
+ formatAngleRadians,
21
+ getAngleToSegmentReference,
22
+ getSegmentAngleReferenceAtPoint,
23
+ } from '../shared/segment-angle'
24
+ import { isWallLongEnough, snapWallDraftPoint, type WallPlanPoint } from './wall-drafting'
25
+
26
+ function samePoint(a: WallPlanPoint, b: WallPlanPoint) {
27
+ return a[0] === b[0] && a[1] === b[1]
28
+ }
29
+
30
+ type WallSegmentLike = {
31
+ id: WallNode['id']
32
+ start: WallPlanPoint
33
+ end: WallPlanPoint
34
+ curveOffset?: number
35
+ }
36
+
37
+ type AngleLabelState = {
38
+ label: string
39
+ position: [number, number, number]
40
+ } | null
41
+
42
+ function getEndpointAngleLabel(args: {
43
+ preview: { start: WallPlanPoint; end: WallPlanPoint; curveOffset?: number }
44
+ walls: WallSegmentLike[]
45
+ nodeId: WallNode['id']
46
+ }): AngleLabelState {
47
+ const { preview, walls, nodeId } = args
48
+ const endpoints = [
49
+ {
50
+ point: preview.start,
51
+ },
52
+ {
53
+ point: preview.end,
54
+ },
55
+ ]
56
+ const targetSegment: WallSegmentLike = {
57
+ id: nodeId,
58
+ start: preview.start,
59
+ end: preview.end,
60
+ curveOffset: preview.curveOffset,
61
+ }
62
+
63
+ for (const endpoint of endpoints) {
64
+ const targetReference = getSegmentAngleReferenceAtPoint(endpoint.point, targetSegment)
65
+ if (!targetReference) continue
66
+
67
+ const connectedWall = walls.find(
68
+ (wall) =>
69
+ wall.id !== nodeId && Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, wall)),
70
+ )
71
+ if (!connectedWall) continue
72
+
73
+ const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedWall)
74
+ if (!connectedReference) continue
75
+
76
+ const angle = getAngleToSegmentReference(targetReference.vector, connectedReference)
77
+ if (angle === null) continue
78
+
79
+ return {
80
+ label: formatAngleRadians(angle),
81
+ position: [endpoint.point[0], 0.34, endpoint.point[1]],
82
+ }
83
+ }
84
+
85
+ return null
86
+ }
87
+
88
+ type LinkedWallSnapshot = {
89
+ id: WallNode['id']
90
+ start: WallPlanPoint
91
+ end: WallPlanPoint
92
+ curveOffset?: number
93
+ }
94
+
95
+ function getLinkedWallSnapshots(args: {
96
+ wallId: WallNode['id']
97
+ wallParentId: string | null
98
+ originalStart: WallPlanPoint
99
+ originalEnd: WallPlanPoint
100
+ }) {
101
+ const { wallId, wallParentId, originalStart, originalEnd } = args
102
+ const { nodes } = useScene.getState()
103
+ const snapshots: LinkedWallSnapshot[] = []
104
+
105
+ for (const node of Object.values(nodes)) {
106
+ if (!(node?.type === 'wall' && node.id !== wallId)) {
107
+ continue
108
+ }
109
+
110
+ if ((node.parentId ?? null) !== wallParentId) {
111
+ continue
112
+ }
113
+
114
+ if (
115
+ !samePoint(node.start, originalStart) &&
116
+ !samePoint(node.start, originalEnd) &&
117
+ !samePoint(node.end, originalStart) &&
118
+ !samePoint(node.end, originalEnd)
119
+ ) {
120
+ continue
121
+ }
122
+
123
+ snapshots.push({
124
+ id: node.id,
125
+ start: [...node.start] as WallPlanPoint,
126
+ end: [...node.end] as WallPlanPoint,
127
+ curveOffset: node.curveOffset,
128
+ })
129
+ }
130
+
131
+ return snapshots
132
+ }
133
+
134
+ function getLinkedWallUpdates(
135
+ linkedWalls: LinkedWallSnapshot[],
136
+ originalStart: WallPlanPoint,
137
+ originalEnd: WallPlanPoint,
138
+ nextStart: WallPlanPoint,
139
+ nextEnd: WallPlanPoint,
140
+ ) {
141
+ return linkedWalls.map((wall) => ({
142
+ id: wall.id,
143
+ curveOffset: wall.curveOffset,
144
+ start: samePoint(wall.start, originalStart)
145
+ ? nextStart
146
+ : samePoint(wall.start, originalEnd)
147
+ ? nextEnd
148
+ : wall.start,
149
+ end: samePoint(wall.end, originalStart)
150
+ ? nextStart
151
+ : samePoint(wall.end, originalEnd)
152
+ ? nextEnd
153
+ : wall.end,
154
+ }))
155
+ }
156
+
157
+ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ target }) => {
158
+ const activatedAtRef = useRef<number>(Date.now())
159
+ const previousGridPosRef = useRef<WallPlanPoint | null>(null)
160
+ const shiftPressedRef = useRef(false)
161
+ const altPressedRef = useRef(false)
162
+ const nodeIdRef = useRef(target.wall.id)
163
+ const originalStartRef = useRef<WallPlanPoint>([...target.wall.start] as WallPlanPoint)
164
+ const originalEndRef = useRef<WallPlanPoint>([...target.wall.end] as WallPlanPoint)
165
+ const fixedPointRef = useRef<WallPlanPoint>(
166
+ target.endpoint === 'start'
167
+ ? ([...target.wall.end] as WallPlanPoint)
168
+ : ([...target.wall.start] as WallPlanPoint),
169
+ )
170
+ const linkedOriginalsRef = useRef(
171
+ getLinkedWallSnapshots({
172
+ wallId: target.wall.id,
173
+ wallParentId: target.wall.parentId ?? null,
174
+ originalStart: target.wall.start,
175
+ originalEnd: target.wall.end,
176
+ }),
177
+ )
178
+ const previewRef = useRef<{ start: WallPlanPoint; end: WallPlanPoint } | null>(null)
179
+ const [angleLabel, setAngleLabel] = useState<AngleLabelState>(null)
180
+
181
+ const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
182
+ const point = target.endpoint === 'start' ? target.wall.start : target.wall.end
183
+ return [point[0], 0, point[1]]
184
+ })
185
+ const [altPressed, setAltPressed] = useState(false)
186
+
187
+ const exitMoveMode = useCallback(() => {
188
+ useEditor.getState().setMovingWallEndpoint(null)
189
+ }, [])
190
+
191
+ useEffect(() => {
192
+ const nodeId = nodeIdRef.current
193
+ const originalStart = originalStartRef.current
194
+ const originalEnd = originalEndRef.current
195
+ const fixedPoint = fixedPointRef.current
196
+ const levelWalls = Object.values(useScene.getState().nodes).filter(
197
+ (node): node is WallNode =>
198
+ node?.type === 'wall' && (node.parentId ?? null) === (target.wall.parentId ?? null),
199
+ )
200
+
201
+ pauseSceneHistory(useScene)
202
+ let wasCommitted = false
203
+
204
+ const applyNodePreview = (
205
+ updates: Array<{ id: WallNode['id']; start: WallPlanPoint; end: WallPlanPoint }>,
206
+ ) => {
207
+ useScene.getState().updateNodes(
208
+ updates.map((entry) => ({
209
+ id: entry.id as AnyNodeId,
210
+ data: { start: entry.start, end: entry.end },
211
+ })),
212
+ )
213
+ for (const entry of updates) {
214
+ useScene.getState().markDirty(entry.id as AnyNodeId)
215
+ }
216
+ }
217
+
218
+ const applyPreview = (movingPoint: WallPlanPoint, detachLinkedWalls = false) => {
219
+ const nextStart = target.endpoint === 'start' ? movingPoint : fixedPoint
220
+ const nextEnd = target.endpoint === 'end' ? movingPoint : fixedPoint
221
+ const linkedUpdates = detachLinkedWalls
222
+ ? []
223
+ : getLinkedWallUpdates(
224
+ linkedOriginalsRef.current,
225
+ originalStart,
226
+ originalEnd,
227
+ nextStart,
228
+ nextEnd,
229
+ )
230
+ previewRef.current = { start: nextStart, end: nextEnd }
231
+ setCursorLocalPos([movingPoint[0], 0, movingPoint[1]])
232
+ setAngleLabel(
233
+ getEndpointAngleLabel({
234
+ preview: { start: nextStart, end: nextEnd, curveOffset: target.wall.curveOffset },
235
+ walls: [
236
+ ...levelWalls.map((wall) => ({
237
+ id: wall.id,
238
+ start: wall.start,
239
+ end: wall.end,
240
+ curveOffset: wall.curveOffset,
241
+ })),
242
+ ...linkedUpdates,
243
+ ],
244
+ nodeId,
245
+ }),
246
+ )
247
+ applyNodePreview([{ id: nodeId, start: nextStart, end: nextEnd }, ...linkedUpdates])
248
+ }
249
+
250
+ const restoreOriginal = (clearAngleLabel = true) => {
251
+ applyNodePreview([
252
+ { id: nodeId, start: originalStart, end: originalEnd },
253
+ ...linkedOriginalsRef.current,
254
+ ])
255
+ if (clearAngleLabel) {
256
+ setAngleLabel(null)
257
+ }
258
+ }
259
+
260
+ const onGridMove = (event: GridEvent) => {
261
+ const planPoint: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
262
+ const snappedPoint = snapWallDraftPoint({
263
+ point: planPoint,
264
+ walls: levelWalls,
265
+ start: fixedPoint,
266
+ angleSnap: !shiftPressedRef.current,
267
+ ignoreWallIds: [nodeId],
268
+ })
269
+
270
+ if (
271
+ previousGridPosRef.current &&
272
+ (snappedPoint[0] !== previousGridPosRef.current[0] ||
273
+ snappedPoint[1] !== previousGridPosRef.current[1])
274
+ ) {
275
+ sfxEmitter.emit('sfx:grid-snap')
276
+ }
277
+ previousGridPosRef.current = snappedPoint
278
+
279
+ applyPreview(snappedPoint, event.nativeEvent.altKey)
280
+ }
281
+
282
+ const onGridClick = (event: GridEvent) => {
283
+ if (Date.now() - activatedAtRef.current < 150) {
284
+ event.nativeEvent?.stopPropagation?.()
285
+ return
286
+ }
287
+
288
+ const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
289
+ const hasChanged =
290
+ !samePoint(preview.start, originalStart) || !samePoint(preview.end, originalEnd)
291
+
292
+ if (hasChanged && isWallLongEnough(preview.start, preview.end)) {
293
+ wasCommitted = true
294
+
295
+ // Restore original baseline while paused so the next resume+update
296
+ // registers as a single tracked change (undo reverts to original).
297
+ applyNodePreview([
298
+ { id: nodeId, start: originalStart, end: originalEnd },
299
+ ...linkedOriginalsRef.current,
300
+ ])
301
+
302
+ resumeSceneHistory(useScene)
303
+ applyNodePreview([
304
+ { id: nodeId, start: preview.start, end: preview.end },
305
+ ...(altPressedRef.current
306
+ ? []
307
+ : getLinkedWallUpdates(
308
+ linkedOriginalsRef.current,
309
+ originalStart,
310
+ originalEnd,
311
+ preview.start,
312
+ preview.end,
313
+ )),
314
+ ])
315
+ pauseSceneHistory(useScene)
316
+ sfxEmitter.emit('sfx:item-place')
317
+ }
318
+
319
+ useViewer.getState().setSelection({ selectedIds: [nodeId] })
320
+ setAngleLabel(null)
321
+ exitMoveMode()
322
+ event.nativeEvent?.stopPropagation?.()
323
+ }
324
+
325
+ const onCancel = () => {
326
+ restoreOriginal()
327
+ useViewer.getState().setSelection({ selectedIds: [nodeId] })
328
+ resumeSceneHistory(useScene)
329
+ setAngleLabel(null)
330
+ markToolCancelConsumed()
331
+ exitMoveMode()
332
+ }
333
+
334
+ const onKeyDown = (event: KeyboardEvent) => {
335
+ if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
336
+ return
337
+ }
338
+ if (event.key === 'Shift') {
339
+ shiftPressedRef.current = true
340
+ }
341
+ if (event.key === 'Alt') {
342
+ altPressedRef.current = true
343
+ setAltPressed(true)
344
+ }
345
+ }
346
+
347
+ const onKeyUp = (event: KeyboardEvent) => {
348
+ if (event.key === 'Shift') {
349
+ shiftPressedRef.current = false
350
+ }
351
+ if (event.key === 'Alt') {
352
+ altPressedRef.current = false
353
+ setAltPressed(false)
354
+ }
355
+ }
356
+
357
+ const onWindowBlur = () => {
358
+ shiftPressedRef.current = false
359
+ altPressedRef.current = false
360
+ setAltPressed(false)
361
+ }
362
+
363
+ emitter.on('grid:move', onGridMove)
364
+ emitter.on('grid:click', onGridClick)
365
+ emitter.on('tool:cancel', onCancel)
366
+ window.addEventListener('keydown', onKeyDown)
367
+ window.addEventListener('keyup', onKeyUp)
368
+ window.addEventListener('blur', onWindowBlur)
369
+
370
+ return () => {
371
+ if (!wasCommitted) {
372
+ restoreOriginal(false)
373
+ }
374
+ resumeSceneHistory(useScene)
375
+ emitter.off('grid:move', onGridMove)
376
+ emitter.off('grid:click', onGridClick)
377
+ emitter.off('tool:cancel', onCancel)
378
+ window.removeEventListener('keydown', onKeyDown)
379
+ window.removeEventListener('keyup', onKeyUp)
380
+ window.removeEventListener('blur', onWindowBlur)
381
+ }
382
+ }, [exitMoveMode, target])
383
+
384
+ return (
385
+ <group>
386
+ <CursorSphere position={cursorLocalPos} showTooltip={false} />
387
+ <Html
388
+ position={[cursorLocalPos[0], 0, cursorLocalPos[2]]}
389
+ style={{ pointerEvents: 'none', touchAction: 'none' }}
390
+ zIndexRange={[100, 0]}
391
+ >
392
+ <div className="translate-y-10">
393
+ <div
394
+ className={`whitespace-nowrap rounded-full border px-2 py-1 text-[11px] font-medium shadow-lg backdrop-blur-md transition-colors ${
395
+ altPressed
396
+ ? 'border-amber-500/80 bg-amber-500/15 text-amber-100'
397
+ : 'border-border bg-background/95 text-muted-foreground'
398
+ }`}
399
+ >
400
+ {altPressed ? 'Detaching corner' : 'Alt to detach'}
401
+ </div>
402
+ </div>
403
+ </Html>
404
+ {angleLabel && <EndpointAngleLabel label={angleLabel.label} position={angleLabel.position} />}
405
+ </group>
406
+ )
407
+ }
408
+
409
+ function EndpointAngleLabel({
410
+ label,
411
+ position,
412
+ }: {
413
+ label: string
414
+ position: [number, number, number]
415
+ }) {
416
+ return (
417
+ <Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
418
+ <div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono text-[11px] font-semibold text-foreground shadow-lg backdrop-blur-md">
419
+ {label}
420
+ </div>
421
+ </Html>
422
+ )
423
+ }