@pascal-app/editor 0.4.0 → 0.5.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 (61) hide show
  1. package/package.json +5 -5
  2. package/src/components/editor/floating-action-menu.tsx +101 -29
  3. package/src/components/editor/floating-building-action-menu.tsx +69 -0
  4. package/src/components/editor/floorplan-panel.tsx +31 -13
  5. package/src/components/editor/index.tsx +219 -167
  6. package/src/components/editor/node-action-menu.tsx +26 -10
  7. package/src/components/editor/selection-manager.tsx +38 -2
  8. package/src/components/editor/thumbnail-generator.tsx +245 -64
  9. package/src/components/systems/stair/stair-edit-system.tsx +27 -5
  10. package/src/components/tools/building/move-building-tool.tsx +157 -0
  11. package/src/components/tools/door/door-math.ts +1 -1
  12. package/src/components/tools/door/door-tool.tsx +19 -7
  13. package/src/components/tools/door/move-door-tool.tsx +17 -8
  14. package/src/components/tools/fence/fence-drafting.ts +125 -0
  15. package/src/components/tools/fence/fence-tool.tsx +190 -0
  16. package/src/components/tools/fence/move-fence-tool.tsx +223 -0
  17. package/src/components/tools/item/item-tool.tsx +3 -3
  18. package/src/components/tools/item/move-tool.tsx +7 -0
  19. package/src/components/tools/item/placement-strategies.ts +15 -7
  20. package/src/components/tools/item/use-placement-coordinator.tsx +89 -14
  21. package/src/components/tools/roof/move-roof-tool.tsx +5 -2
  22. package/src/components/tools/roof/roof-tool.tsx +6 -6
  23. package/src/components/tools/select/box-select-tool.tsx +2 -2
  24. package/src/components/tools/shared/polygon-editor.tsx +2 -2
  25. package/src/components/tools/slab/slab-tool.tsx +4 -4
  26. package/src/components/tools/stair/stair-defaults.ts +10 -0
  27. package/src/components/tools/stair/stair-tool.tsx +29 -6
  28. package/src/components/tools/tool-manager.tsx +42 -14
  29. package/src/components/tools/wall/wall-tool.tsx +19 -29
  30. package/src/components/tools/window/move-window-tool.tsx +17 -8
  31. package/src/components/tools/window/window-math.ts +1 -1
  32. package/src/components/tools/window/window-tool.tsx +19 -7
  33. package/src/components/tools/zone/zone-tool.tsx +7 -7
  34. package/src/components/ui/action-menu/structure-tools.tsx +1 -0
  35. package/src/components/ui/helpers/building-helper.tsx +32 -0
  36. package/src/components/ui/helpers/helper-manager.tsx +2 -0
  37. package/src/components/ui/panels/fence-panel.tsx +184 -0
  38. package/src/components/ui/panels/panel-manager.tsx +3 -0
  39. package/src/components/ui/panels/stair-panel.tsx +206 -33
  40. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +22 -15
  41. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +60 -52
  42. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +35 -24
  43. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +65 -0
  44. package/src/components/ui/sidebar/panels/site-panel/index.tsx +59 -40
  45. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +14 -13
  46. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +59 -52
  47. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +27 -22
  48. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +66 -49
  49. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +35 -36
  50. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +66 -49
  51. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +11 -11
  52. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +17 -14
  53. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +57 -53
  54. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +35 -24
  55. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +22 -27
  56. package/src/components/viewer-overlay.tsx +1 -0
  57. package/src/hooks/use-auto-save.ts +3 -6
  58. package/src/hooks/use-contextual-tools.ts +10 -2
  59. package/src/hooks/use-grid-events.ts +13 -1
  60. package/src/hooks/use-keyboard.ts +4 -0
  61. package/src/store/use-editor.tsx +7 -0
