@pascal-app/editor 0.4.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 +62 -0
- package/src/components/editor/custom-camera-controls.tsx +387 -0
- package/src/components/editor/editor-layout-v2.tsx +220 -0
- package/src/components/editor/export-manager.tsx +78 -0
- package/src/components/editor/first-person-controls.tsx +249 -0
- package/src/components/editor/floating-action-menu.tsx +231 -0
- package/src/components/editor/floorplan-panel.tsx +9609 -0
- package/src/components/editor/grid.tsx +161 -0
- package/src/components/editor/index.tsx +928 -0
- package/src/components/editor/node-action-menu.tsx +66 -0
- package/src/components/editor/preset-thumbnail-generator.tsx +125 -0
- package/src/components/editor/selection-manager.tsx +897 -0
- package/src/components/editor/site-edge-labels.tsx +90 -0
- package/src/components/editor/thumbnail-generator.tsx +166 -0
- package/src/components/editor/wall-measurement-label.tsx +258 -0
- package/src/components/feedback-dialog.tsx +265 -0
- package/src/components/pascal-radio.tsx +280 -0
- package/src/components/preview-button.tsx +16 -0
- package/src/components/systems/ceiling/ceiling-system.tsx +77 -0
- package/src/components/systems/roof/roof-edit-system.tsx +69 -0
- package/src/components/systems/stair/stair-edit-system.tsx +69 -0
- package/src/components/systems/zone/zone-label-editor-system.tsx +320 -0
- package/src/components/systems/zone/zone-system.tsx +87 -0
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +42 -0
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +47 -0
- package/src/components/tools/ceiling/ceiling-tool.tsx +465 -0
- package/src/components/tools/door/door-math.ts +110 -0
- package/src/components/tools/door/door-tool.tsx +293 -0
- package/src/components/tools/door/move-door-tool.tsx +373 -0
- package/src/components/tools/item/item-tool.tsx +26 -0
- package/src/components/tools/item/move-tool.tsx +90 -0
- package/src/components/tools/item/placement-math.ts +85 -0
- package/src/components/tools/item/placement-strategies.ts +556 -0
- package/src/components/tools/item/placement-types.ts +117 -0
- package/src/components/tools/item/use-draft-node.ts +227 -0
- package/src/components/tools/item/use-placement-coordinator.tsx +877 -0
- package/src/components/tools/roof/move-roof-tool.tsx +288 -0
- package/src/components/tools/roof/roof-tool.tsx +318 -0
- package/src/components/tools/select/box-select-tool.tsx +626 -0
- package/src/components/tools/shared/cursor-sphere.tsx +119 -0
- package/src/components/tools/shared/polygon-editor.tsx +361 -0
- package/src/components/tools/site/site-boundary-editor.tsx +42 -0
- package/src/components/tools/slab/slab-boundary-editor.tsx +42 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +47 -0
- package/src/components/tools/slab/slab-tool.tsx +322 -0
- package/src/components/tools/stair/stair-defaults.ts +7 -0
- package/src/components/tools/stair/stair-tool.tsx +194 -0
- package/src/components/tools/tool-manager.tsx +120 -0
- package/src/components/tools/wall/wall-drafting.ts +140 -0
- package/src/components/tools/wall/wall-tool.tsx +210 -0
- package/src/components/tools/window/move-window-tool.tsx +410 -0
- package/src/components/tools/window/window-math.ts +117 -0
- package/src/components/tools/window/window-tool.tsx +303 -0
- package/src/components/tools/zone/zone-boundary-editor.tsx +39 -0
- package/src/components/tools/zone/zone-tool.tsx +364 -0
- package/src/components/ui/action-menu/action-button.tsx +59 -0
- package/src/components/ui/action-menu/camera-actions.tsx +74 -0
- package/src/components/ui/action-menu/control-modes.tsx +240 -0
- package/src/components/ui/action-menu/furnish-tools.tsx +102 -0
- package/src/components/ui/action-menu/index.tsx +152 -0
- package/src/components/ui/action-menu/structure-tools.tsx +100 -0
- package/src/components/ui/action-menu/view-toggles.tsx +397 -0
- package/src/components/ui/command-palette/editor-commands.tsx +396 -0
- package/src/components/ui/command-palette/index.tsx +730 -0
- package/src/components/ui/controls/action-button.tsx +33 -0
- package/src/components/ui/controls/material-picker.tsx +194 -0
- package/src/components/ui/controls/metric-control.tsx +262 -0
- package/src/components/ui/controls/panel-section.tsx +65 -0
- package/src/components/ui/controls/segmented-control.tsx +45 -0
- package/src/components/ui/controls/slider-control.tsx +245 -0
- package/src/components/ui/controls/toggle-control.tsx +38 -0
- package/src/components/ui/floating-level-selector.tsx +355 -0
- package/src/components/ui/helpers/ceiling-helper.tsx +20 -0
- package/src/components/ui/helpers/helper-manager.tsx +33 -0
- package/src/components/ui/helpers/item-helper.tsx +40 -0
- package/src/components/ui/helpers/roof-helper.tsx +16 -0
- package/src/components/ui/helpers/slab-helper.tsx +20 -0
- package/src/components/ui/helpers/wall-helper.tsx +20 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1580 -0
- package/src/components/ui/item-catalog/item-catalog.tsx +219 -0
- package/src/components/ui/panels/ceiling-panel.tsx +230 -0
- package/src/components/ui/panels/collections/collections-popover.tsx +356 -0
- package/src/components/ui/panels/door-panel.tsx +600 -0
- package/src/components/ui/panels/item-panel.tsx +306 -0
- package/src/components/ui/panels/panel-manager.tsx +59 -0
- package/src/components/ui/panels/panel-wrapper.tsx +80 -0
- package/src/components/ui/panels/presets/presets-popover.tsx +511 -0
- package/src/components/ui/panels/reference-panel.tsx +177 -0
- package/src/components/ui/panels/roof-panel.tsx +262 -0
- package/src/components/ui/panels/roof-segment-panel.tsx +326 -0
- package/src/components/ui/panels/slab-panel.tsx +228 -0
- package/src/components/ui/panels/stair-panel.tsx +304 -0
- package/src/components/ui/panels/stair-segment-panel.tsx +339 -0
- package/src/components/ui/panels/wall-panel.tsx +123 -0
- package/src/components/ui/panels/window-panel.tsx +441 -0
- package/src/components/ui/primitives/button.tsx +69 -0
- package/src/components/ui/primitives/card.tsx +75 -0
- package/src/components/ui/primitives/color-dot.tsx +61 -0
- package/src/components/ui/primitives/context-menu.tsx +227 -0
- package/src/components/ui/primitives/dialog.tsx +129 -0
- package/src/components/ui/primitives/dropdown-menu.tsx +228 -0
- package/src/components/ui/primitives/error-boundary.tsx +52 -0
- package/src/components/ui/primitives/input.tsx +21 -0
- package/src/components/ui/primitives/number-input.tsx +187 -0
- package/src/components/ui/primitives/opacity-control.tsx +79 -0
- package/src/components/ui/primitives/popover.tsx +42 -0
- package/src/components/ui/primitives/separator.tsx +28 -0
- package/src/components/ui/primitives/sheet.tsx +130 -0
- package/src/components/ui/primitives/shortcut-token.tsx +64 -0
- package/src/components/ui/primitives/sidebar.tsx +855 -0
- package/src/components/ui/primitives/skeleton.tsx +13 -0
- package/src/components/ui/primitives/slider.tsx +58 -0
- package/src/components/ui/primitives/switch.tsx +29 -0
- package/src/components/ui/primitives/tooltip.tsx +57 -0
- package/src/components/ui/scene-loader.tsx +40 -0
- package/src/components/ui/sidebar/app-sidebar.tsx +103 -0
- package/src/components/ui/sidebar/icon-rail.tsx +147 -0
- package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +100 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +438 -0
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +188 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +80 -0
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +126 -0
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +64 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +1543 -0
- package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +98 -0
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +117 -0
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +65 -0
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +214 -0
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +96 -0
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +216 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +115 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node-drag.tsx +342 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +271 -0
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +106 -0
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +64 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +87 -0
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +167 -0
- package/src/components/ui/sidebar/tab-bar.tsx +39 -0
- package/src/components/ui/slider-demo.tsx +36 -0
- package/src/components/ui/slider.tsx +81 -0
- package/src/components/ui/viewer-toolbar.tsx +342 -0
- package/src/components/viewer-overlay.tsx +499 -0
- package/src/components/viewer-zone-system.tsx +48 -0
- package/src/contexts/presets-context.tsx +121 -0
- package/src/hooks/use-auto-save.ts +194 -0
- package/src/hooks/use-contextual-tools.ts +52 -0
- package/src/hooks/use-grid-events.ts +106 -0
- package/src/hooks/use-keyboard.ts +214 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/hooks/use-reduced-motion.ts +20 -0
- package/src/index.tsx +33 -0
- package/src/lib/constants.ts +3 -0
- package/src/lib/level-selection.ts +31 -0
- package/src/lib/scene.ts +394 -0
- package/src/lib/sfx/index.ts +2 -0
- package/src/lib/sfx-bus.ts +49 -0
- package/src/lib/sfx-player.ts +60 -0
- package/src/lib/utils.ts +43 -0
- package/src/store/use-audio.tsx +45 -0
- package/src/store/use-command-registry.ts +36 -0
- package/src/store/use-editor.tsx +522 -0
- package/src/store/use-palette-view-registry.ts +45 -0
- package/src/store/use-upload.ts +90 -0
- package/src/three-types.ts +3 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { emitter, type GridEvent, type LevelNode, SlabNode, useScene } from '@pascal-app/core'
|
|
2
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
3
|
+
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
4
|
+
import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three'
|
|
5
|
+
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
|
6
|
+
import { EDITOR_LAYER } from '../../../lib/constants'
|
|
7
|
+
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
8
|
+
import { CursorSphere } from '../shared/cursor-sphere'
|
|
9
|
+
|
|
10
|
+
const Y_OFFSET = 0.02
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Snaps a point to the nearest axis-aligned or 45-degree diagonal from the last point
|
|
14
|
+
*/
|
|
15
|
+
const calculateSnapPoint = (
|
|
16
|
+
lastPoint: [number, number],
|
|
17
|
+
currentPoint: [number, number],
|
|
18
|
+
): [number, number] => {
|
|
19
|
+
const [x1, y1] = lastPoint
|
|
20
|
+
const [x, y] = currentPoint
|
|
21
|
+
|
|
22
|
+
const dx = x - x1
|
|
23
|
+
const dy = y - y1
|
|
24
|
+
const absDx = Math.abs(dx)
|
|
25
|
+
const absDy = Math.abs(dy)
|
|
26
|
+
|
|
27
|
+
// Calculate distances to horizontal, vertical, and diagonal lines
|
|
28
|
+
const horizontalDist = absDy
|
|
29
|
+
const verticalDist = absDx
|
|
30
|
+
const diagonalDist = Math.abs(absDx - absDy)
|
|
31
|
+
|
|
32
|
+
// Find the minimum distance to determine which axis to snap to
|
|
33
|
+
const minDist = Math.min(horizontalDist, verticalDist, diagonalDist)
|
|
34
|
+
|
|
35
|
+
if (minDist === diagonalDist) {
|
|
36
|
+
// Snap to 45° diagonal
|
|
37
|
+
const diagonalLength = Math.min(absDx, absDy)
|
|
38
|
+
return [x1 + Math.sign(dx) * diagonalLength, y1 + Math.sign(dy) * diagonalLength]
|
|
39
|
+
}
|
|
40
|
+
if (minDist === horizontalDist) {
|
|
41
|
+
// Snap to horizontal
|
|
42
|
+
return [x, y1]
|
|
43
|
+
}
|
|
44
|
+
// Snap to vertical
|
|
45
|
+
return [x1, y]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Creates a slab with the given polygon points and returns its ID
|
|
50
|
+
*/
|
|
51
|
+
const commitSlabDrawing = (levelId: LevelNode['id'], points: Array<[number, number]>): string => {
|
|
52
|
+
const { createNode, nodes } = useScene.getState()
|
|
53
|
+
|
|
54
|
+
// Count existing slabs for naming
|
|
55
|
+
const slabCount = Object.values(nodes).filter((n) => n.type === 'slab').length
|
|
56
|
+
const name = `Slab ${slabCount + 1}`
|
|
57
|
+
|
|
58
|
+
const slab = SlabNode.parse({
|
|
59
|
+
name,
|
|
60
|
+
polygon: points,
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
createNode(slab, levelId)
|
|
64
|
+
sfxEmitter.emit('sfx:structure-build')
|
|
65
|
+
return slab.id
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const SlabTool: React.FC = () => {
|
|
69
|
+
const cursorRef = useRef<Group>(null)
|
|
70
|
+
const mainLineRef = useRef<Line>(null!)
|
|
71
|
+
const closingLineRef = useRef<Line>(null!)
|
|
72
|
+
const currentLevelId = useViewer((state) => state.selection.levelId)
|
|
73
|
+
const setSelection = useViewer((state) => state.setSelection)
|
|
74
|
+
|
|
75
|
+
const [points, setPoints] = useState<Array<[number, number]>>([])
|
|
76
|
+
const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])
|
|
77
|
+
const [snappedCursorPosition, setSnappedCursorPosition] = useState<[number, number]>([0, 0])
|
|
78
|
+
const [levelY, setLevelY] = useState(0)
|
|
79
|
+
const previousSnappedPointRef = useRef<[number, number] | null>(null)
|
|
80
|
+
const shiftPressed = useRef(false)
|
|
81
|
+
|
|
82
|
+
// Update cursor position and lines on grid move
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (!currentLevelId) return
|
|
85
|
+
|
|
86
|
+
const onGridMove = (event: GridEvent) => {
|
|
87
|
+
if (!cursorRef.current) return
|
|
88
|
+
|
|
89
|
+
const gridX = Math.round(event.position[0] * 2) / 2
|
|
90
|
+
const gridZ = Math.round(event.position[2] * 2) / 2
|
|
91
|
+
const gridPosition: [number, number] = [gridX, gridZ]
|
|
92
|
+
|
|
93
|
+
setCursorPosition(gridPosition)
|
|
94
|
+
setLevelY(event.position[1])
|
|
95
|
+
|
|
96
|
+
// Calculate snapped display position (bypass snap when Shift is held)
|
|
97
|
+
const lastPoint = points[points.length - 1]
|
|
98
|
+
const displayPoint =
|
|
99
|
+
shiftPressed.current || !lastPoint
|
|
100
|
+
? gridPosition
|
|
101
|
+
: calculateSnapPoint(lastPoint, gridPosition)
|
|
102
|
+
setSnappedCursorPosition(displayPoint)
|
|
103
|
+
|
|
104
|
+
// Play snap sound when the snapped position actually changes (only when drawing)
|
|
105
|
+
if (
|
|
106
|
+
points.length > 0 &&
|
|
107
|
+
previousSnappedPointRef.current &&
|
|
108
|
+
(displayPoint[0] !== previousSnappedPointRef.current[0] ||
|
|
109
|
+
displayPoint[1] !== previousSnappedPointRef.current[1])
|
|
110
|
+
) {
|
|
111
|
+
sfxEmitter.emit('sfx:grid-snap')
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
previousSnappedPointRef.current = displayPoint
|
|
115
|
+
cursorRef.current.position.set(displayPoint[0], event.position[1], displayPoint[1])
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const onGridClick = (_event: GridEvent) => {
|
|
119
|
+
if (!currentLevelId) return
|
|
120
|
+
|
|
121
|
+
// Use the last displayed snapped position (respects Shift state from onGridMove)
|
|
122
|
+
const clickPoint = previousSnappedPointRef.current ?? cursorPosition
|
|
123
|
+
|
|
124
|
+
// Check if clicking on the first point to close the shape
|
|
125
|
+
const firstPoint = points[0]
|
|
126
|
+
if (
|
|
127
|
+
points.length >= 3 &&
|
|
128
|
+
firstPoint &&
|
|
129
|
+
Math.abs(clickPoint[0] - firstPoint[0]) < 0.25 &&
|
|
130
|
+
Math.abs(clickPoint[1] - firstPoint[1]) < 0.25
|
|
131
|
+
) {
|
|
132
|
+
// Create the slab and select it
|
|
133
|
+
const slabId = commitSlabDrawing(currentLevelId, points)
|
|
134
|
+
setSelection({ selectedIds: [slabId] })
|
|
135
|
+
setPoints([])
|
|
136
|
+
} else {
|
|
137
|
+
// Add point to polygon
|
|
138
|
+
setPoints([...points, clickPoint])
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const onGridDoubleClick = (_event: GridEvent) => {
|
|
143
|
+
if (!currentLevelId) return
|
|
144
|
+
|
|
145
|
+
// Need at least 3 points to form a polygon
|
|
146
|
+
if (points.length >= 3) {
|
|
147
|
+
const slabId = commitSlabDrawing(currentLevelId, points)
|
|
148
|
+
setSelection({ selectedIds: [slabId] })
|
|
149
|
+
setPoints([])
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const onCancel = () => {
|
|
154
|
+
if (points.length > 0) markToolCancelConsumed()
|
|
155
|
+
setPoints([])
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
159
|
+
if (e.key === 'Shift') shiftPressed.current = true
|
|
160
|
+
}
|
|
161
|
+
const onKeyUp = (e: KeyboardEvent) => {
|
|
162
|
+
if (e.key === 'Shift') shiftPressed.current = false
|
|
163
|
+
}
|
|
164
|
+
document.addEventListener('keydown', onKeyDown)
|
|
165
|
+
document.addEventListener('keyup', onKeyUp)
|
|
166
|
+
|
|
167
|
+
emitter.on('grid:move', onGridMove)
|
|
168
|
+
emitter.on('grid:click', onGridClick)
|
|
169
|
+
emitter.on('grid:double-click', onGridDoubleClick)
|
|
170
|
+
emitter.on('tool:cancel', onCancel)
|
|
171
|
+
|
|
172
|
+
return () => {
|
|
173
|
+
document.removeEventListener('keydown', onKeyDown)
|
|
174
|
+
document.removeEventListener('keyup', onKeyUp)
|
|
175
|
+
emitter.off('grid:move', onGridMove)
|
|
176
|
+
emitter.off('grid:click', onGridClick)
|
|
177
|
+
emitter.off('grid:double-click', onGridDoubleClick)
|
|
178
|
+
emitter.off('tool:cancel', onCancel)
|
|
179
|
+
}
|
|
180
|
+
}, [currentLevelId, points, cursorPosition, setSelection])
|
|
181
|
+
|
|
182
|
+
// Update line geometries when points change
|
|
183
|
+
useEffect(() => {
|
|
184
|
+
if (!(mainLineRef.current && closingLineRef.current)) return
|
|
185
|
+
|
|
186
|
+
if (points.length === 0) {
|
|
187
|
+
mainLineRef.current.visible = false
|
|
188
|
+
closingLineRef.current.visible = false
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const y = levelY + Y_OFFSET
|
|
193
|
+
const snappedCursor = snappedCursorPosition
|
|
194
|
+
|
|
195
|
+
// Build main line points
|
|
196
|
+
const linePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, y, z))
|
|
197
|
+
linePoints.push(new Vector3(snappedCursor[0], y, snappedCursor[1]))
|
|
198
|
+
|
|
199
|
+
// Update main line
|
|
200
|
+
if (linePoints.length >= 2) {
|
|
201
|
+
mainLineRef.current.geometry.dispose()
|
|
202
|
+
mainLineRef.current.geometry = new BufferGeometry().setFromPoints(linePoints)
|
|
203
|
+
mainLineRef.current.visible = true
|
|
204
|
+
} else {
|
|
205
|
+
mainLineRef.current.visible = false
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Update closing line (from cursor back to first point)
|
|
209
|
+
const firstPoint = points[0]
|
|
210
|
+
if (points.length >= 2 && firstPoint) {
|
|
211
|
+
const closingPoints = [
|
|
212
|
+
new Vector3(snappedCursor[0], y, snappedCursor[1]),
|
|
213
|
+
new Vector3(firstPoint[0], y, firstPoint[1]),
|
|
214
|
+
]
|
|
215
|
+
closingLineRef.current.geometry.dispose()
|
|
216
|
+
closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints)
|
|
217
|
+
closingLineRef.current.visible = true
|
|
218
|
+
} else {
|
|
219
|
+
closingLineRef.current.visible = false
|
|
220
|
+
}
|
|
221
|
+
}, [points, snappedCursorPosition, levelY])
|
|
222
|
+
|
|
223
|
+
// Create preview shape when we have 3+ points
|
|
224
|
+
const previewShape = useMemo(() => {
|
|
225
|
+
if (points.length < 3) return null
|
|
226
|
+
|
|
227
|
+
const snappedCursor = snappedCursorPosition
|
|
228
|
+
|
|
229
|
+
const allPoints = [...points, snappedCursor]
|
|
230
|
+
|
|
231
|
+
// THREE.Shape is in X-Y plane. After rotation of -PI/2 around X:
|
|
232
|
+
// - Shape X -> World X
|
|
233
|
+
// - Shape Y -> World -Z (so we negate Z to get correct orientation)
|
|
234
|
+
const firstPt = allPoints[0]
|
|
235
|
+
if (!firstPt) return null
|
|
236
|
+
|
|
237
|
+
const shape = new Shape()
|
|
238
|
+
shape.moveTo(firstPt[0], -firstPt[1])
|
|
239
|
+
|
|
240
|
+
for (let i = 1; i < allPoints.length; i++) {
|
|
241
|
+
const pt = allPoints[i]
|
|
242
|
+
if (pt) {
|
|
243
|
+
shape.lineTo(pt[0], -pt[1])
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
shape.closePath()
|
|
247
|
+
|
|
248
|
+
return shape
|
|
249
|
+
}, [points, snappedCursorPosition])
|
|
250
|
+
|
|
251
|
+
return (
|
|
252
|
+
<group>
|
|
253
|
+
{/* Cursor */}
|
|
254
|
+
<CursorSphere ref={cursorRef} />
|
|
255
|
+
|
|
256
|
+
{/* Preview fill */}
|
|
257
|
+
{previewShape && (
|
|
258
|
+
<mesh
|
|
259
|
+
frustumCulled={false}
|
|
260
|
+
layers={EDITOR_LAYER}
|
|
261
|
+
position={[0, levelY + Y_OFFSET, 0]}
|
|
262
|
+
rotation={[-Math.PI / 2, 0, 0]}
|
|
263
|
+
>
|
|
264
|
+
<shapeGeometry args={[previewShape]} />
|
|
265
|
+
<meshBasicMaterial
|
|
266
|
+
color="#818cf8"
|
|
267
|
+
depthTest={false}
|
|
268
|
+
opacity={0.15}
|
|
269
|
+
side={DoubleSide}
|
|
270
|
+
transparent
|
|
271
|
+
/>
|
|
272
|
+
</mesh>
|
|
273
|
+
)}
|
|
274
|
+
|
|
275
|
+
{/* Main line */}
|
|
276
|
+
{/* @ts-ignore */}
|
|
277
|
+
<line
|
|
278
|
+
frustumCulled={false}
|
|
279
|
+
layers={EDITOR_LAYER}
|
|
280
|
+
// @ts-expect-error
|
|
281
|
+
ref={mainLineRef}
|
|
282
|
+
renderOrder={1}
|
|
283
|
+
visible={false}
|
|
284
|
+
>
|
|
285
|
+
<bufferGeometry />
|
|
286
|
+
<lineBasicNodeMaterial color="#818cf8" depthTest={false} depthWrite={false} linewidth={3} />
|
|
287
|
+
</line>
|
|
288
|
+
|
|
289
|
+
{/* Closing line */}
|
|
290
|
+
{/* @ts-ignore */}
|
|
291
|
+
<line
|
|
292
|
+
frustumCulled={false}
|
|
293
|
+
layers={EDITOR_LAYER}
|
|
294
|
+
// @ts-expect-error
|
|
295
|
+
ref={closingLineRef}
|
|
296
|
+
renderOrder={1}
|
|
297
|
+
visible={false}
|
|
298
|
+
>
|
|
299
|
+
<bufferGeometry />
|
|
300
|
+
<lineBasicNodeMaterial
|
|
301
|
+
color="#818cf8"
|
|
302
|
+
depthTest={false}
|
|
303
|
+
depthWrite={false}
|
|
304
|
+
linewidth={2}
|
|
305
|
+
opacity={0.5}
|
|
306
|
+
transparent
|
|
307
|
+
/>
|
|
308
|
+
</line>
|
|
309
|
+
|
|
310
|
+
{/* Point markers */}
|
|
311
|
+
{points.map(([x, z], index) => (
|
|
312
|
+
<CursorSphere
|
|
313
|
+
color="#818cf8"
|
|
314
|
+
height={0}
|
|
315
|
+
key={index}
|
|
316
|
+
position={[x, levelY + Y_OFFSET + 0.01, z]}
|
|
317
|
+
showTooltip={false}
|
|
318
|
+
/>
|
|
319
|
+
))}
|
|
320
|
+
</group>
|
|
321
|
+
)
|
|
322
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export const DEFAULT_STAIR_WIDTH = 1.0
|
|
2
|
+
export const DEFAULT_STAIR_LENGTH = 3.0
|
|
3
|
+
export const DEFAULT_STAIR_HEIGHT = 2.5
|
|
4
|
+
export const DEFAULT_STAIR_STEP_COUNT = 10
|
|
5
|
+
export const DEFAULT_STAIR_ATTACHMENT_SIDE = 'front' as const
|
|
6
|
+
export const DEFAULT_STAIR_FILL_TO_FLOOR = true
|
|
7
|
+
export const DEFAULT_STAIR_THICKNESS = 0.25
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AnyNode,
|
|
3
|
+
emitter,
|
|
4
|
+
type GridEvent,
|
|
5
|
+
type LevelNode,
|
|
6
|
+
StairNode,
|
|
7
|
+
StairSegmentNode,
|
|
8
|
+
useScene,
|
|
9
|
+
} from '@pascal-app/core'
|
|
10
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
11
|
+
import { useEffect, useMemo, useRef } from 'react'
|
|
12
|
+
import * as THREE from 'three'
|
|
13
|
+
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
14
|
+
import { CursorSphere } from '../shared/cursor-sphere'
|
|
15
|
+
import {
|
|
16
|
+
DEFAULT_STAIR_ATTACHMENT_SIDE,
|
|
17
|
+
DEFAULT_STAIR_FILL_TO_FLOOR,
|
|
18
|
+
DEFAULT_STAIR_HEIGHT,
|
|
19
|
+
DEFAULT_STAIR_LENGTH,
|
|
20
|
+
DEFAULT_STAIR_STEP_COUNT,
|
|
21
|
+
DEFAULT_STAIR_THICKNESS,
|
|
22
|
+
DEFAULT_STAIR_WIDTH,
|
|
23
|
+
} from './stair-defaults'
|
|
24
|
+
|
|
25
|
+
const GRID_OFFSET = 0.02
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Generates the step-profile geometry for the ghost preview.
|
|
29
|
+
* Same algorithm as StairSystem's generateStairSegmentGeometry.
|
|
30
|
+
*/
|
|
31
|
+
function createStairPreviewGeometry(): THREE.BufferGeometry {
|
|
32
|
+
const riserHeight = DEFAULT_STAIR_HEIGHT / DEFAULT_STAIR_STEP_COUNT
|
|
33
|
+
const treadDepth = DEFAULT_STAIR_LENGTH / DEFAULT_STAIR_STEP_COUNT
|
|
34
|
+
|
|
35
|
+
const shape = new THREE.Shape()
|
|
36
|
+
shape.moveTo(0, 0)
|
|
37
|
+
|
|
38
|
+
for (let i = 0; i < DEFAULT_STAIR_STEP_COUNT; i++) {
|
|
39
|
+
shape.lineTo(i * treadDepth, (i + 1) * riserHeight)
|
|
40
|
+
shape.lineTo((i + 1) * treadDepth, (i + 1) * riserHeight)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Fill to floor (absoluteHeight = 0)
|
|
44
|
+
shape.lineTo(DEFAULT_STAIR_LENGTH, 0)
|
|
45
|
+
shape.lineTo(0, 0)
|
|
46
|
+
|
|
47
|
+
const geometry = new THREE.ExtrudeGeometry(shape, {
|
|
48
|
+
steps: 1,
|
|
49
|
+
depth: DEFAULT_STAIR_WIDTH,
|
|
50
|
+
bevelEnabled: false,
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
// Rotate so extrusion is along X (width), shape profile in XZ plane
|
|
54
|
+
const matrix = new THREE.Matrix4()
|
|
55
|
+
matrix.makeRotationY(-Math.PI / 2)
|
|
56
|
+
matrix.setPosition(DEFAULT_STAIR_WIDTH / 2, 0, 0)
|
|
57
|
+
geometry.applyMatrix4(matrix)
|
|
58
|
+
|
|
59
|
+
return geometry
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Creates a stair group with one default stair segment at the given position/rotation.
|
|
64
|
+
*/
|
|
65
|
+
function commitStairPlacement(
|
|
66
|
+
levelId: LevelNode['id'],
|
|
67
|
+
position: [number, number, number],
|
|
68
|
+
rotation: number,
|
|
69
|
+
): void {
|
|
70
|
+
const { createNodes, nodes } = useScene.getState()
|
|
71
|
+
|
|
72
|
+
const stairCount = Object.values(nodes).filter((n) => n.type === 'stair').length
|
|
73
|
+
const name = `Staircase ${stairCount + 1}`
|
|
74
|
+
|
|
75
|
+
const segment = StairSegmentNode.parse({
|
|
76
|
+
segmentType: 'stair',
|
|
77
|
+
width: DEFAULT_STAIR_WIDTH,
|
|
78
|
+
length: DEFAULT_STAIR_LENGTH,
|
|
79
|
+
height: DEFAULT_STAIR_HEIGHT,
|
|
80
|
+
stepCount: DEFAULT_STAIR_STEP_COUNT,
|
|
81
|
+
attachmentSide: DEFAULT_STAIR_ATTACHMENT_SIDE,
|
|
82
|
+
fillToFloor: DEFAULT_STAIR_FILL_TO_FLOOR,
|
|
83
|
+
thickness: DEFAULT_STAIR_THICKNESS,
|
|
84
|
+
position: [0, 0, 0],
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
const stair = StairNode.parse({
|
|
88
|
+
name,
|
|
89
|
+
position,
|
|
90
|
+
rotation,
|
|
91
|
+
children: [segment.id],
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
createNodes([
|
|
95
|
+
{ node: stair, parentId: levelId },
|
|
96
|
+
{ node: segment, parentId: stair.id },
|
|
97
|
+
])
|
|
98
|
+
|
|
99
|
+
sfxEmitter.emit('sfx:structure-build')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export const StairTool: React.FC = () => {
|
|
103
|
+
const cursorRef = useRef<THREE.Group>(null)
|
|
104
|
+
const previewRef = useRef<THREE.Group>(null)
|
|
105
|
+
const rotationRef = useRef(0)
|
|
106
|
+
const previousGridPosRef = useRef<[number, number] | null>(null)
|
|
107
|
+
const currentLevelId = useViewer((state) => state.selection.levelId)
|
|
108
|
+
|
|
109
|
+
const previewGeometry = useMemo(() => createStairPreviewGeometry(), [])
|
|
110
|
+
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
if (!currentLevelId) return
|
|
113
|
+
|
|
114
|
+
// Reset rotation when tool activates
|
|
115
|
+
rotationRef.current = 0
|
|
116
|
+
if (previewRef.current) previewRef.current.rotation.y = 0
|
|
117
|
+
|
|
118
|
+
const onGridMove = (event: GridEvent) => {
|
|
119
|
+
const gridX = Math.round(event.position[0] * 2) / 2
|
|
120
|
+
const gridZ = Math.round(event.position[2] * 2) / 2
|
|
121
|
+
const y = event.position[1]
|
|
122
|
+
|
|
123
|
+
if (cursorRef.current) {
|
|
124
|
+
cursorRef.current.position.set(gridX, y + GRID_OFFSET, gridZ)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (previewRef.current) {
|
|
128
|
+
previewRef.current.position.set(gridX, y, gridZ)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (
|
|
132
|
+
previousGridPosRef.current &&
|
|
133
|
+
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
|
|
134
|
+
) {
|
|
135
|
+
sfxEmitter.emit('sfx:grid-snap')
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
previousGridPosRef.current = [gridX, gridZ]
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const onGridClick = (event: GridEvent) => {
|
|
142
|
+
if (!currentLevelId) return
|
|
143
|
+
|
|
144
|
+
const gridX = Math.round(event.position[0] * 2) / 2
|
|
145
|
+
const gridZ = Math.round(event.position[2] * 2) / 2
|
|
146
|
+
const y = event.position[1]
|
|
147
|
+
|
|
148
|
+
commitStairPlacement(currentLevelId, [gridX, y, gridZ], rotationRef.current)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
152
|
+
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const ROTATION_STEP = Math.PI / 4
|
|
157
|
+
let rotationDelta = 0
|
|
158
|
+
if (event.key === 'r' || event.key === 'R') rotationDelta = ROTATION_STEP
|
|
159
|
+
else if (event.key === 't' || event.key === 'T') rotationDelta = -ROTATION_STEP
|
|
160
|
+
|
|
161
|
+
if (rotationDelta !== 0) {
|
|
162
|
+
event.preventDefault()
|
|
163
|
+
sfxEmitter.emit('sfx:item-rotate')
|
|
164
|
+
rotationRef.current += rotationDelta
|
|
165
|
+
if (previewRef.current) {
|
|
166
|
+
previewRef.current.rotation.y = rotationRef.current
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
emitter.on('grid:move', onGridMove)
|
|
172
|
+
emitter.on('grid:click', onGridClick)
|
|
173
|
+
window.addEventListener('keydown', onKeyDown)
|
|
174
|
+
|
|
175
|
+
return () => {
|
|
176
|
+
emitter.off('grid:move', onGridMove)
|
|
177
|
+
emitter.off('grid:click', onGridClick)
|
|
178
|
+
window.removeEventListener('keydown', onKeyDown)
|
|
179
|
+
}
|
|
180
|
+
}, [currentLevelId])
|
|
181
|
+
|
|
182
|
+
return (
|
|
183
|
+
<group>
|
|
184
|
+
<CursorSphere ref={cursorRef} />
|
|
185
|
+
|
|
186
|
+
{/* 3D ghost preview — position/rotation updated imperatively */}
|
|
187
|
+
<group ref={previewRef}>
|
|
188
|
+
<mesh castShadow geometry={previewGeometry}>
|
|
189
|
+
<meshStandardMaterial color="#818cf8" depthWrite={false} opacity={0.35} transparent />
|
|
190
|
+
</mesh>
|
|
191
|
+
</group>
|
|
192
|
+
</group>
|
|
193
|
+
)
|
|
194
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { type AnyNodeId, type CeilingNode, type SlabNode, useScene } from '@pascal-app/core'
|
|
2
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
3
|
+
import useEditor, { type Phase, type Tool } from '../../store/use-editor'
|
|
4
|
+
import { CeilingBoundaryEditor } from './ceiling/ceiling-boundary-editor'
|
|
5
|
+
import { CeilingHoleEditor } from './ceiling/ceiling-hole-editor'
|
|
6
|
+
import { CeilingTool } from './ceiling/ceiling-tool'
|
|
7
|
+
import { DoorTool } from './door/door-tool'
|
|
8
|
+
import { ItemTool } from './item/item-tool'
|
|
9
|
+
import { MoveTool } from './item/move-tool'
|
|
10
|
+
import { RoofTool } from './roof/roof-tool'
|
|
11
|
+
import { SiteBoundaryEditor } from './site/site-boundary-editor'
|
|
12
|
+
import { SlabBoundaryEditor } from './slab/slab-boundary-editor'
|
|
13
|
+
import { SlabHoleEditor } from './slab/slab-hole-editor'
|
|
14
|
+
import { SlabTool } from './slab/slab-tool'
|
|
15
|
+
import { StairTool } from './stair/stair-tool'
|
|
16
|
+
import { WallTool } from './wall/wall-tool'
|
|
17
|
+
import { WindowTool } from './window/window-tool'
|
|
18
|
+
import { ZoneBoundaryEditor } from './zone/zone-boundary-editor'
|
|
19
|
+
import { ZoneTool } from './zone/zone-tool'
|
|
20
|
+
|
|
21
|
+
const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = {
|
|
22
|
+
site: {
|
|
23
|
+
'property-line': SiteBoundaryEditor,
|
|
24
|
+
},
|
|
25
|
+
structure: {
|
|
26
|
+
wall: WallTool,
|
|
27
|
+
slab: SlabTool,
|
|
28
|
+
ceiling: CeilingTool,
|
|
29
|
+
roof: RoofTool,
|
|
30
|
+
stair: StairTool,
|
|
31
|
+
door: DoorTool,
|
|
32
|
+
item: ItemTool,
|
|
33
|
+
zone: ZoneTool,
|
|
34
|
+
window: WindowTool,
|
|
35
|
+
},
|
|
36
|
+
furnish: {
|
|
37
|
+
item: ItemTool,
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const ToolManager: React.FC = () => {
|
|
42
|
+
const phase = useEditor((state) => state.phase)
|
|
43
|
+
const mode = useEditor((state) => state.mode)
|
|
44
|
+
const tool = useEditor((state) => state.tool)
|
|
45
|
+
const movingNode = useEditor((state) => state.movingNode)
|
|
46
|
+
const editingHole = useEditor((state) => state.editingHole)
|
|
47
|
+
const selectedZoneId = useViewer((state) => state.selection.zoneId)
|
|
48
|
+
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
|
49
|
+
const nodes = useScene((state) => state.nodes)
|
|
50
|
+
|
|
51
|
+
// Check if a slab is selected
|
|
52
|
+
const selectedSlabId = selectedIds.find((id) => nodes[id as AnyNodeId]?.type === 'slab') as
|
|
53
|
+
| SlabNode['id']
|
|
54
|
+
| undefined
|
|
55
|
+
|
|
56
|
+
// Check if a ceiling is selected
|
|
57
|
+
const selectedCeilingId = selectedIds.find((id) => nodes[id as AnyNodeId]?.type === 'ceiling') as
|
|
58
|
+
| CeilingNode['id']
|
|
59
|
+
| undefined
|
|
60
|
+
|
|
61
|
+
// Show site boundary editor when in site phase (toggle controls entry/exit)
|
|
62
|
+
const showSiteBoundaryEditor = phase === 'site'
|
|
63
|
+
|
|
64
|
+
// Show slab boundary editor when in structure/select mode with a slab selected (but not editing a hole)
|
|
65
|
+
const showSlabBoundaryEditor =
|
|
66
|
+
phase === 'structure' &&
|
|
67
|
+
mode === 'select' &&
|
|
68
|
+
selectedSlabId !== undefined &&
|
|
69
|
+
(!editingHole || editingHole.nodeId !== selectedSlabId)
|
|
70
|
+
|
|
71
|
+
// Show slab hole editor when editing a hole on the selected slab
|
|
72
|
+
const showSlabHoleEditor =
|
|
73
|
+
selectedSlabId !== undefined && editingHole !== null && editingHole.nodeId === selectedSlabId
|
|
74
|
+
|
|
75
|
+
// Show ceiling boundary editor when in structure/select mode with a ceiling selected (but not editing a hole)
|
|
76
|
+
const showCeilingBoundaryEditor =
|
|
77
|
+
phase === 'structure' &&
|
|
78
|
+
mode === 'select' &&
|
|
79
|
+
selectedCeilingId !== undefined &&
|
|
80
|
+
(!editingHole || editingHole.nodeId !== selectedCeilingId)
|
|
81
|
+
|
|
82
|
+
// Show ceiling hole editor when editing a hole on the selected ceiling
|
|
83
|
+
const showCeilingHoleEditor =
|
|
84
|
+
selectedCeilingId !== undefined &&
|
|
85
|
+
editingHole !== null &&
|
|
86
|
+
editingHole.nodeId === selectedCeilingId
|
|
87
|
+
|
|
88
|
+
// Show zone boundary editor when in structure/select mode with a zone selected
|
|
89
|
+
// Hide when editing a slab or ceiling to avoid overlapping handles
|
|
90
|
+
const showZoneBoundaryEditor =
|
|
91
|
+
phase === 'structure' &&
|
|
92
|
+
mode === 'select' &&
|
|
93
|
+
selectedZoneId !== null &&
|
|
94
|
+
!showSlabBoundaryEditor &&
|
|
95
|
+
!showCeilingBoundaryEditor
|
|
96
|
+
|
|
97
|
+
// Show build tools when in build mode
|
|
98
|
+
const showBuildTool = mode === 'build' && tool !== null
|
|
99
|
+
|
|
100
|
+
const BuildToolComponent = showBuildTool ? tools[phase]?.[tool] : null
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<>
|
|
104
|
+
{showSiteBoundaryEditor && <SiteBoundaryEditor />}
|
|
105
|
+
{showZoneBoundaryEditor && selectedZoneId && <ZoneBoundaryEditor zoneId={selectedZoneId} />}
|
|
106
|
+
{showSlabBoundaryEditor && selectedSlabId && <SlabBoundaryEditor slabId={selectedSlabId} />}
|
|
107
|
+
{showSlabHoleEditor && selectedSlabId && editingHole && (
|
|
108
|
+
<SlabHoleEditor holeIndex={editingHole.holeIndex} slabId={selectedSlabId} />
|
|
109
|
+
)}
|
|
110
|
+
{showCeilingBoundaryEditor && selectedCeilingId && (
|
|
111
|
+
<CeilingBoundaryEditor ceilingId={selectedCeilingId} />
|
|
112
|
+
)}
|
|
113
|
+
{showCeilingHoleEditor && selectedCeilingId && editingHole && (
|
|
114
|
+
<CeilingHoleEditor ceilingId={selectedCeilingId} holeIndex={editingHole.holeIndex} />
|
|
115
|
+
)}
|
|
116
|
+
{movingNode && <MoveTool />}
|
|
117
|
+
{!movingNode && BuildToolComponent && <BuildToolComponent />}
|
|
118
|
+
</>
|
|
119
|
+
)
|
|
120
|
+
}
|