@pascal-app/editor 0.4.0 → 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 +341 -48
- package/src/components/editor/floating-building-action-menu.tsx +70 -0
- package/src/components/editor/floorplan-panel.tsx +1350 -722
- package/src/components/editor/index.tsx +221 -167
- package/src/components/editor/node-action-menu.tsx +40 -11
- package/src/components/editor/selection-manager.tsx +238 -10
- package/src/components/editor/site-edge-labels.tsx +9 -3
- package/src/components/editor/thumbnail-generator.tsx +422 -79
- 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/systems/stair/stair-edit-system.tsx +27 -5
- package/src/components/tools/building/move-building-tool.tsx +157 -0
- 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-math.ts +1 -1
- package/src/components/tools/door/door-tool.tsx +31 -7
- package/src/components/tools/door/move-door-tool.tsx +27 -8
- package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
- package/src/components/tools/fence/fence-drafting.ts +137 -0
- package/src/components/tools/fence/fence-tool.tsx +190 -0
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +327 -0
- package/src/components/tools/fence/move-fence-tool.tsx +231 -0
- package/src/components/tools/item/item-tool.tsx +3 -3
- package/src/components/tools/item/move-tool.tsx +16 -0
- package/src/components/tools/item/placement-math.ts +14 -6
- package/src/components/tools/item/placement-strategies.ts +17 -9
- package/src/components/tools/item/use-placement-coordinator.tsx +123 -16
- package/src/components/tools/roof/move-roof-tool.tsx +90 -26
- package/src/components/tools/roof/roof-tool.tsx +6 -6
- package/src/components/tools/select/box-select-tool.tsx +2 -2
- 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/slab/slab-tool.tsx +4 -4
- package/src/components/tools/stair/stair-defaults.ts +10 -0
- package/src/components/tools/stair/stair-tool.tsx +39 -8
- package/src/components/tools/tool-manager.tsx +54 -14
- 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/wall/wall-tool.tsx +19 -29
- package/src/components/tools/window/move-window-tool.tsx +27 -8
- package/src/components/tools/window/window-math.ts +1 -1
- package/src/components/tools/window/window-tool.tsx +31 -7
- package/src/components/tools/zone/zone-tool.tsx +7 -7
- package/src/components/ui/action-menu/control-modes.tsx +9 -4
- package/src/components/ui/action-menu/structure-tools.tsx +1 -0
- 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/helpers/building-helper.tsx +32 -0
- package/src/components/ui/helpers/helper-manager.tsx +2 -0
- 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 +269 -0
- package/src/components/ui/panels/item-panel.tsx +5 -5
- package/src/components/ui/panels/panel-manager.tsx +32 -27
- 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 +377 -50
- 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 +28 -17
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +65 -53
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +40 -25
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +69 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +88 -72
- package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +14 -13
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +64 -53
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +32 -23
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +72 -51
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +40 -37
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +72 -51
- package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +13 -13
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +20 -17
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +62 -54
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +40 -25
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +27 -28
- package/src/components/ui/viewer-toolbar.tsx +55 -2
- package/src/components/viewer-overlay.tsx +26 -19
- package/src/hooks/use-auto-save.ts +3 -6
- package/src/hooks/use-contextual-tools.ts +25 -16
- package/src/hooks/use-grid-events.ts +13 -1
- package/src/hooks/use-keyboard.ts +7 -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 +125 -10
|
@@ -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
|
+
}
|
|
@@ -86,12 +86,12 @@ export const SlabTool: React.FC = () => {
|
|
|
86
86
|
const onGridMove = (event: GridEvent) => {
|
|
87
87
|
if (!cursorRef.current) return
|
|
88
88
|
|
|
89
|
-
const gridX = Math.round(event.
|
|
90
|
-
const gridZ = Math.round(event.
|
|
89
|
+
const gridX = Math.round(event.localPosition[0] * 2) / 2
|
|
90
|
+
const gridZ = Math.round(event.localPosition[2] * 2) / 2
|
|
91
91
|
const gridPosition: [number, number] = [gridX, gridZ]
|
|
92
92
|
|
|
93
93
|
setCursorPosition(gridPosition)
|
|
94
|
-
setLevelY(event.
|
|
94
|
+
setLevelY(event.localPosition[1])
|
|
95
95
|
|
|
96
96
|
// Calculate snapped display position (bypass snap when Shift is held)
|
|
97
97
|
const lastPoint = points[points.length - 1]
|
|
@@ -112,7 +112,7 @@ export const SlabTool: React.FC = () => {
|
|
|
112
112
|
}
|
|
113
113
|
|
|
114
114
|
previousSnappedPointRef.current = displayPoint
|
|
115
|
-
cursorRef.current.position.set(displayPoint[0], event.
|
|
115
|
+
cursorRef.current.position.set(displayPoint[0], event.localPosition[1], displayPoint[1])
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
const onGridClick = (_event: GridEvent) => {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export const DEFAULT_STAIR_TYPE = 'straight' as const
|
|
1
2
|
export const DEFAULT_STAIR_WIDTH = 1.0
|
|
2
3
|
export const DEFAULT_STAIR_LENGTH = 3.0
|
|
3
4
|
export const DEFAULT_STAIR_HEIGHT = 2.5
|
|
@@ -5,3 +6,12 @@ export const DEFAULT_STAIR_STEP_COUNT = 10
|
|
|
5
6
|
export const DEFAULT_STAIR_ATTACHMENT_SIDE = 'front' as const
|
|
6
7
|
export const DEFAULT_STAIR_FILL_TO_FLOOR = true
|
|
7
8
|
export const DEFAULT_STAIR_THICKNESS = 0.25
|
|
9
|
+
export const DEFAULT_CURVED_STAIR_INNER_RADIUS = 0.9
|
|
10
|
+
export const DEFAULT_CURVED_STAIR_SWEEP_ANGLE = Math.PI / 2
|
|
11
|
+
export const DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE = (400 * Math.PI) / 180
|
|
12
|
+
export const DEFAULT_SPIRAL_TOP_LANDING_MODE = 'none' as const
|
|
13
|
+
export const DEFAULT_SPIRAL_TOP_LANDING_DEPTH = 0.9
|
|
14
|
+
export const DEFAULT_SPIRAL_SHOW_CENTER_COLUMN = true
|
|
15
|
+
export const DEFAULT_SPIRAL_SHOW_STEP_SUPPORTS = true
|
|
16
|
+
export const DEFAULT_STAIR_RAILING_MODE = 'right' as const
|
|
17
|
+
export const DEFAULT_STAIR_RAILING_HEIGHT = 0.92
|
|
@@ -13,12 +13,21 @@ import * as THREE from 'three'
|
|
|
13
13
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
14
14
|
import { CursorSphere } from '../shared/cursor-sphere'
|
|
15
15
|
import {
|
|
16
|
+
DEFAULT_CURVED_STAIR_INNER_RADIUS,
|
|
17
|
+
DEFAULT_CURVED_STAIR_SWEEP_ANGLE,
|
|
18
|
+
DEFAULT_SPIRAL_SHOW_CENTER_COLUMN,
|
|
19
|
+
DEFAULT_SPIRAL_SHOW_STEP_SUPPORTS,
|
|
20
|
+
DEFAULT_SPIRAL_TOP_LANDING_DEPTH,
|
|
21
|
+
DEFAULT_SPIRAL_TOP_LANDING_MODE,
|
|
16
22
|
DEFAULT_STAIR_ATTACHMENT_SIDE,
|
|
17
23
|
DEFAULT_STAIR_FILL_TO_FLOOR,
|
|
18
24
|
DEFAULT_STAIR_HEIGHT,
|
|
19
25
|
DEFAULT_STAIR_LENGTH,
|
|
26
|
+
DEFAULT_STAIR_RAILING_HEIGHT,
|
|
27
|
+
DEFAULT_STAIR_RAILING_MODE,
|
|
20
28
|
DEFAULT_STAIR_STEP_COUNT,
|
|
21
29
|
DEFAULT_STAIR_THICKNESS,
|
|
30
|
+
DEFAULT_STAIR_TYPE,
|
|
22
31
|
DEFAULT_STAIR_WIDTH,
|
|
23
32
|
} from './stair-defaults'
|
|
24
33
|
|
|
@@ -84,10 +93,34 @@ function commitStairPlacement(
|
|
|
84
93
|
position: [0, 0, 0],
|
|
85
94
|
})
|
|
86
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
|
+
|
|
87
102
|
const stair = StairNode.parse({
|
|
88
103
|
name,
|
|
89
104
|
position,
|
|
90
105
|
rotation,
|
|
106
|
+
stairType: DEFAULT_STAIR_TYPE,
|
|
107
|
+
fromLevelId: levelId,
|
|
108
|
+
toLevelId: nextLevelId,
|
|
109
|
+
slabOpeningMode: 'destination',
|
|
110
|
+
openingOffset: 0.08,
|
|
111
|
+
width: DEFAULT_STAIR_WIDTH,
|
|
112
|
+
totalRise: DEFAULT_STAIR_HEIGHT,
|
|
113
|
+
stepCount: DEFAULT_STAIR_STEP_COUNT,
|
|
114
|
+
thickness: DEFAULT_STAIR_THICKNESS,
|
|
115
|
+
fillToFloor: DEFAULT_STAIR_FILL_TO_FLOOR,
|
|
116
|
+
innerRadius: DEFAULT_CURVED_STAIR_INNER_RADIUS,
|
|
117
|
+
sweepAngle: DEFAULT_CURVED_STAIR_SWEEP_ANGLE,
|
|
118
|
+
topLandingMode: DEFAULT_SPIRAL_TOP_LANDING_MODE,
|
|
119
|
+
topLandingDepth: DEFAULT_SPIRAL_TOP_LANDING_DEPTH,
|
|
120
|
+
showCenterColumn: DEFAULT_SPIRAL_SHOW_CENTER_COLUMN,
|
|
121
|
+
showStepSupports: DEFAULT_SPIRAL_SHOW_STEP_SUPPORTS,
|
|
122
|
+
railingHeight: DEFAULT_STAIR_RAILING_HEIGHT,
|
|
123
|
+
railingMode: DEFAULT_STAIR_RAILING_MODE,
|
|
91
124
|
children: [segment.id],
|
|
92
125
|
})
|
|
93
126
|
|
|
@@ -116,9 +149,9 @@ export const StairTool: React.FC = () => {
|
|
|
116
149
|
if (previewRef.current) previewRef.current.rotation.y = 0
|
|
117
150
|
|
|
118
151
|
const onGridMove = (event: GridEvent) => {
|
|
119
|
-
const gridX = Math.round(event.
|
|
120
|
-
const gridZ = Math.round(event.
|
|
121
|
-
const y = event.
|
|
152
|
+
const gridX = Math.round(event.localPosition[0] * 2) / 2
|
|
153
|
+
const gridZ = Math.round(event.localPosition[2] * 2) / 2
|
|
154
|
+
const y = event.localPosition[1]
|
|
122
155
|
|
|
123
156
|
if (cursorRef.current) {
|
|
124
157
|
cursorRef.current.position.set(gridX, y + GRID_OFFSET, gridZ)
|
|
@@ -141,11 +174,9 @@ export const StairTool: React.FC = () => {
|
|
|
141
174
|
const onGridClick = (event: GridEvent) => {
|
|
142
175
|
if (!currentLevelId) return
|
|
143
176
|
|
|
144
|
-
const gridX = Math.round(event.
|
|
145
|
-
const gridZ = Math.round(event.
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
commitStairPlacement(currentLevelId, [gridX, y, gridZ], rotationRef.current)
|
|
177
|
+
const gridX = Math.round(event.localPosition[0] * 2) / 2
|
|
178
|
+
const gridZ = Math.round(event.localPosition[2] * 2) / 2
|
|
179
|
+
commitStairPlacement(currentLevelId, [gridX, 0, gridZ], rotationRef.current)
|
|
149
180
|
}
|
|
150
181
|
|
|
151
182
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
@@ -1,10 +1,19 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type AnyNodeId,
|
|
3
|
+
type BuildingNode,
|
|
4
|
+
type CeilingNode,
|
|
5
|
+
type SlabNode,
|
|
6
|
+
useScene,
|
|
7
|
+
} from '@pascal-app/core'
|
|
2
8
|
import { useViewer } from '@pascal-app/viewer'
|
|
3
9
|
import useEditor, { type Phase, type Tool } from '../../store/use-editor'
|
|
4
10
|
import { CeilingBoundaryEditor } from './ceiling/ceiling-boundary-editor'
|
|
5
11
|
import { CeilingHoleEditor } from './ceiling/ceiling-hole-editor'
|
|
6
12
|
import { CeilingTool } from './ceiling/ceiling-tool'
|
|
7
13
|
import { DoorTool } from './door/door-tool'
|
|
14
|
+
import { CurveFenceTool } from './fence/curve-fence-tool'
|
|
15
|
+
import { FenceTool } from './fence/fence-tool'
|
|
16
|
+
import { MoveFenceEndpointTool } from './fence/move-fence-endpoint-tool'
|
|
8
17
|
import { ItemTool } from './item/item-tool'
|
|
9
18
|
import { MoveTool } from './item/move-tool'
|
|
10
19
|
import { RoofTool } from './roof/roof-tool'
|
|
@@ -13,6 +22,8 @@ import { SlabBoundaryEditor } from './slab/slab-boundary-editor'
|
|
|
13
22
|
import { SlabHoleEditor } from './slab/slab-hole-editor'
|
|
14
23
|
import { SlabTool } from './slab/slab-tool'
|
|
15
24
|
import { StairTool } from './stair/stair-tool'
|
|
25
|
+
import { CurveWallTool } from './wall/curve-wall-tool'
|
|
26
|
+
import { MoveWallEndpointTool } from './wall/move-wall-endpoint-tool'
|
|
16
27
|
import { WallTool } from './wall/wall-tool'
|
|
17
28
|
import { WindowTool } from './window/window-tool'
|
|
18
29
|
import { ZoneBoundaryEditor } from './zone/zone-boundary-editor'
|
|
@@ -24,6 +35,7 @@ const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = {
|
|
|
24
35
|
},
|
|
25
36
|
structure: {
|
|
26
37
|
wall: WallTool,
|
|
38
|
+
fence: FenceTool,
|
|
27
39
|
slab: SlabTool,
|
|
28
40
|
ceiling: CeilingTool,
|
|
29
41
|
roof: RoofTool,
|
|
@@ -43,11 +55,24 @@ export const ToolManager: React.FC = () => {
|
|
|
43
55
|
const mode = useEditor((state) => state.mode)
|
|
44
56
|
const tool = useEditor((state) => state.tool)
|
|
45
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)
|
|
46
62
|
const editingHole = useEditor((state) => state.editingHole)
|
|
47
63
|
const selectedZoneId = useViewer((state) => state.selection.zoneId)
|
|
64
|
+
const buildingId = useViewer((state) => state.selection.buildingId)
|
|
48
65
|
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
|
49
66
|
const nodes = useScene((state) => state.nodes)
|
|
50
67
|
|
|
68
|
+
// Building transform for the local group — all building-relative tools live inside this group
|
|
69
|
+
// so their cursor positions and committed data are naturally in building-local space.
|
|
70
|
+
const building = buildingId
|
|
71
|
+
? (nodes[buildingId as AnyNodeId] as BuildingNode | undefined)
|
|
72
|
+
: undefined
|
|
73
|
+
const buildingPosition = building?.position ?? [0, 0, 0]
|
|
74
|
+
const buildingRotation = building?.rotation ?? [0, 0, 0]
|
|
75
|
+
|
|
51
76
|
// Check if a slab is selected
|
|
52
77
|
const selectedSlabId = selectedIds.find((id) => nodes[id as AnyNodeId]?.type === 'slab') as
|
|
53
78
|
| SlabNode['id']
|
|
@@ -102,19 +127,34 @@ export const ToolManager: React.FC = () => {
|
|
|
102
127
|
return (
|
|
103
128
|
<>
|
|
104
129
|
{showSiteBoundaryEditor && <SiteBoundaryEditor />}
|
|
105
|
-
{
|
|
106
|
-
{
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
130
|
+
{/* World-space tools: site boundary and building movement operate in world coordinates */}
|
|
131
|
+
{movingNode?.type === 'building' && <MoveTool />}
|
|
132
|
+
|
|
133
|
+
{/* Building-local group: all other tools are relative to the selected building.
|
|
134
|
+
Cursor visuals set positions in building-local space; this group applies the
|
|
135
|
+
building's world transform so they render at the correct world position. */}
|
|
136
|
+
<group
|
|
137
|
+
position={buildingPosition as [number, number, number]}
|
|
138
|
+
rotation={buildingRotation as [number, number, number]}
|
|
139
|
+
>
|
|
140
|
+
{showZoneBoundaryEditor && selectedZoneId && <ZoneBoundaryEditor zoneId={selectedZoneId} />}
|
|
141
|
+
{showSlabBoundaryEditor && selectedSlabId && <SlabBoundaryEditor slabId={selectedSlabId} />}
|
|
142
|
+
{showSlabHoleEditor && selectedSlabId && editingHole && (
|
|
143
|
+
<SlabHoleEditor holeIndex={editingHole.holeIndex} slabId={selectedSlabId} />
|
|
144
|
+
)}
|
|
145
|
+
{showCeilingBoundaryEditor && selectedCeilingId && (
|
|
146
|
+
<CeilingBoundaryEditor ceilingId={selectedCeilingId} />
|
|
147
|
+
)}
|
|
148
|
+
{showCeilingHoleEditor && selectedCeilingId && editingHole && (
|
|
149
|
+
<CeilingHoleEditor ceilingId={selectedCeilingId} holeIndex={editingHole.holeIndex} />
|
|
150
|
+
)}
|
|
151
|
+
{movingWallEndpoint && <MoveWallEndpointTool target={movingWallEndpoint} />}
|
|
152
|
+
{movingFenceEndpoint && <MoveFenceEndpointTool target={movingFenceEndpoint} />}
|
|
153
|
+
{curvingWall && <CurveWallTool node={curvingWall} />}
|
|
154
|
+
{curvingFence && <CurveFenceTool node={curvingFence} />}
|
|
155
|
+
{movingNode && movingNode.type !== 'building' && <MoveTool />}
|
|
156
|
+
{!movingNode && BuildToolComponent && <BuildToolComponent />}
|
|
157
|
+
</group>
|
|
118
158
|
</>
|
|
119
159
|
)
|
|
120
160
|
}
|