@pascal-app/editor 0.5.1 → 0.6.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.
- package/package.json +8 -7
- package/src/components/editor/editor-layout-v2.tsx +9 -0
- package/src/components/editor/floating-action-menu.tsx +255 -34
- package/src/components/editor/floating-building-action-menu.tsx +4 -3
- package/src/components/editor/floorplan-panel.tsx +1323 -713
- package/src/components/editor/index.tsx +2 -0
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/selection-manager.tsx +200 -8
- package/src/components/editor/site-edge-labels.tsx +9 -3
- package/src/components/editor/thumbnail-generator.tsx +319 -157
- package/src/components/editor/wall-measurement-label.tsx +120 -32
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +272 -0
- package/src/components/systems/roof/roof-edit-system.tsx +5 -5
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
- package/src/components/tools/door/door-tool.tsx +12 -0
- package/src/components/tools/door/move-door-tool.tsx +10 -0
- package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
- package/src/components/tools/fence/fence-drafting.ts +19 -7
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +327 -0
- package/src/components/tools/fence/move-fence-tool.tsx +8 -0
- package/src/components/tools/item/move-tool.tsx +9 -0
- package/src/components/tools/item/placement-math.ts +14 -6
- package/src/components/tools/item/placement-strategies.ts +2 -2
- package/src/components/tools/item/use-placement-coordinator.tsx +42 -10
- package/src/components/tools/roof/move-roof-tool.tsx +89 -28
- package/src/components/tools/shared/polygon-editor.tsx +98 -8
- package/src/components/tools/slab/move-slab-tool.tsx +182 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
- package/src/components/tools/stair/stair-tool.tsx +11 -3
- package/src/components/tools/tool-manager.tsx +12 -0
- package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +322 -0
- package/src/components/tools/wall/move-wall-tool.tsx +356 -0
- package/src/components/tools/wall/wall-drafting.ts +331 -9
- package/src/components/tools/window/move-window-tool.tsx +10 -0
- package/src/components/tools/window/window-tool.tsx +12 -0
- package/src/components/ui/action-menu/control-modes.tsx +9 -4
- package/src/components/ui/command-palette/editor-commands.tsx +9 -4
- package/src/components/ui/command-palette/index.tsx +0 -1
- package/src/components/ui/controls/material-picker.tsx +127 -94
- package/src/components/ui/controls/slider-control.tsx +28 -14
- package/src/components/ui/item-catalog/catalog-items.tsx +5 -0
- package/src/components/ui/panels/ceiling-panel.tsx +61 -17
- package/src/components/ui/panels/door-panel.tsx +5 -5
- package/src/components/ui/panels/fence-panel.tsx +97 -12
- package/src/components/ui/panels/item-panel.tsx +5 -5
- package/src/components/ui/panels/panel-manager.tsx +31 -29
- package/src/components/ui/panels/reference-panel.tsx +5 -4
- package/src/components/ui/panels/roof-panel.tsx +91 -22
- package/src/components/ui/panels/roof-segment-panel.tsx +23 -13
- package/src/components/ui/panels/slab-panel.tsx +63 -15
- package/src/components/ui/panels/stair-panel.tsx +173 -19
- package/src/components/ui/panels/stair-segment-panel.tsx +28 -17
- package/src/components/ui/panels/wall-panel.tsx +159 -11
- package/src/components/ui/panels/window-panel.tsx +5 -7
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +29 -32
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +3 -3
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +3 -3
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +7 -3
- package/src/components/ui/viewer-toolbar.tsx +55 -2
- package/src/components/viewer-overlay.tsx +25 -19
- package/src/hooks/use-contextual-tools.ts +14 -13
- package/src/hooks/use-keyboard.ts +3 -2
- package/src/index.tsx +2 -1
- package/src/lib/history.ts +20 -0
- package/src/lib/sfx-player.ts +96 -13
- package/src/store/use-editor.tsx +118 -10
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type AnyNodeId,
|
|
3
3
|
emitter,
|
|
4
|
+
type FenceNode,
|
|
4
5
|
type GridEvent,
|
|
6
|
+
type LevelNode,
|
|
5
7
|
type RoofNode,
|
|
6
8
|
type RoofSegmentNode,
|
|
7
9
|
type StairNode,
|
|
@@ -9,13 +11,16 @@ import {
|
|
|
9
11
|
sceneRegistry,
|
|
10
12
|
useLiveTransforms,
|
|
11
13
|
useScene,
|
|
14
|
+
type WallNode,
|
|
12
15
|
} from '@pascal-app/core'
|
|
13
16
|
import { useViewer } from '@pascal-app/viewer'
|
|
14
17
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
15
18
|
import * as THREE from 'three'
|
|
16
19
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
17
20
|
import useEditor from '../../../store/use-editor'
|
|
21
|
+
import { snapFenceDraftPoint } from '../fence/fence-drafting'
|
|
18
22
|
import { CursorSphere } from '../shared/cursor-sphere'
|
|
23
|
+
import type { WallPlanPoint } from '../wall/wall-drafting'
|
|
19
24
|
|
|
20
25
|
export const MoveRoofTool: React.FC<{
|
|
21
26
|
node: RoofNode | RoofSegmentNode | StairNode | StairSegmentNode
|
|
@@ -29,9 +34,13 @@ export const MoveRoofTool: React.FC<{
|
|
|
29
34
|
const [cursorWorldPos, setCursorWorldPos] = useState<[number, number, number]>(() => {
|
|
30
35
|
const obj = sceneRegistry.nodes.get(movingNode.id)
|
|
31
36
|
if (obj) {
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
37
|
+
const worldPos = obj.getWorldPosition(new THREE.Vector3())
|
|
38
|
+
// Cursor renders inside the building-local ToolManager group, so convert
|
|
39
|
+
// world → building-local to honor any building rotation.
|
|
40
|
+
const buildingId = useViewer.getState().selection.buildingId
|
|
41
|
+
const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
|
|
42
|
+
if (buildingObj) buildingObj.worldToLocal(worldPos)
|
|
43
|
+
return [worldPos.x, worldPos.y, worldPos.z]
|
|
35
44
|
}
|
|
36
45
|
// Fallback if not registered (e.g. newly created duplicate without mesh yet)
|
|
37
46
|
if (
|
|
@@ -114,10 +123,55 @@ export const MoveRoofTool: React.FC<{
|
|
|
114
123
|
}
|
|
115
124
|
}
|
|
116
125
|
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
126
|
+
const resolveLevelId = () => {
|
|
127
|
+
if (movingNode.type === 'roof' || movingNode.type === 'stair') {
|
|
128
|
+
return movingNode.parentId ?? null
|
|
129
|
+
}
|
|
120
130
|
|
|
131
|
+
if (
|
|
132
|
+
(movingNode.type === 'roof-segment' || movingNode.type === 'stair-segment') &&
|
|
133
|
+
movingNode.parentId
|
|
134
|
+
) {
|
|
135
|
+
const parentNode = useScene.getState().nodes[movingNode.parentId as AnyNodeId]
|
|
136
|
+
return parentNode && 'parentId' in parentNode ? (parentNode.parentId ?? null) : null
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return null
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const levelId = resolveLevelId()
|
|
143
|
+
const levelNode =
|
|
144
|
+
levelId && useScene.getState().nodes[levelId as AnyNodeId]?.type === 'level'
|
|
145
|
+
? (useScene.getState().nodes[levelId as AnyNodeId] as LevelNode)
|
|
146
|
+
: null
|
|
147
|
+
const levelChildren = levelNode?.children ?? []
|
|
148
|
+
const levelWalls = levelChildren
|
|
149
|
+
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
|
|
150
|
+
.filter((node): node is WallNode => node?.type === 'wall')
|
|
151
|
+
const levelFences = levelChildren
|
|
152
|
+
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
|
|
153
|
+
.filter((node): node is FenceNode => node?.type === 'fence')
|
|
154
|
+
const buildingId = useViewer.getState().selection.buildingId
|
|
155
|
+
const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
|
|
156
|
+
|
|
157
|
+
const localToWorldPoint = (localPoint: WallPlanPoint, y: number): [number, number, number] => {
|
|
158
|
+
if (buildingObj) {
|
|
159
|
+
const worldPoint = buildingObj.localToWorld(new THREE.Vector3(localPoint[0], y, localPoint[1]))
|
|
160
|
+
return [worldPoint.x, worldPoint.y, worldPoint.z]
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return [localPoint[0], y, localPoint[1]]
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const computeLocal = (
|
|
167
|
+
gridX: number,
|
|
168
|
+
gridZ: number,
|
|
169
|
+
y: number,
|
|
170
|
+
buildingLocalX: number,
|
|
171
|
+
buildingLocalZ: number,
|
|
172
|
+
): [number, number] => {
|
|
173
|
+
// Segments have a transformed parent (stair/roof). Convert world → parent-local
|
|
174
|
+
// via Three.js hierarchy so the segment's stored position stays parent-relative.
|
|
121
175
|
if (
|
|
122
176
|
(movingNode.type === 'roof-segment' || movingNode.type === 'stair-segment') &&
|
|
123
177
|
movingNode.parentId
|
|
@@ -128,40 +182,42 @@ export const MoveRoofTool: React.FC<{
|
|
|
128
182
|
if (parentObj) {
|
|
129
183
|
const worldVec = new THREE.Vector3(gridX, y, gridZ)
|
|
130
184
|
parentObj.worldToLocal(worldVec)
|
|
131
|
-
|
|
132
|
-
localZ = worldVec.z
|
|
133
|
-
} else {
|
|
134
|
-
const dx = gridX - (parentNode.position[0] as number)
|
|
135
|
-
const dz = gridZ - (parentNode.position[2] as number)
|
|
136
|
-
const angle = -(parentNode.rotation as number)
|
|
137
|
-
localX = dx * Math.cos(angle) - dz * Math.sin(angle)
|
|
138
|
-
localZ = dx * Math.sin(angle) + dz * Math.cos(angle)
|
|
185
|
+
return [worldVec.x, worldVec.z]
|
|
139
186
|
}
|
|
187
|
+
const dx = gridX - (parentNode.position[0] as number)
|
|
188
|
+
const dz = gridZ - (parentNode.position[2] as number)
|
|
189
|
+
const angle = -(parentNode.rotation as number)
|
|
190
|
+
return [
|
|
191
|
+
dx * Math.cos(angle) - dz * Math.sin(angle),
|
|
192
|
+
dx * Math.sin(angle) + dz * Math.cos(angle),
|
|
193
|
+
]
|
|
140
194
|
}
|
|
141
195
|
}
|
|
142
196
|
|
|
143
|
-
|
|
197
|
+
// Stair/roof live directly in the level — their stored position is building-local.
|
|
198
|
+
// event.localPosition is already building-local, so using it handles building rotation.
|
|
199
|
+
return [buildingLocalX, buildingLocalZ]
|
|
144
200
|
}
|
|
145
201
|
|
|
146
202
|
const onGridMove = (event: GridEvent) => {
|
|
147
|
-
const gridX = Math.round(event.position[0] * 2) / 2
|
|
148
|
-
const gridZ = Math.round(event.position[2] * 2) / 2
|
|
149
203
|
const y = event.position[1]
|
|
150
204
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
205
|
+
const snappedLocal = snapFenceDraftPoint({
|
|
206
|
+
point: [event.localPosition[0], event.localPosition[2]],
|
|
207
|
+
walls: levelWalls,
|
|
208
|
+
fences: levelFences,
|
|
209
|
+
})
|
|
210
|
+
const [gridX, , gridZ] = localToWorldPoint(snappedLocal, y)
|
|
211
|
+
|
|
212
|
+
if (previousGridPosRef.current && (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])) {
|
|
155
213
|
sfxEmitter.emit('sfx:grid-snap')
|
|
156
214
|
}
|
|
157
215
|
|
|
158
216
|
previousGridPosRef.current = [gridX, gridZ]
|
|
159
|
-
|
|
160
|
-
const lx = Math.round(event.localPosition[0] * 2) / 2
|
|
161
|
-
const lz = Math.round(event.localPosition[2] * 2) / 2
|
|
217
|
+
const [lx, lz] = snappedLocal
|
|
162
218
|
setCursorWorldPos([lx, event.localPosition[1], lz])
|
|
163
219
|
|
|
164
|
-
const [localX, localZ] = computeLocal(gridX, gridZ, y)
|
|
220
|
+
const [localX, localZ] = computeLocal(gridX, gridZ, y, lx, lz)
|
|
165
221
|
|
|
166
222
|
// Directly update the Three.js mesh — no store update during drag
|
|
167
223
|
const mesh = sceneRegistry.nodes.get(movingNode.id)
|
|
@@ -178,11 +234,16 @@ export const MoveRoofTool: React.FC<{
|
|
|
178
234
|
}
|
|
179
235
|
|
|
180
236
|
const onGridClick = (event: GridEvent) => {
|
|
181
|
-
const gridX = Math.round(event.position[0] * 2) / 2 // world, for computeLocal
|
|
182
|
-
const gridZ = Math.round(event.position[2] * 2) / 2
|
|
183
237
|
const y = event.position[1]
|
|
238
|
+
const snappedLocal = snapFenceDraftPoint({
|
|
239
|
+
point: [event.localPosition[0], event.localPosition[2]],
|
|
240
|
+
walls: levelWalls,
|
|
241
|
+
fences: levelFences,
|
|
242
|
+
})
|
|
243
|
+
const [gridX, , gridZ] = localToWorldPoint(snappedLocal, y)
|
|
244
|
+
const [lx, lz] = snappedLocal
|
|
184
245
|
|
|
185
|
-
const [localX, localZ] = computeLocal(gridX, gridZ, y)
|
|
246
|
+
const [localX, localZ] = computeLocal(gridX, gridZ, y, lx, lz)
|
|
186
247
|
|
|
187
248
|
wasCommitted = true
|
|
188
249
|
|
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
import { emitter, type GridEvent, sceneRegistry } from '@pascal-app/core'
|
|
2
2
|
import { createPortal } from '@react-three/fiber'
|
|
3
3
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
4
|
-
import { BufferGeometry, Float32BufferAttribute, type Line } from 'three'
|
|
4
|
+
import { BufferGeometry, Float32BufferAttribute, type Line, type Object3D } from 'three'
|
|
5
5
|
import { EDITOR_LAYER } from '../../../lib/constants'
|
|
6
6
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
7
|
+
import { snapToHalf } from '../item/placement-math'
|
|
7
8
|
|
|
8
9
|
const Y_OFFSET = 0.02
|
|
9
10
|
|
|
10
11
|
type DragState = {
|
|
11
12
|
isDragging: boolean
|
|
12
|
-
|
|
13
|
+
mode: 'vertex' | 'polygon'
|
|
14
|
+
vertexIndex: number | null
|
|
13
15
|
initialPosition: [number, number]
|
|
16
|
+
initialPolygon: Array<[number, number]>
|
|
14
17
|
pointerId: number
|
|
15
18
|
}
|
|
16
19
|
|
|
@@ -23,6 +26,8 @@ export interface PolygonEditorProps {
|
|
|
23
26
|
levelId?: string
|
|
24
27
|
/** Height of the surface being edited (e.g. slab elevation). Handles adapt to this. */
|
|
25
28
|
surfaceHeight?: number
|
|
29
|
+
/** Whether to show the center handle that moves the entire polygon. */
|
|
30
|
+
allowPolygonMove?: boolean
|
|
26
31
|
}
|
|
27
32
|
|
|
28
33
|
/**
|
|
@@ -38,9 +43,42 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
38
43
|
minVertices = 3,
|
|
39
44
|
levelId,
|
|
40
45
|
surfaceHeight = 0,
|
|
46
|
+
allowPolygonMove = false,
|
|
41
47
|
}) => {
|
|
42
|
-
|
|
43
|
-
|
|
48
|
+
const [levelNode, setLevelNode] = useState<Object3D | null>(() =>
|
|
49
|
+
levelId ? (sceneRegistry.nodes.get(levelId) ?? null) : null,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (!levelId) {
|
|
54
|
+
setLevelNode(null)
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let frameId = 0
|
|
59
|
+
|
|
60
|
+
const resolveLevelNode = () => {
|
|
61
|
+
const nextLevelNode = sceneRegistry.nodes.get(levelId) ?? null
|
|
62
|
+
setLevelNode((currentLevelNode) => {
|
|
63
|
+
if (currentLevelNode === nextLevelNode) {
|
|
64
|
+
return currentLevelNode
|
|
65
|
+
}
|
|
66
|
+
return nextLevelNode
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
if (!nextLevelNode) {
|
|
70
|
+
frameId = window.requestAnimationFrame(resolveLevelNode)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
resolveLevelNode()
|
|
75
|
+
|
|
76
|
+
return () => {
|
|
77
|
+
if (frameId) {
|
|
78
|
+
window.cancelAnimationFrame(frameId)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}, [levelId])
|
|
44
82
|
|
|
45
83
|
// When using portal, edit at Y_OFFSET (local to level)
|
|
46
84
|
// When not using portal, edit at world origin
|
|
@@ -75,6 +113,17 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
75
113
|
// The polygon to display (preview during drag, or actual polygon)
|
|
76
114
|
const displayPolygon = previewPolygon ?? polygon
|
|
77
115
|
|
|
116
|
+
const polygonCenter = useMemo(() => {
|
|
117
|
+
if (displayPolygon.length === 0) return [0, 0] as [number, number]
|
|
118
|
+
let sumX = 0
|
|
119
|
+
let sumZ = 0
|
|
120
|
+
for (const [x, z] of displayPolygon) {
|
|
121
|
+
sumX += x
|
|
122
|
+
sumZ += z
|
|
123
|
+
}
|
|
124
|
+
return [sumX / displayPolygon.length, sumZ / displayPolygon.length] as [number, number]
|
|
125
|
+
}, [displayPolygon])
|
|
126
|
+
|
|
78
127
|
// Calculate midpoints for adding new vertices
|
|
79
128
|
const midpoints = useMemo(() => {
|
|
80
129
|
if (displayPolygon.length < 2) return []
|
|
@@ -139,8 +188,8 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
139
188
|
// Listen to grid:move events to track cursor position
|
|
140
189
|
useEffect(() => {
|
|
141
190
|
const onGridMove = (event: GridEvent) => {
|
|
142
|
-
const gridX =
|
|
143
|
-
const gridZ =
|
|
191
|
+
const gridX = snapToHalf(event.localPosition[0])
|
|
192
|
+
const gridZ = snapToHalf(event.localPosition[2])
|
|
144
193
|
const newPosition: [number, number] = [gridX, gridZ]
|
|
145
194
|
|
|
146
195
|
// Play snap sound when cursor moves to a new grid cell during drag
|
|
@@ -158,7 +207,15 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
158
207
|
|
|
159
208
|
// Update vertex position during drag
|
|
160
209
|
if (dragState?.isDragging) {
|
|
161
|
-
|
|
210
|
+
if (dragState.mode === 'vertex' && dragState.vertexIndex !== null) {
|
|
211
|
+
handleVertexDrag(dragState.vertexIndex, newPosition)
|
|
212
|
+
} else if (dragState.mode === 'polygon') {
|
|
213
|
+
const deltaX = newPosition[0] - dragState.initialPosition[0]
|
|
214
|
+
const deltaZ = newPosition[1] - dragState.initialPosition[1]
|
|
215
|
+
setPreviewPolygon(
|
|
216
|
+
dragState.initialPolygon.map(([x, z]) => [x + deltaX, z + deltaZ] as [number, number]),
|
|
217
|
+
)
|
|
218
|
+
}
|
|
162
219
|
}
|
|
163
220
|
}
|
|
164
221
|
|
|
@@ -257,7 +314,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
257
314
|
{/* Vertex handles - blue cylinders that match surface height */}
|
|
258
315
|
{displayPolygon.map(([x, z], index) => {
|
|
259
316
|
const isHovered = hoveredVertex === index
|
|
260
|
-
const isDragging = dragState?.vertexIndex === index
|
|
317
|
+
const isDragging = dragState?.mode === 'vertex' && dragState.vertexIndex === index
|
|
261
318
|
const radius = 0.1
|
|
262
319
|
const height = Math.max(MIN_HANDLE_HEIGHT, surfaceHeight + 0.02)
|
|
263
320
|
|
|
@@ -282,8 +339,10 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
282
339
|
e.stopPropagation()
|
|
283
340
|
setDragState({
|
|
284
341
|
isDragging: true,
|
|
342
|
+
mode: 'vertex',
|
|
285
343
|
vertexIndex: index,
|
|
286
344
|
initialPosition: [x!, z!],
|
|
345
|
+
initialPolygon: displayPolygon.map(([px, pz]) => [px, pz] as [number, number]),
|
|
287
346
|
pointerId: e.pointerId,
|
|
288
347
|
})
|
|
289
348
|
}}
|
|
@@ -305,6 +364,37 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
305
364
|
)
|
|
306
365
|
})}
|
|
307
366
|
|
|
367
|
+
{allowPolygonMove && (
|
|
368
|
+
<mesh
|
|
369
|
+
castShadow
|
|
370
|
+
layers={EDITOR_LAYER}
|
|
371
|
+
onClick={(e) => {
|
|
372
|
+
if (e.button !== 0) return
|
|
373
|
+
e.stopPropagation()
|
|
374
|
+
}}
|
|
375
|
+
onPointerDown={(e) => {
|
|
376
|
+
if (e.button !== 0) return
|
|
377
|
+
e.stopPropagation()
|
|
378
|
+
setDragState({
|
|
379
|
+
isDragging: true,
|
|
380
|
+
mode: 'polygon',
|
|
381
|
+
vertexIndex: null,
|
|
382
|
+
initialPosition: polygonCenter,
|
|
383
|
+
initialPolygon: displayPolygon.map(([px, pz]) => [px, pz] as [number, number]),
|
|
384
|
+
pointerId: e.pointerId,
|
|
385
|
+
})
|
|
386
|
+
}}
|
|
387
|
+
position={[
|
|
388
|
+
polygonCenter[0],
|
|
389
|
+
editY + Math.max(MIN_HANDLE_HEIGHT, surfaceHeight + 0.02) + 0.08,
|
|
390
|
+
polygonCenter[1],
|
|
391
|
+
]}
|
|
392
|
+
>
|
|
393
|
+
<sphereGeometry args={[0.09, 20, 20]} />
|
|
394
|
+
<meshStandardMaterial color={dragState?.mode === 'polygon' ? '#22c55e' : '#f59e0b'} />
|
|
395
|
+
</mesh>
|
|
396
|
+
)}
|
|
397
|
+
|
|
308
398
|
{/* Midpoint handles - smaller green cylinders for adding vertices (hidden while dragging) */}
|
|
309
399
|
{!dragState &&
|
|
310
400
|
midpoints.map(([x, z], index) => {
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type AnyNodeId,
|
|
5
|
+
emitter,
|
|
6
|
+
type FenceNode,
|
|
7
|
+
type GridEvent,
|
|
8
|
+
type LevelNode,
|
|
9
|
+
type SlabNode,
|
|
10
|
+
useScene,
|
|
11
|
+
type WallNode,
|
|
12
|
+
} from '@pascal-app/core'
|
|
13
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
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 from '../../../store/use-editor'
|
|
18
|
+
import { snapFenceDraftPoint } from '../fence/fence-drafting'
|
|
19
|
+
import { CursorSphere } from '../shared/cursor-sphere'
|
|
20
|
+
|
|
21
|
+
function translatePolygon(
|
|
22
|
+
polygon: Array<[number, number]>,
|
|
23
|
+
deltaX: number,
|
|
24
|
+
deltaZ: number,
|
|
25
|
+
): Array<[number, number]> {
|
|
26
|
+
return polygon.map(([x, z]) => [x + deltaX, z + deltaZ] as [number, number])
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function getPolygonCenter(polygon: Array<[number, number]>): [number, number] {
|
|
30
|
+
if (polygon.length === 0) return [0, 0]
|
|
31
|
+
let sumX = 0
|
|
32
|
+
let sumZ = 0
|
|
33
|
+
for (const [x, z] of polygon) {
|
|
34
|
+
sumX += x
|
|
35
|
+
sumZ += z
|
|
36
|
+
}
|
|
37
|
+
return [sumX / polygon.length, sumZ / polygon.length]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
|
|
41
|
+
const activatedAtRef = useRef<number>(Date.now())
|
|
42
|
+
const originalPolygonRef = useRef(node.polygon.map(([x, z]) => [x, z] as [number, number]))
|
|
43
|
+
const originalHolesRef = useRef(
|
|
44
|
+
(node.holes ?? []).map((hole) => hole.map(([x, z]) => [x, z] as [number, number])),
|
|
45
|
+
)
|
|
46
|
+
const dragAnchorRef = useRef<[number, number] | null>(null)
|
|
47
|
+
const previousGridPosRef = useRef<[number, number] | null>(null)
|
|
48
|
+
const previewRef = useRef<{
|
|
49
|
+
polygon: Array<[number, number]>
|
|
50
|
+
holes: Array<Array<[number, number]>>
|
|
51
|
+
} | null>(null)
|
|
52
|
+
|
|
53
|
+
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
|
|
54
|
+
const center = getPolygonCenter(node.polygon)
|
|
55
|
+
return [center[0], 0, center[1]]
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
const exitMoveMode = useCallback(() => {
|
|
59
|
+
useEditor.getState().setMovingNode(null)
|
|
60
|
+
}, [])
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
const originalPolygon = originalPolygonRef.current
|
|
64
|
+
const originalHoles = originalHolesRef.current
|
|
65
|
+
const levelNode =
|
|
66
|
+
node.parentId && useScene.getState().nodes[node.parentId as AnyNodeId]?.type === 'level'
|
|
67
|
+
? (useScene.getState().nodes[node.parentId as AnyNodeId] as LevelNode)
|
|
68
|
+
: null
|
|
69
|
+
const levelChildren = levelNode?.children ?? []
|
|
70
|
+
const levelWalls = levelChildren
|
|
71
|
+
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
|
|
72
|
+
.filter((child): child is WallNode => child?.type === 'wall')
|
|
73
|
+
const levelFences = levelChildren
|
|
74
|
+
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
|
|
75
|
+
.filter((child): child is FenceNode => child?.type === 'fence')
|
|
76
|
+
|
|
77
|
+
useScene.temporal.getState().pause()
|
|
78
|
+
let wasCommitted = false
|
|
79
|
+
|
|
80
|
+
const applyPreview = (
|
|
81
|
+
polygon: Array<[number, number]>,
|
|
82
|
+
holes: Array<Array<[number, number]>>,
|
|
83
|
+
) => {
|
|
84
|
+
previewRef.current = { polygon, holes }
|
|
85
|
+
const center = getPolygonCenter(polygon)
|
|
86
|
+
setCursorLocalPos([center[0], 0, center[1]])
|
|
87
|
+
useScene.getState().updateNode(node.id, { polygon, holes })
|
|
88
|
+
useScene.getState().markDirty(node.id as AnyNodeId)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const restoreOriginal = () => {
|
|
92
|
+
useScene.getState().updateNode(node.id, {
|
|
93
|
+
holes: originalHoles,
|
|
94
|
+
polygon: originalPolygon,
|
|
95
|
+
})
|
|
96
|
+
useScene.getState().markDirty(node.id as AnyNodeId)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const onGridMove = (event: GridEvent) => {
|
|
100
|
+
const [localX, localZ] = snapFenceDraftPoint({
|
|
101
|
+
point: [event.localPosition[0], event.localPosition[2]],
|
|
102
|
+
walls: levelWalls,
|
|
103
|
+
fences: levelFences,
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
if (
|
|
107
|
+
previousGridPosRef.current &&
|
|
108
|
+
(localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1])
|
|
109
|
+
) {
|
|
110
|
+
sfxEmitter.emit('sfx:grid-snap')
|
|
111
|
+
}
|
|
112
|
+
previousGridPosRef.current = [localX, localZ]
|
|
113
|
+
|
|
114
|
+
const anchor = dragAnchorRef.current ?? [localX, localZ]
|
|
115
|
+
dragAnchorRef.current = anchor
|
|
116
|
+
|
|
117
|
+
const deltaX = localX - anchor[0]
|
|
118
|
+
const deltaZ = localZ - anchor[1]
|
|
119
|
+
|
|
120
|
+
applyPreview(
|
|
121
|
+
translatePolygon(originalPolygon, deltaX, deltaZ),
|
|
122
|
+
originalHoles.map((hole) => translatePolygon(hole, deltaX, deltaZ)),
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const onGridClick = (event: GridEvent) => {
|
|
127
|
+
if (Date.now() - activatedAtRef.current < 150) {
|
|
128
|
+
event.nativeEvent?.stopPropagation?.()
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const preview = previewRef.current ?? { polygon: originalPolygon, holes: originalHoles }
|
|
133
|
+
|
|
134
|
+
wasCommitted = true
|
|
135
|
+
|
|
136
|
+
// Restore original baseline while paused so the next resume+update
|
|
137
|
+
// registers as a single tracked change (undo reverts to original).
|
|
138
|
+
useScene.getState().updateNode(node.id, {
|
|
139
|
+
polygon: originalPolygon,
|
|
140
|
+
holes: originalHoles,
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
useScene.temporal.getState().resume()
|
|
144
|
+
useScene.getState().updateNode(node.id, preview)
|
|
145
|
+
useScene.getState().markDirty(node.id as AnyNodeId)
|
|
146
|
+
useScene.temporal.getState().pause()
|
|
147
|
+
|
|
148
|
+
sfxEmitter.emit('sfx:item-place')
|
|
149
|
+
useViewer.getState().setSelection({ selectedIds: [node.id] })
|
|
150
|
+
exitMoveMode()
|
|
151
|
+
event.nativeEvent?.stopPropagation?.()
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const onCancel = () => {
|
|
155
|
+
restoreOriginal()
|
|
156
|
+
useViewer.getState().setSelection({ selectedIds: [node.id] })
|
|
157
|
+
useScene.temporal.getState().resume()
|
|
158
|
+
markToolCancelConsumed()
|
|
159
|
+
exitMoveMode()
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
emitter.on('grid:move', onGridMove)
|
|
163
|
+
emitter.on('grid:click', onGridClick)
|
|
164
|
+
emitter.on('tool:cancel', onCancel)
|
|
165
|
+
|
|
166
|
+
return () => {
|
|
167
|
+
if (!wasCommitted) {
|
|
168
|
+
restoreOriginal()
|
|
169
|
+
}
|
|
170
|
+
useScene.temporal.getState().resume()
|
|
171
|
+
emitter.off('grid:move', onGridMove)
|
|
172
|
+
emitter.off('grid:click', onGridClick)
|
|
173
|
+
emitter.off('tool:cancel', onCancel)
|
|
174
|
+
}
|
|
175
|
+
}, [exitMoveMode, node.id])
|
|
176
|
+
|
|
177
|
+
return (
|
|
178
|
+
<group>
|
|
179
|
+
<CursorSphere position={cursorLocalPos} showTooltip={false} />
|
|
180
|
+
</group>
|
|
181
|
+
)
|
|
182
|
+
}
|
|
@@ -93,11 +93,21 @@ function commitStairPlacement(
|
|
|
93
93
|
position: [0, 0, 0],
|
|
94
94
|
})
|
|
95
95
|
|
|
96
|
+
const sortedLevels = Object.values(nodes)
|
|
97
|
+
.filter((node): node is LevelNode => node.type === 'level')
|
|
98
|
+
.sort((left, right) => left.level - right.level)
|
|
99
|
+
const currentLevelIndex = sortedLevels.findIndex((level) => level.id === levelId)
|
|
100
|
+
const nextLevelId = sortedLevels[currentLevelIndex + 1]?.id ?? levelId
|
|
101
|
+
|
|
96
102
|
const stair = StairNode.parse({
|
|
97
103
|
name,
|
|
98
104
|
position,
|
|
99
105
|
rotation,
|
|
100
106
|
stairType: DEFAULT_STAIR_TYPE,
|
|
107
|
+
fromLevelId: levelId,
|
|
108
|
+
toLevelId: nextLevelId,
|
|
109
|
+
slabOpeningMode: 'destination',
|
|
110
|
+
openingOffset: 0.08,
|
|
101
111
|
width: DEFAULT_STAIR_WIDTH,
|
|
102
112
|
totalRise: DEFAULT_STAIR_HEIGHT,
|
|
103
113
|
stepCount: DEFAULT_STAIR_STEP_COUNT,
|
|
@@ -166,9 +176,7 @@ export const StairTool: React.FC = () => {
|
|
|
166
176
|
|
|
167
177
|
const gridX = Math.round(event.localPosition[0] * 2) / 2
|
|
168
178
|
const gridZ = Math.round(event.localPosition[2] * 2) / 2
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
commitStairPlacement(currentLevelId, [gridX, y, gridZ], rotationRef.current)
|
|
179
|
+
commitStairPlacement(currentLevelId, [gridX, 0, gridZ], rotationRef.current)
|
|
172
180
|
}
|
|
173
181
|
|
|
174
182
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
@@ -11,7 +11,9 @@ import { CeilingBoundaryEditor } from './ceiling/ceiling-boundary-editor'
|
|
|
11
11
|
import { CeilingHoleEditor } from './ceiling/ceiling-hole-editor'
|
|
12
12
|
import { CeilingTool } from './ceiling/ceiling-tool'
|
|
13
13
|
import { DoorTool } from './door/door-tool'
|
|
14
|
+
import { CurveFenceTool } from './fence/curve-fence-tool'
|
|
14
15
|
import { FenceTool } from './fence/fence-tool'
|
|
16
|
+
import { MoveFenceEndpointTool } from './fence/move-fence-endpoint-tool'
|
|
15
17
|
import { ItemTool } from './item/item-tool'
|
|
16
18
|
import { MoveTool } from './item/move-tool'
|
|
17
19
|
import { RoofTool } from './roof/roof-tool'
|
|
@@ -20,6 +22,8 @@ import { SlabBoundaryEditor } from './slab/slab-boundary-editor'
|
|
|
20
22
|
import { SlabHoleEditor } from './slab/slab-hole-editor'
|
|
21
23
|
import { SlabTool } from './slab/slab-tool'
|
|
22
24
|
import { StairTool } from './stair/stair-tool'
|
|
25
|
+
import { CurveWallTool } from './wall/curve-wall-tool'
|
|
26
|
+
import { MoveWallEndpointTool } from './wall/move-wall-endpoint-tool'
|
|
23
27
|
import { WallTool } from './wall/wall-tool'
|
|
24
28
|
import { WindowTool } from './window/window-tool'
|
|
25
29
|
import { ZoneBoundaryEditor } from './zone/zone-boundary-editor'
|
|
@@ -51,6 +55,10 @@ export const ToolManager: React.FC = () => {
|
|
|
51
55
|
const mode = useEditor((state) => state.mode)
|
|
52
56
|
const tool = useEditor((state) => state.tool)
|
|
53
57
|
const movingNode = useEditor((state) => state.movingNode)
|
|
58
|
+
const movingWallEndpoint = useEditor((state) => state.movingWallEndpoint)
|
|
59
|
+
const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint)
|
|
60
|
+
const curvingWall = useEditor((state) => state.curvingWall)
|
|
61
|
+
const curvingFence = useEditor((state) => state.curvingFence)
|
|
54
62
|
const editingHole = useEditor((state) => state.editingHole)
|
|
55
63
|
const selectedZoneId = useViewer((state) => state.selection.zoneId)
|
|
56
64
|
const buildingId = useViewer((state) => state.selection.buildingId)
|
|
@@ -140,6 +148,10 @@ export const ToolManager: React.FC = () => {
|
|
|
140
148
|
{showCeilingHoleEditor && selectedCeilingId && editingHole && (
|
|
141
149
|
<CeilingHoleEditor ceilingId={selectedCeilingId} holeIndex={editingHole.holeIndex} />
|
|
142
150
|
)}
|
|
151
|
+
{movingWallEndpoint && <MoveWallEndpointTool target={movingWallEndpoint} />}
|
|
152
|
+
{movingFenceEndpoint && <MoveFenceEndpointTool target={movingFenceEndpoint} />}
|
|
153
|
+
{curvingWall && <CurveWallTool node={curvingWall} />}
|
|
154
|
+
{curvingFence && <CurveFenceTool node={curvingFence} />}
|
|
143
155
|
{movingNode && movingNode.type !== 'building' && <MoveTool />}
|
|
144
156
|
{!movingNode && BuildToolComponent && <BuildToolComponent />}
|
|
145
157
|
</group>
|