@@ -0,0 +1,125 @@
1
+ import { FenceNode, useScene, type WallNode } from '@pascal-app/core'
2
+ import { useViewer } from '@pascal-app/viewer'
3
+ import { sfxEmitter } from '../../../lib/sfx-bus'
4
+ import {
5
+ type WallPlanPoint,
6
+ findWallSnapTarget,
7
+ isWallLongEnough,
8
+ snapPointTo45Degrees,
9
+ snapPointToGrid,
10
+ } from '../wall/wall-drafting'
11
+
12
+ export type FencePlanPoint = WallPlanPoint
13
+
14
+ type SegmentNode = {
15
+ start: FencePlanPoint
16
+ end: FencePlanPoint
17
+ }
18
+
19
+ function distanceSquared(a: FencePlanPoint, b: FencePlanPoint): number {
20
+ const dx = a[0] - b[0]
21
+ const dz = a[1] - b[1]
22
+ return dx * dx + dz * dz
23
+ }
24
+
25
+ function projectPointOntoSegment(
26
+ point: FencePlanPoint,
27
+ segment: SegmentNode,
28
+ ): FencePlanPoint | null {
29
+ const [x1, z1] = segment.start
30
+ const [x2, z2] = segment.end
31
+ const dx = x2 - x1
32
+ const dz = z2 - z1
33
+ const lengthSquared = dx * dx + dz * dz
34
+ if (lengthSquared < 1e-9) {
35
+ return null
36
+ }
37
+
38
+ const t = ((point[0] - x1) * dx + (point[1] - z1) * dz) / lengthSquared
39
+ if (t <= 0 || t >= 1) {
40
+ return null
41
+ }
42
+
43
+ return [x1 + dx * t, z1 + dz * t]
44
+ }
45
+
46
+ function findFenceSnapTarget(
47
+ point: FencePlanPoint,
48
+ fences: FenceNode[],
49
+ ignoreFenceIds: string[] = [],
50
+ ): FencePlanPoint | null {
51
+ const radiusSquared = 0.35 ** 2
52
+ const ignoredFenceIds = new Set(ignoreFenceIds)
53
+ let bestTarget: FencePlanPoint | null = null
54
+ let bestDistanceSquared = Number.POSITIVE_INFINITY
55
+
56
+ for (const fence of fences) {
57
+ if (ignoredFenceIds.has(fence.id)) {
58
+ continue
59
+ }
60
+
61
+ const candidates: Array<FencePlanPoint | null> = [
62
+ fence.start,
63
+ fence.end,
64
+ projectPointOntoSegment(point, fence),
65
+ ]
66
+
67
+ for (const candidate of candidates) {
68
+ if (!candidate) {
69
+ continue
70
+ }
71
+
72
+ const candidateDistanceSquared = distanceSquared(point, candidate)
73
+ if (
74
+ candidateDistanceSquared > radiusSquared ||
75
+ candidateDistanceSquared >= bestDistanceSquared
76
+ ) {
77
+ continue
78
+ }
79
+
80
+ bestTarget = candidate
81
+ bestDistanceSquared = candidateDistanceSquared
82
+ }
83
+ }
84
+
85
+ return bestTarget
86
+ }
87
+
88
+ export function snapFenceDraftPoint(args: {
89
+ point: FencePlanPoint
90
+ walls: WallNode[]
91
+ fences: FenceNode[]
92
+ start?: FencePlanPoint
93
+ angleSnap?: boolean
94
+ ignoreFenceIds?: string[]
95
+ }): FencePlanPoint {
96
+ const { point, walls, fences, start, angleSnap = false, ignoreFenceIds } = args
97
+ const basePoint = start && angleSnap ? snapPointTo45Degrees(start, point) : snapPointToGrid(point)
98
+ const fenceSnapTarget = findFenceSnapTarget(basePoint, fences, ignoreFenceIds)
99
+
100
+ return fenceSnapTarget ?? findWallSnapTarget(basePoint, walls) ?? basePoint
101
+ }
102
+
103
+ export function createFenceOnCurrentLevel(
104
+ start: FencePlanPoint,
105
+ end: FencePlanPoint,
106
+ ): FenceNode | null {
107
+ const currentLevelId = useViewer.getState().selection.levelId
108
+ const { createNode, nodes } = useScene.getState()
109
+
110
+ if (!(currentLevelId && isWallLongEnough(start, end))) {
111
+ return null
112
+ }
113
+
114
+ const fenceCount = Object.values(nodes).filter((node) => node.type === 'fence').length
115
+ const fence = FenceNode.parse({
116
+ name: `Fence ${fenceCount + 1}`,
117
+ start,
118
+ end,
119
+ })
120
+
121
+ createNode(fence, currentLevelId)
122
+ sfxEmitter.emit('sfx:structure-build')
123
+
124
+ return fence
125
+ }
@@ -0,0 +1,190 @@
1
+ import {
2
+ emitter,
3
+ type FenceNode,
4
+ type GridEvent,
5
+ type LevelNode,
6
+ useScene,
7
+ type WallNode,
8
+ } from '@pascal-app/core'
9
+ import { useViewer } from '@pascal-app/viewer'
10
+ import { useEffect, useRef } from 'react'
11
+ import { DoubleSide, type Group, type Mesh, Shape, ShapeGeometry, Vector3 } from 'three'
12
+ import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
13
+ import { EDITOR_LAYER } from '../../../lib/constants'
14
+ import { sfxEmitter } from '../../../lib/sfx-bus'
15
+ import { CursorSphere } from '../shared/cursor-sphere'
16
+ import {
17
+ createFenceOnCurrentLevel,
18
+ snapFenceDraftPoint,
19
+ type FencePlanPoint,
20
+ } from './fence-drafting'
21
+
22
+ const FENCE_PREVIEW_HEIGHT = 1.8
23
+
24
+ const updateFencePreview = (mesh: Mesh, start: Vector3, end: Vector3) => {
25
+ const direction = new Vector3(end.x - start.x, 0, end.z - start.z)
26
+ const length = direction.length()
27
+
28
+ if (length < 0.01) {
29
+ mesh.visible = false
30
+ return
31
+ }
32
+
33
+ mesh.visible = true
34
+ direction.normalize()
35
+
36
+ const shape = new Shape()
37
+ shape.moveTo(0, 0)
38
+ shape.lineTo(length, 0)
39
+ shape.lineTo(length, FENCE_PREVIEW_HEIGHT)
40
+ shape.lineTo(0, FENCE_PREVIEW_HEIGHT)
41
+ shape.closePath()
42
+
43
+ const geometry = new ShapeGeometry(shape)
44
+ const angle = -Math.atan2(direction.z, direction.x)
45
+
46
+ mesh.position.set(start.x, start.y, start.z)
47
+ mesh.rotation.y = angle
48
+
49
+ if (mesh.geometry) {
50
+ mesh.geometry.dispose()
51
+ }
52
+ mesh.geometry = geometry
53
+ }
54
+
55
+ const getCurrentLevelElements = (): { walls: WallNode[]; fences: FenceNode[] } => {
56
+ const currentLevelId = useViewer.getState().selection.levelId
57
+ const { nodes } = useScene.getState()
58
+
59
+ if (!currentLevelId) return { walls: [], fences: [] }
60
+
61
+ const levelNode = nodes[currentLevelId]
62
+ if (!levelNode || levelNode.type !== 'level') return { walls: [], fences: [] }
63
+
64
+ const children = (levelNode as LevelNode).children.map((childId) => nodes[childId])
65
+
66
+ return {
67
+ walls: children.filter((node): node is WallNode => node?.type === 'wall'),
68
+ fences: children.filter((node): node is FenceNode => node?.type === 'fence'),
69
+ }
70
+ }
71
+
72
+ export const FenceTool: React.FC = () => {
73
+ const cursorRef = useRef<Group>(null)
74
+ const previewRef = useRef<Mesh>(null!)
75
+ const startingPoint = useRef(new Vector3(0, 0, 0))
76
+ const endingPoint = useRef(new Vector3(0, 0, 0))
77
+ const buildingState = useRef(0)
78
+ const shiftPressed = useRef(false)
79
+
80
+ useEffect(() => {
81
+ let previousFenceEnd: [number, number] | null = null
82
+
83
+ const onGridMove = (event: GridEvent) => {
84
+ if (!(cursorRef.current && previewRef.current)) return
85
+
86
+ const { walls, fences } = getCurrentLevelElements()
87
+ const localPoint: FencePlanPoint = [event.localPosition[0], event.localPosition[2]]
88
+
89
+ if (buildingState.current === 1) {
90
+ const snappedLocal = snapFenceDraftPoint({
91
+ point: localPoint,
92
+ walls,
93
+ fences,
94
+ start: [startingPoint.current.x, startingPoint.current.z],
95
+ angleSnap: !shiftPressed.current,
96
+ })
97
+ endingPoint.current.set(snappedLocal[0], event.localPosition[1], snappedLocal[1])
98
+ cursorRef.current.position.copy(endingPoint.current)
99
+
100
+ const currentFenceEnd: [number, number] = [snappedLocal[0], snappedLocal[1]]
101
+ if (
102
+ previousFenceEnd &&
103
+ (currentFenceEnd[0] !== previousFenceEnd[0] || currentFenceEnd[1] !== previousFenceEnd[1])
104
+ ) {
105
+ sfxEmitter.emit('sfx:grid-snap')
106
+ }
107
+ previousFenceEnd = currentFenceEnd
108
+
109
+ updateFencePreview(previewRef.current, startingPoint.current, endingPoint.current)
110
+ } else {
111
+ const snappedPoint = snapFenceDraftPoint({ point: localPoint, walls, fences })
112
+ cursorRef.current.position.set(snappedPoint[0], event.localPosition[1], snappedPoint[1])
113
+ }
114
+ }
115
+
116
+ const onGridClick = (event: GridEvent) => {
117
+ const { walls, fences } = getCurrentLevelElements()
118
+ const localClick: FencePlanPoint = [event.localPosition[0], event.localPosition[2]]
119
+
120
+ if (buildingState.current === 0) {
121
+ const snappedStart = snapFenceDraftPoint({ point: localClick, walls, fences })
122
+ startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1])
123
+ endingPoint.current.copy(startingPoint.current)
124
+ buildingState.current = 1
125
+ previewRef.current.visible = true
126
+ } else {
127
+ const snappedEnd = snapFenceDraftPoint({
128
+ point: localClick,
129
+ walls,
130
+ fences,
131
+ start: [startingPoint.current.x, startingPoint.current.z],
132
+ angleSnap: !shiftPressed.current,
133
+ })
134
+ const dx = snappedEnd[0] - startingPoint.current.x
135
+ const dz = snappedEnd[1] - startingPoint.current.z
136
+ if (dx * dx + dz * dz < 0.01 * 0.01) return
137
+ createFenceOnCurrentLevel([startingPoint.current.x, startingPoint.current.z], snappedEnd)
138
+ previewRef.current.visible = false
139
+ buildingState.current = 0
140
+ }
141
+ }
142
+
143
+ const onKeyDown = (e: KeyboardEvent) => {
144
+ if (e.key === 'Shift') shiftPressed.current = true
145
+ }
146
+
147
+ const onKeyUp = (e: KeyboardEvent) => {
148
+ if (e.key === 'Shift') shiftPressed.current = false
149
+ }
150
+
151
+ const onCancel = () => {
152
+ if (buildingState.current === 1) {
153
+ markToolCancelConsumed()
154
+ buildingState.current = 0
155
+ previewRef.current.visible = false
156
+ }
157
+ }
158
+
159
+ emitter.on('grid:move', onGridMove)
160
+ emitter.on('grid:click', onGridClick)
161
+ emitter.on('tool:cancel', onCancel)
162
+ window.addEventListener('keydown', onKeyDown)
163
+ window.addEventListener('keyup', onKeyUp)
164
+
165
+ return () => {
166
+ emitter.off('grid:move', onGridMove)
167
+ emitter.off('grid:click', onGridClick)
168
+ emitter.off('tool:cancel', onCancel)
169
+ window.removeEventListener('keydown', onKeyDown)
170
+ window.removeEventListener('keyup', onKeyUp)
171
+ }
172
+ }, [])
173
+
174
+ return (
175
+ <group>
176
+ <CursorSphere ref={cursorRef} height={FENCE_PREVIEW_HEIGHT} />
177
+ <mesh layers={EDITOR_LAYER} ref={previewRef} renderOrder={1} visible={false}>
178
+ <shapeGeometry />
179
+ <meshBasicMaterial
180
+ color="#ffffff"
181
+ depthTest={false}
182
+ depthWrite={false}
183
+ opacity={0.45}
184
+ side={DoubleSide}
185
+ transparent
186
+ />
187
+ </mesh>
188
+ </group>
189
+ )
190
+ }
@@ -0,0 +1,223 @@
1
+ 'use client'
2
+
3
+ import { type AnyNodeId, type FenceNode, emitter, type GridEvent, useScene } from '@pascal-app/core'
4
+ import { useViewer } from '@pascal-app/viewer'
5
+ import { useCallback, useEffect, useRef, useState } from 'react'
6
+ import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
7
+ import { sfxEmitter } from '../../../lib/sfx-bus'
8
+ import useEditor from '../../../store/use-editor'
9
+ import { CursorSphere } from '../shared/cursor-sphere'
10
+
11
+ function snap(value: number) {
12
+ return Math.round(value * 2) / 2
13
+ }
14
+
15
+ function samePoint(a: [number, number], b: [number, number]) {
16
+ return a[0] === b[0] && a[1] === b[1]
17
+ }
18
+
19
+ type LinkedFenceSnapshot = {
20
+ id: FenceNode['id']
21
+ start: [number, number]
22
+ end: [number, number]
23
+ }
24
+
25
+ function getLinkedFenceSnapshots(args: {
26
+ fenceId: FenceNode['id']
27
+ originalStart: [number, number]
28
+ originalEnd: [number, number]
29
+ }) {
30
+ const { fenceId, originalStart, originalEnd } = args
31
+ const { nodes } = useScene.getState()
32
+ const snapshots: LinkedFenceSnapshot[] = []
33
+
34
+ for (const node of Object.values(nodes)) {
35
+ if (!(node?.type === 'fence' && node.id !== fenceId)) {
36
+ continue
37
+ }
38
+
39
+ if (
40
+ !samePoint(node.start, originalStart) &&
41
+ !samePoint(node.start, originalEnd) &&
42
+ !samePoint(node.end, originalStart) &&
43
+ !samePoint(node.end, originalEnd)
44
+ ) {
45
+ continue
46
+ }
47
+
48
+ snapshots.push({
49
+ id: node.id,
50
+ start: [...node.start] as [number, number],
51
+ end: [...node.end] as [number, number],
52
+ })
53
+ }
54
+
55
+ return snapshots
56
+ }
57
+
58
+ function getLinkedFenceUpdates(
59
+ linkedFences: LinkedFenceSnapshot[],
60
+ originalStart: [number, number],
61
+ originalEnd: [number, number],
62
+ nextStart: [number, number],
63
+ nextEnd: [number, number],
64
+ ) {
65
+ return linkedFences.map((fence) => ({
66
+ id: fence.id,
67
+ start: samePoint(fence.start, originalStart)
68
+ ? nextStart
69
+ : samePoint(fence.start, originalEnd)
70
+ ? nextEnd
71
+ : fence.start,
72
+ end: samePoint(fence.end, originalStart)
73
+ ? nextStart
74
+ : samePoint(fence.end, originalEnd)
75
+ ? nextEnd
76
+ : fence.end,
77
+ }))
78
+ }
79
+
80
+ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
81
+ const previousGridPosRef = useRef<[number, number] | null>(null)
82
+ const originalStartRef = useRef<[number, number]>([...node.start] as [number, number])
83
+ const originalEndRef = useRef<[number, number]>([...node.end] as [number, number])
84
+ const linkedOriginalsRef = useRef(
85
+ getLinkedFenceSnapshots({
86
+ fenceId: node.id,
87
+ originalStart: node.start,
88
+ originalEnd: node.end,
89
+ }),
90
+ )
91
+ const dragAnchorRef = useRef<[number, number] | null>(null)
92
+ const nodeIdRef = useRef(node.id)
93
+ const previewRef = useRef<{ start: [number, number]; end: [number, number] } | null>(null)
94
+
95
+ const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
96
+ const centerX = (node.start[0] + node.end[0]) / 2
97
+ const centerZ = (node.start[1] + node.end[1]) / 2
98
+ return [centerX, 0, centerZ]
99
+ })
100
+
101
+ const exitMoveMode = useCallback(() => {
102
+ useEditor.getState().setMovingNode(null)
103
+ }, [])
104
+
105
+ useEffect(() => {
106
+ const nodeId = nodeIdRef.current
107
+ const originalStart = originalStartRef.current
108
+ const originalEnd = originalEndRef.current
109
+
110
+ useScene.temporal.getState().pause()
111
+ let wasCommitted = false
112
+
113
+ const applyNodePreview = (updates: Array<{ id: FenceNode['id']; start: [number, number]; end: [number, number] }>) => {
114
+ useScene.getState().updateNodes(
115
+ updates.map((entry) => ({
116
+ id: entry.id as AnyNodeId,
117
+ data: { start: entry.start, end: entry.end },
118
+ })),
119
+ )
120
+ for (const entry of updates) {
121
+ useScene.getState().markDirty(entry.id as AnyNodeId)
122
+ }
123
+ }
124
+
125
+ const applyPreview = (nextStart: [number, number], nextEnd: [number, number]) => {
126
+ previewRef.current = { start: nextStart, end: nextEnd }
127
+ const centerX = (nextStart[0] + nextEnd[0]) / 2
128
+ const centerZ = (nextStart[1] + nextEnd[1]) / 2
129
+ setCursorLocalPos([centerX, 0, centerZ])
130
+ applyNodePreview([
131
+ { id: nodeId, start: nextStart, end: nextEnd },
132
+ ...getLinkedFenceUpdates(
133
+ linkedOriginalsRef.current,
134
+ originalStart,
135
+ originalEnd,
136
+ nextStart,
137
+ nextEnd,
138
+ ),
139
+ ])
140
+ }
141
+
142
+ const onGridMove = (event: GridEvent) => {
143
+ const localX = snap(event.localPosition[0])
144
+ const localZ = snap(event.localPosition[2])
145
+
146
+ if (
147
+ previousGridPosRef.current &&
148
+ (localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1])
149
+ ) {
150
+ sfxEmitter.emit('sfx:grid-snap')
151
+ }
152
+ previousGridPosRef.current = [localX, localZ]
153
+
154
+ const anchor = dragAnchorRef.current ?? [localX, localZ]
155
+ dragAnchorRef.current = anchor
156
+
157
+ const deltaX = localX - anchor[0]
158
+ const deltaZ = localZ - anchor[1]
159
+
160
+ const nextStart: [number, number] = [originalStart[0] + deltaX, originalStart[1] + deltaZ]
161
+ const nextEnd: [number, number] = [originalEnd[0] + deltaX, originalEnd[1] + deltaZ]
162
+
163
+ applyPreview(nextStart, nextEnd)
164
+ }
165
+
166
+ const onGridClick = (event: GridEvent) => {
167
+ const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
168
+
169
+ wasCommitted = true
170
+ useScene.temporal.getState().resume()
171
+ applyNodePreview([
172
+ { id: nodeId, start: preview.start, end: preview.end },
173
+ ...getLinkedFenceUpdates(
174
+ linkedOriginalsRef.current,
175
+ originalStart,
176
+ originalEnd,
177
+ preview.start,
178
+ preview.end,
179
+ ),
180
+ ])
181
+ useScene.temporal.getState().pause()
182
+
183
+ sfxEmitter.emit('sfx:item-place')
184
+ useViewer.getState().setSelection({ selectedIds: [nodeId] })
185
+ exitMoveMode()
186
+ event.nativeEvent?.stopPropagation?.()
187
+ }
188
+
189
+ const onCancel = () => {
190
+ applyNodePreview([
191
+ { id: nodeId, start: originalStart, end: originalEnd },
192
+ ...linkedOriginalsRef.current,
193
+ ])
194
+ useViewer.getState().setSelection({ selectedIds: [nodeId] })
195
+ useScene.temporal.getState().resume()
196
+ markToolCancelConsumed()
197
+ exitMoveMode()
198
+ }
199
+
200
+ emitter.on('grid:move', onGridMove)
201
+ emitter.on('grid:click', onGridClick)
202
+ emitter.on('tool:cancel', onCancel)
203
+
204
+ return () => {
205
+ if (!wasCommitted) {
206
+ applyNodePreview([
207
+ { id: nodeId, start: originalStart, end: originalEnd },
208
+ ...linkedOriginalsRef.current,
209
+ ])
210
+ }
211
+ useScene.temporal.getState().resume()
212
+ emitter.off('grid:move', onGridMove)
213
+ emitter.off('grid:click', onGridClick)
214
+ emitter.off('tool:cancel', onCancel)
215
+ }
216
+ }, [exitMoveMode])
217
+
218
+ return (
219
+ <group>
220
+ <CursorSphere position={cursorLocalPos} showTooltip={false} />
221
+ </group>
222
+ )
223
+ }
@@ -8,11 +8,11 @@ export const ItemTool: React.FC = () => {
8
8
  const draftNode = useDraftNode()
9
9
 
10
10
  const cursor = usePlacementCoordinator({
11
- asset: selectedItem!,
11
+ asset: selectedItem,
12
12
  draftNode,
13
13
  initDraft: (gridPosition) => {
14
- if (!selectedItem?.attachTo) {
15
- draftNode.create(gridPosition, selectedItem!)
14
+ if (selectedItem && !selectedItem.attachTo) {
15
+ draftNode.create(gridPosition, selectedItem)
16
16
  }
17
17
  },
18
18
  onCommitted: () => {
@@ -1,5 +1,7 @@
1
1
  import type {
2
+ BuildingNode,
2
3
  DoorNode,
4
+ FenceNode,
3
5
  ItemNode,
4
6
  RoofNode,
5
7
  RoofSegmentNode,
@@ -10,7 +12,9 @@ import type {
10
12
  import { Vector3 } from 'three'
11
13
  import { sfxEmitter } from '../../../lib/sfx-bus'
12
14
  import useEditor from '../../../store/use-editor'
15
+ import { MoveBuildingContent } from '../building/move-building-tool'
13
16
  import { MoveDoorTool } from '../door/move-door-tool'
17
+ import { MoveFenceTool } from '../fence/move-fence-tool'
14
18
  import { MoveRoofTool } from '../roof/move-roof-tool'
15
19
  import { MoveWindowTool } from '../window/move-window-tool'
16
20
  import type { PlacementState } from './placement-types'
@@ -80,8 +84,11 @@ export const MoveTool: React.FC = () => {
80
84
  const movingNode = useEditor((state) => state.movingNode)
81
85
 
82
86
  if (!movingNode) return null
87
+ if (movingNode.type === 'building')
88
+ return <MoveBuildingContent node={movingNode as BuildingNode} />
83
89
  if (movingNode.type === 'door') return <MoveDoorTool node={movingNode as DoorNode} />
84
90
  if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} />
91
+ if (movingNode.type === 'fence') return <MoveFenceTool node={movingNode as FenceNode} />
85
92
  if (movingNode.type === 'roof' || movingNode.type === 'roof-segment')
86
93
  return <MoveRoofTool node={movingNode as RoofNode | RoofSegmentNode} />
87
94
  if (movingNode.type === 'stair' || movingNode.type === 'stair-segment')
@@ -47,12 +47,16 @@ export const floorStrategy = {
47
47
  ? getScaledDimensions(ctx.draftItem)
48
48
  : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
49
49
  const [dimX, , dimZ] = dims
50
- const x = snapToGrid(event.position[0], dimX)
51
- const z = snapToGrid(event.position[2], dimZ)
50
+ const rotY = ctx.draftItem?.rotation?.[1] ?? 0
51
+ const swapDims = Math.abs(Math.sin(rotY)) > 0.9
52
+ // event.localPosition is building-local; the coordinator cursor group is inside the
53
+ // building-local ToolManager group, so local coords are correct for both data and visuals.
54
+ const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
55
+ const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ)
52
56
 
53
57
  return {
54
58
  gridPosition: [x, 0, z],
55
- cursorPosition: [x, event.position[1], z],
59
+ cursorPosition: [x, event.localPosition[1], z],
56
60
  cursorRotationY: 0,
57
61
  nodeUpdate: { position: [x, 0, z] },
58
62
  stopPropagation: false,
@@ -302,9 +306,11 @@ export const ceilingStrategy = {
302
306
  : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
303
307
  const [dimX, , dimZ] = dims
304
308
  const itemHeight = dims[1]
309
+ const rotY = ctx.draftItem?.rotation?.[1] ?? 0
310
+ const swapDims = Math.abs(Math.sin(rotY)) > 0.9
305
311
 
306
- const x = snapToGrid(event.position[0], dimX)
307
- const z = snapToGrid(event.position[2], dimZ)
312
+ const x = snapToGrid(event.position[0], swapDims ? dimZ : dimX)
313
+ const z = snapToGrid(event.position[2], swapDims ? dimX : dimZ)
308
314
 
309
315
  return {
310
316
  stateUpdate: { surface: 'ceiling', ceilingId: event.node.id },
@@ -329,9 +335,11 @@ export const ceilingStrategy = {
329
335
  const dims = getScaledDimensions(ctx.draftItem)
330
336
  const [dimX, , dimZ] = dims
331
337
  const itemHeight = dims[1]
338
+ const rotY = ctx.draftItem.rotation?.[1] ?? 0
339
+ const swapDims = Math.abs(Math.sin(rotY)) > 0.9
332
340
 
333
- const x = snapToGrid(event.position[0], dimX)
334
- const z = snapToGrid(event.position[2], dimZ)
341
+ const x = snapToGrid(event.position[0], swapDims ? dimZ : dimX)
342
+ const z = snapToGrid(event.position[2], swapDims ? dimX : dimZ)
335
343
 
336
344
  return {
337
345
  gridPosition: [x, -itemHeight, z],