@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,465 @@
|
|
|
1
|
+
import { CeilingNode, emitter, type GridEvent, type LevelNode, 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 { mix, positionLocal } from 'three/tsl'
|
|
6
|
+
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
|
7
|
+
import { EDITOR_LAYER } from '../../../lib/constants'
|
|
8
|
+
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
9
|
+
import { CursorSphere } from '../shared/cursor-sphere'
|
|
10
|
+
|
|
11
|
+
const CEILING_HEIGHT = 2.52
|
|
12
|
+
const GRID_OFFSET = 0.02
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Snaps a point to the nearest axis-aligned or 45-degree diagonal from the last point
|
|
16
|
+
*/
|
|
17
|
+
const calculateSnapPoint = (
|
|
18
|
+
lastPoint: [number, number],
|
|
19
|
+
currentPoint: [number, number],
|
|
20
|
+
): [number, number] => {
|
|
21
|
+
const [x1, y1] = lastPoint
|
|
22
|
+
const [x, y] = currentPoint
|
|
23
|
+
|
|
24
|
+
const dx = x - x1
|
|
25
|
+
const dy = y - y1
|
|
26
|
+
const absDx = Math.abs(dx)
|
|
27
|
+
const absDy = Math.abs(dy)
|
|
28
|
+
|
|
29
|
+
// Calculate distances to horizontal, vertical, and diagonal lines
|
|
30
|
+
const horizontalDist = absDy
|
|
31
|
+
const verticalDist = absDx
|
|
32
|
+
const diagonalDist = Math.abs(absDx - absDy)
|
|
33
|
+
|
|
34
|
+
// Find the minimum distance to determine which axis to snap to
|
|
35
|
+
const minDist = Math.min(horizontalDist, verticalDist, diagonalDist)
|
|
36
|
+
|
|
37
|
+
if (minDist === diagonalDist) {
|
|
38
|
+
// Snap to 45° diagonal
|
|
39
|
+
const diagonalLength = Math.min(absDx, absDy)
|
|
40
|
+
return [x1 + Math.sign(dx) * diagonalLength, y1 + Math.sign(dy) * diagonalLength]
|
|
41
|
+
}
|
|
42
|
+
if (minDist === horizontalDist) {
|
|
43
|
+
// Snap to horizontal
|
|
44
|
+
return [x, y1]
|
|
45
|
+
}
|
|
46
|
+
// Snap to vertical
|
|
47
|
+
return [x1, y]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Creates a ceiling with the given polygon points and returns its ID
|
|
52
|
+
*/
|
|
53
|
+
const commitCeilingDrawing = (
|
|
54
|
+
levelId: LevelNode['id'],
|
|
55
|
+
points: Array<[number, number]>,
|
|
56
|
+
): string => {
|
|
57
|
+
const { createNode, nodes } = useScene.getState()
|
|
58
|
+
|
|
59
|
+
// Count existing ceilings for naming
|
|
60
|
+
const ceilingCount = Object.values(nodes).filter((n) => n.type === 'ceiling').length
|
|
61
|
+
const name = `Ceiling ${ceilingCount + 1}`
|
|
62
|
+
|
|
63
|
+
const ceiling = CeilingNode.parse({
|
|
64
|
+
name,
|
|
65
|
+
polygon: points,
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
createNode(ceiling, levelId)
|
|
69
|
+
sfxEmitter.emit('sfx:structure-build')
|
|
70
|
+
return ceiling.id
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const CeilingTool: React.FC = () => {
|
|
74
|
+
const cursorRef = useRef<Group>(null)
|
|
75
|
+
const gridCursorRef = useRef<Group>(null)
|
|
76
|
+
const mainLineRef = useRef<Line>(null!)
|
|
77
|
+
const closingLineRef = useRef<Line>(null!)
|
|
78
|
+
const groundMainLineRef = useRef<Line>(null!)
|
|
79
|
+
const groundClosingLineRef = useRef<Line>(null!)
|
|
80
|
+
const verticalLineRef = useRef<Line>(null!)
|
|
81
|
+
const currentLevelId = useViewer((state) => state.selection.levelId)
|
|
82
|
+
const setSelection = useViewer((state) => state.setSelection)
|
|
83
|
+
|
|
84
|
+
const [points, setPoints] = useState<Array<[number, number]>>([])
|
|
85
|
+
const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])
|
|
86
|
+
const [snappedCursorPosition, setSnappedCursorPosition] = useState<[number, number]>([0, 0])
|
|
87
|
+
const [levelY, setLevelY] = useState(0)
|
|
88
|
+
const previousSnappedPointRef = useRef<[number, number] | null>(null)
|
|
89
|
+
const shiftPressed = useRef(false)
|
|
90
|
+
|
|
91
|
+
// Static geometry: local y goes 0 (grid) → H (ceiling), mesh is positioned at gridY
|
|
92
|
+
const verticalGeo = useMemo(
|
|
93
|
+
() =>
|
|
94
|
+
new BufferGeometry().setFromPoints([
|
|
95
|
+
new Vector3(0, 0, 0),
|
|
96
|
+
new Vector3(0, CEILING_HEIGHT - GRID_OFFSET, 0),
|
|
97
|
+
]),
|
|
98
|
+
[],
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
// opacityNode: positionLocal.y is 0 at grid, H at ceiling → fade from 0.6 to 0
|
|
102
|
+
const gradientOpacityNode = useMemo(
|
|
103
|
+
() => mix(0.6, 0.0, positionLocal.y.div(CEILING_HEIGHT - GRID_OFFSET).clamp()),
|
|
104
|
+
[],
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
// Update cursor position and lines on grid move
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (!currentLevelId) return
|
|
110
|
+
|
|
111
|
+
const onGridMove = (event: GridEvent) => {
|
|
112
|
+
if (!(cursorRef.current && gridCursorRef.current)) return
|
|
113
|
+
|
|
114
|
+
const gridX = Math.round(event.position[0] * 2) / 2
|
|
115
|
+
const gridZ = Math.round(event.position[2] * 2) / 2
|
|
116
|
+
const gridPosition: [number, number] = [gridX, gridZ]
|
|
117
|
+
|
|
118
|
+
setCursorPosition(gridPosition)
|
|
119
|
+
setLevelY(event.position[1])
|
|
120
|
+
|
|
121
|
+
const ceilingY = event.position[1] + CEILING_HEIGHT
|
|
122
|
+
const gridY = event.position[1] + GRID_OFFSET
|
|
123
|
+
|
|
124
|
+
// Calculate snapped display position (bypass snap when Shift is held)
|
|
125
|
+
const lastPoint = points[points.length - 1]
|
|
126
|
+
const displayPoint =
|
|
127
|
+
shiftPressed.current || !lastPoint
|
|
128
|
+
? gridPosition
|
|
129
|
+
: calculateSnapPoint(lastPoint, gridPosition)
|
|
130
|
+
setSnappedCursorPosition(displayPoint)
|
|
131
|
+
|
|
132
|
+
// Play snap sound when the snapped position actually changes (only when drawing)
|
|
133
|
+
if (
|
|
134
|
+
points.length > 0 &&
|
|
135
|
+
previousSnappedPointRef.current &&
|
|
136
|
+
(displayPoint[0] !== previousSnappedPointRef.current[0] ||
|
|
137
|
+
displayPoint[1] !== previousSnappedPointRef.current[1])
|
|
138
|
+
) {
|
|
139
|
+
sfxEmitter.emit('sfx:grid-snap')
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
previousSnappedPointRef.current = displayPoint
|
|
143
|
+
cursorRef.current.position.set(displayPoint[0], ceilingY, displayPoint[1])
|
|
144
|
+
gridCursorRef.current.position.set(displayPoint[0], gridY, displayPoint[1])
|
|
145
|
+
|
|
146
|
+
if (verticalLineRef.current) {
|
|
147
|
+
verticalLineRef.current.position.set(displayPoint[0], gridY, displayPoint[1])
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const onGridClick = (_event: GridEvent) => {
|
|
152
|
+
if (!currentLevelId) return
|
|
153
|
+
|
|
154
|
+
// Use the last displayed snapped position (respects Shift state from onGridMove)
|
|
155
|
+
const clickPoint = previousSnappedPointRef.current ?? cursorPosition
|
|
156
|
+
|
|
157
|
+
// Check if clicking on the first point to close the shape
|
|
158
|
+
const firstPoint = points[0]
|
|
159
|
+
if (
|
|
160
|
+
points.length >= 3 &&
|
|
161
|
+
firstPoint &&
|
|
162
|
+
Math.abs(clickPoint[0] - firstPoint[0]) < 0.25 &&
|
|
163
|
+
Math.abs(clickPoint[1] - firstPoint[1]) < 0.25
|
|
164
|
+
) {
|
|
165
|
+
// Create the ceiling and select it
|
|
166
|
+
const ceilingId = commitCeilingDrawing(currentLevelId, points)
|
|
167
|
+
setSelection({ selectedIds: [ceilingId] })
|
|
168
|
+
setPoints([])
|
|
169
|
+
} else {
|
|
170
|
+
// Add point to polygon
|
|
171
|
+
setPoints([...points, clickPoint])
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const onGridDoubleClick = (_event: GridEvent) => {
|
|
176
|
+
if (!currentLevelId) return
|
|
177
|
+
|
|
178
|
+
// Need at least 3 points to form a polygon
|
|
179
|
+
if (points.length >= 3) {
|
|
180
|
+
const ceilingId = commitCeilingDrawing(currentLevelId, points)
|
|
181
|
+
setSelection({ selectedIds: [ceilingId] })
|
|
182
|
+
setPoints([])
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const onCancel = () => {
|
|
187
|
+
if (points.length > 0) markToolCancelConsumed()
|
|
188
|
+
setPoints([])
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
192
|
+
if (e.key === 'Shift') shiftPressed.current = true
|
|
193
|
+
}
|
|
194
|
+
const onKeyUp = (e: KeyboardEvent) => {
|
|
195
|
+
if (e.key === 'Shift') shiftPressed.current = false
|
|
196
|
+
}
|
|
197
|
+
document.addEventListener('keydown', onKeyDown)
|
|
198
|
+
document.addEventListener('keyup', onKeyUp)
|
|
199
|
+
|
|
200
|
+
emitter.on('grid:move', onGridMove)
|
|
201
|
+
emitter.on('grid:click', onGridClick)
|
|
202
|
+
emitter.on('grid:double-click', onGridDoubleClick)
|
|
203
|
+
emitter.on('tool:cancel', onCancel)
|
|
204
|
+
|
|
205
|
+
return () => {
|
|
206
|
+
document.removeEventListener('keydown', onKeyDown)
|
|
207
|
+
document.removeEventListener('keyup', onKeyUp)
|
|
208
|
+
emitter.off('grid:move', onGridMove)
|
|
209
|
+
emitter.off('grid:click', onGridClick)
|
|
210
|
+
emitter.off('grid:double-click', onGridDoubleClick)
|
|
211
|
+
emitter.off('tool:cancel', onCancel)
|
|
212
|
+
}
|
|
213
|
+
}, [currentLevelId, points, cursorPosition, setSelection])
|
|
214
|
+
|
|
215
|
+
// Update line geometries when points change
|
|
216
|
+
useEffect(() => {
|
|
217
|
+
if (!(mainLineRef.current && closingLineRef.current)) return
|
|
218
|
+
|
|
219
|
+
if (points.length === 0) {
|
|
220
|
+
mainLineRef.current.visible = false
|
|
221
|
+
closingLineRef.current.visible = false
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const ceilingY = levelY + CEILING_HEIGHT
|
|
226
|
+
const snappedCursor = snappedCursorPosition
|
|
227
|
+
|
|
228
|
+
// Build main line points
|
|
229
|
+
const linePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, ceilingY, z))
|
|
230
|
+
linePoints.push(new Vector3(snappedCursor[0], ceilingY, snappedCursor[1]))
|
|
231
|
+
|
|
232
|
+
const gridY = levelY + GRID_OFFSET
|
|
233
|
+
const groundLinePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, gridY, z))
|
|
234
|
+
groundLinePoints.push(new Vector3(snappedCursor[0], gridY, snappedCursor[1]))
|
|
235
|
+
|
|
236
|
+
// Update main line
|
|
237
|
+
if (linePoints.length >= 2) {
|
|
238
|
+
mainLineRef.current.geometry.dispose()
|
|
239
|
+
mainLineRef.current.geometry = new BufferGeometry().setFromPoints(linePoints)
|
|
240
|
+
mainLineRef.current.visible = true
|
|
241
|
+
|
|
242
|
+
groundMainLineRef.current.geometry.dispose()
|
|
243
|
+
groundMainLineRef.current.geometry = new BufferGeometry().setFromPoints(groundLinePoints)
|
|
244
|
+
groundMainLineRef.current.visible = true
|
|
245
|
+
} else {
|
|
246
|
+
mainLineRef.current.visible = false
|
|
247
|
+
groundMainLineRef.current.visible = false
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Update closing line (from cursor back to first point)
|
|
251
|
+
const firstPoint = points[0]
|
|
252
|
+
if (points.length >= 2 && firstPoint) {
|
|
253
|
+
const closingPoints = [
|
|
254
|
+
new Vector3(snappedCursor[0], ceilingY, snappedCursor[1]),
|
|
255
|
+
new Vector3(firstPoint[0], ceilingY, firstPoint[1]),
|
|
256
|
+
]
|
|
257
|
+
closingLineRef.current.geometry.dispose()
|
|
258
|
+
closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints)
|
|
259
|
+
closingLineRef.current.visible = true
|
|
260
|
+
|
|
261
|
+
const groundClosingPoints = [
|
|
262
|
+
new Vector3(snappedCursor[0], gridY, snappedCursor[1]),
|
|
263
|
+
new Vector3(firstPoint[0], gridY, firstPoint[1]),
|
|
264
|
+
]
|
|
265
|
+
groundClosingLineRef.current.geometry.dispose()
|
|
266
|
+
groundClosingLineRef.current.geometry = new BufferGeometry().setFromPoints(
|
|
267
|
+
groundClosingPoints,
|
|
268
|
+
)
|
|
269
|
+
groundClosingLineRef.current.visible = true
|
|
270
|
+
} else {
|
|
271
|
+
closingLineRef.current.visible = false
|
|
272
|
+
groundClosingLineRef.current.visible = false
|
|
273
|
+
}
|
|
274
|
+
}, [points, snappedCursorPosition, levelY])
|
|
275
|
+
|
|
276
|
+
// Create preview shape when we have 3+ points
|
|
277
|
+
const previewShape = useMemo(() => {
|
|
278
|
+
if (points.length < 3) return null
|
|
279
|
+
|
|
280
|
+
const snappedCursor = snappedCursorPosition
|
|
281
|
+
|
|
282
|
+
const allPoints = [...points, snappedCursor]
|
|
283
|
+
|
|
284
|
+
// THREE.Shape is in X-Y plane. After rotation of -PI/2 around X:
|
|
285
|
+
// - Shape X -> World X
|
|
286
|
+
// - Shape Y -> World -Z (so we negate Z to get correct orientation)
|
|
287
|
+
const firstPt = allPoints[0]
|
|
288
|
+
if (!firstPt) return null
|
|
289
|
+
|
|
290
|
+
const shape = new Shape()
|
|
291
|
+
shape.moveTo(firstPt[0], -firstPt[1])
|
|
292
|
+
|
|
293
|
+
for (let i = 1; i < allPoints.length; i++) {
|
|
294
|
+
const pt = allPoints[i]
|
|
295
|
+
if (pt) {
|
|
296
|
+
shape.lineTo(pt[0], -pt[1])
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
shape.closePath()
|
|
300
|
+
|
|
301
|
+
return shape
|
|
302
|
+
}, [points, snappedCursorPosition])
|
|
303
|
+
|
|
304
|
+
return (
|
|
305
|
+
<group>
|
|
306
|
+
{/* Cursor at ceiling height */}
|
|
307
|
+
<CursorSphere ref={cursorRef} />
|
|
308
|
+
|
|
309
|
+
{/* Grid-level cursor indicator */}
|
|
310
|
+
<mesh
|
|
311
|
+
layers={EDITOR_LAYER}
|
|
312
|
+
ref={gridCursorRef}
|
|
313
|
+
renderOrder={2}
|
|
314
|
+
rotation={[-Math.PI / 2, 0, 0]}
|
|
315
|
+
>
|
|
316
|
+
<ringGeometry args={[0.15, 0.2, 32]} />
|
|
317
|
+
<meshBasicMaterial
|
|
318
|
+
color="#818cf8"
|
|
319
|
+
depthTest={false}
|
|
320
|
+
depthWrite={true}
|
|
321
|
+
opacity={0.5}
|
|
322
|
+
side={DoubleSide}
|
|
323
|
+
transparent
|
|
324
|
+
/>
|
|
325
|
+
</mesh>
|
|
326
|
+
|
|
327
|
+
{/* Vertical connector: local y=0 at grid, y=H at ceiling; position.y set to gridY on move */}
|
|
328
|
+
{/* @ts-ignore */}
|
|
329
|
+
<line geometry={verticalGeo} layers={EDITOR_LAYER} ref={verticalLineRef} renderOrder={1}>
|
|
330
|
+
<lineBasicNodeMaterial
|
|
331
|
+
color="#818cf8"
|
|
332
|
+
depthTest={false}
|
|
333
|
+
depthWrite={false}
|
|
334
|
+
opacityNode={gradientOpacityNode}
|
|
335
|
+
transparent
|
|
336
|
+
/>
|
|
337
|
+
</line>
|
|
338
|
+
|
|
339
|
+
{/* Preview fill (Top) */}
|
|
340
|
+
{previewShape && (
|
|
341
|
+
<mesh
|
|
342
|
+
frustumCulled={false}
|
|
343
|
+
layers={EDITOR_LAYER}
|
|
344
|
+
position={[0, levelY + CEILING_HEIGHT, 0]}
|
|
345
|
+
rotation={[-Math.PI / 2, 0, 0]}
|
|
346
|
+
>
|
|
347
|
+
<shapeGeometry args={[previewShape]} />
|
|
348
|
+
<meshBasicMaterial
|
|
349
|
+
color="#818cf8"
|
|
350
|
+
depthTest={false}
|
|
351
|
+
opacity={0.15}
|
|
352
|
+
side={DoubleSide}
|
|
353
|
+
transparent
|
|
354
|
+
/>
|
|
355
|
+
</mesh>
|
|
356
|
+
)}
|
|
357
|
+
|
|
358
|
+
{/* Preview fill (Ground) */}
|
|
359
|
+
{previewShape && (
|
|
360
|
+
<mesh
|
|
361
|
+
frustumCulled={false}
|
|
362
|
+
layers={EDITOR_LAYER}
|
|
363
|
+
position={[0, levelY + GRID_OFFSET, 0]}
|
|
364
|
+
rotation={[-Math.PI / 2, 0, 0]}
|
|
365
|
+
>
|
|
366
|
+
<shapeGeometry args={[previewShape]} />
|
|
367
|
+
<meshBasicMaterial
|
|
368
|
+
color="#818cf8"
|
|
369
|
+
depthTest={false}
|
|
370
|
+
opacity={0.1}
|
|
371
|
+
side={DoubleSide}
|
|
372
|
+
transparent
|
|
373
|
+
/>
|
|
374
|
+
</mesh>
|
|
375
|
+
)}
|
|
376
|
+
|
|
377
|
+
{/* Main line */}
|
|
378
|
+
{/* @ts-ignore */}
|
|
379
|
+
<line
|
|
380
|
+
frustumCulled={false}
|
|
381
|
+
layers={EDITOR_LAYER}
|
|
382
|
+
// @ts-expect-error
|
|
383
|
+
ref={mainLineRef}
|
|
384
|
+
renderOrder={1}
|
|
385
|
+
visible={false}
|
|
386
|
+
>
|
|
387
|
+
<bufferGeometry />
|
|
388
|
+
<lineBasicNodeMaterial color="#818cf8" depthTest={false} depthWrite={false} linewidth={3} />
|
|
389
|
+
</line>
|
|
390
|
+
|
|
391
|
+
{/* Closing line */}
|
|
392
|
+
{/* @ts-ignore */}
|
|
393
|
+
<line
|
|
394
|
+
frustumCulled={false}
|
|
395
|
+
layers={EDITOR_LAYER}
|
|
396
|
+
// @ts-expect-error
|
|
397
|
+
ref={closingLineRef}
|
|
398
|
+
renderOrder={1}
|
|
399
|
+
visible={false}
|
|
400
|
+
>
|
|
401
|
+
<bufferGeometry />
|
|
402
|
+
<lineBasicNodeMaterial
|
|
403
|
+
color="#818cf8"
|
|
404
|
+
depthTest={false}
|
|
405
|
+
depthWrite={false}
|
|
406
|
+
linewidth={2}
|
|
407
|
+
opacity={0.5}
|
|
408
|
+
transparent
|
|
409
|
+
/>
|
|
410
|
+
</line>
|
|
411
|
+
|
|
412
|
+
{/* Ground main line */}
|
|
413
|
+
{/* @ts-ignore */}
|
|
414
|
+
<line
|
|
415
|
+
frustumCulled={false}
|
|
416
|
+
layers={EDITOR_LAYER}
|
|
417
|
+
// @ts-expect-error
|
|
418
|
+
ref={groundMainLineRef}
|
|
419
|
+
renderOrder={1}
|
|
420
|
+
visible={false}
|
|
421
|
+
>
|
|
422
|
+
<bufferGeometry />
|
|
423
|
+
<lineBasicNodeMaterial
|
|
424
|
+
color="#818cf8"
|
|
425
|
+
depthTest={false}
|
|
426
|
+
depthWrite={false}
|
|
427
|
+
linewidth={3}
|
|
428
|
+
opacity={0.3}
|
|
429
|
+
transparent
|
|
430
|
+
/>
|
|
431
|
+
</line>
|
|
432
|
+
|
|
433
|
+
{/* Ground closing line */}
|
|
434
|
+
{/* @ts-ignore */}
|
|
435
|
+
<line
|
|
436
|
+
frustumCulled={false}
|
|
437
|
+
layers={EDITOR_LAYER}
|
|
438
|
+
// @ts-expect-error
|
|
439
|
+
ref={groundClosingLineRef}
|
|
440
|
+
renderOrder={1}
|
|
441
|
+
visible={false}
|
|
442
|
+
>
|
|
443
|
+
<bufferGeometry />
|
|
444
|
+
<lineBasicNodeMaterial
|
|
445
|
+
color="#818cf8"
|
|
446
|
+
depthTest={false}
|
|
447
|
+
depthWrite={false}
|
|
448
|
+
linewidth={2}
|
|
449
|
+
opacity={0.15}
|
|
450
|
+
transparent
|
|
451
|
+
/>
|
|
452
|
+
</line>
|
|
453
|
+
|
|
454
|
+
{/* Point markers */}
|
|
455
|
+
{points.map(([x, z], index) => (
|
|
456
|
+
<CursorSphere
|
|
457
|
+
color="#818cf8"
|
|
458
|
+
key={index}
|
|
459
|
+
position={[x, levelY + CEILING_HEIGHT + 0.01, z]}
|
|
460
|
+
showTooltip={false}
|
|
461
|
+
/>
|
|
462
|
+
))}
|
|
463
|
+
</group>
|
|
464
|
+
)
|
|
465
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AnyNodeId,
|
|
3
|
+
type DoorNode,
|
|
4
|
+
getScaledDimensions,
|
|
5
|
+
type ItemNode,
|
|
6
|
+
useScene,
|
|
7
|
+
type WallNode,
|
|
8
|
+
type WindowNode,
|
|
9
|
+
} from '@pascal-app/core'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Converts wall-local (X along wall, Y = height above wall base) to world XYZ.
|
|
13
|
+
*/
|
|
14
|
+
export function wallLocalToWorld(
|
|
15
|
+
wallNode: WallNode,
|
|
16
|
+
localX: number,
|
|
17
|
+
localY: number,
|
|
18
|
+
levelYOffset = 0,
|
|
19
|
+
slabElevation = 0,
|
|
20
|
+
): [number, number, number] {
|
|
21
|
+
const wallAngle = Math.atan2(
|
|
22
|
+
wallNode.end[1] - wallNode.start[1],
|
|
23
|
+
wallNode.end[0] - wallNode.start[0],
|
|
24
|
+
)
|
|
25
|
+
return [
|
|
26
|
+
wallNode.start[0] + localX * Math.cos(wallAngle),
|
|
27
|
+
slabElevation + localY + levelYOffset,
|
|
28
|
+
wallNode.start[1] + localX * Math.sin(wallAngle),
|
|
29
|
+
]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Clamps door center X so it stays fully within wall bounds.
|
|
34
|
+
* Y is always height/2 — doors sit at floor level.
|
|
35
|
+
*/
|
|
36
|
+
export function clampToWall(
|
|
37
|
+
wallNode: WallNode,
|
|
38
|
+
localX: number,
|
|
39
|
+
width: number,
|
|
40
|
+
height: number,
|
|
41
|
+
): { clampedX: number; clampedY: number } {
|
|
42
|
+
const dx = wallNode.end[0] - wallNode.start[0]
|
|
43
|
+
const dz = wallNode.end[1] - wallNode.start[1]
|
|
44
|
+
const wallLength = Math.sqrt(dx * dx + dz * dz)
|
|
45
|
+
|
|
46
|
+
const clampedX = Math.max(width / 2, Math.min(wallLength - width / 2, localX))
|
|
47
|
+
const clampedY = height / 2 // Doors always sit at floor level
|
|
48
|
+
return { clampedX, clampedY }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Checks if a proposed door position overlaps any existing wall children.
|
|
53
|
+
* Handles item, window, and door types.
|
|
54
|
+
*/
|
|
55
|
+
export function hasWallChildOverlap(
|
|
56
|
+
wallId: string,
|
|
57
|
+
clampedX: number,
|
|
58
|
+
clampedY: number,
|
|
59
|
+
width: number,
|
|
60
|
+
height: number,
|
|
61
|
+
ignoreId?: string,
|
|
62
|
+
): boolean {
|
|
63
|
+
const nodes = useScene.getState().nodes
|
|
64
|
+
const wallNode = nodes[wallId as AnyNodeId] as WallNode | undefined
|
|
65
|
+
if (!wallNode) return true
|
|
66
|
+
const halfW = width / 2
|
|
67
|
+
const halfH = height / 2
|
|
68
|
+
const newBottom = clampedY - halfH
|
|
69
|
+
const newTop = clampedY + halfH
|
|
70
|
+
const newLeft = clampedX - halfW
|
|
71
|
+
const newRight = clampedX + halfW
|
|
72
|
+
|
|
73
|
+
for (const childId of wallNode.children) {
|
|
74
|
+
if (childId === ignoreId) continue
|
|
75
|
+
const child = nodes[childId as AnyNodeId]
|
|
76
|
+
if (!child) continue
|
|
77
|
+
|
|
78
|
+
let childLeft: number, childRight: number, childBottom: number, childTop: number
|
|
79
|
+
|
|
80
|
+
if (child.type === 'item') {
|
|
81
|
+
const item = child as ItemNode
|
|
82
|
+
if (item.asset.attachTo !== 'wall' && item.asset.attachTo !== 'wall-side') continue
|
|
83
|
+
const [w, h] = getScaledDimensions(item)
|
|
84
|
+
childLeft = item.position[0] - w / 2
|
|
85
|
+
childRight = item.position[0] + w / 2
|
|
86
|
+
childBottom = item.position[1]
|
|
87
|
+
childTop = item.position[1] + h
|
|
88
|
+
} else if (child.type === 'window') {
|
|
89
|
+
const win = child as WindowNode
|
|
90
|
+
childLeft = win.position[0] - win.width / 2
|
|
91
|
+
childRight = win.position[0] + win.width / 2
|
|
92
|
+
childBottom = win.position[1] - win.height / 2
|
|
93
|
+
childTop = win.position[1] + win.height / 2
|
|
94
|
+
} else if (child.type === 'door') {
|
|
95
|
+
const door = child as DoorNode
|
|
96
|
+
childLeft = door.position[0] - door.width / 2
|
|
97
|
+
childRight = door.position[0] + door.width / 2
|
|
98
|
+
childBottom = door.position[1] - door.height / 2
|
|
99
|
+
childTop = door.position[1] + door.height / 2
|
|
100
|
+
} else {
|
|
101
|
+
continue
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const xOverlap = newLeft < childRight && newRight > childLeft
|
|
105
|
+
const yOverlap = newBottom < childTop && newTop > childBottom
|
|
106
|
+
if (xOverlap && yOverlap) return true
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return false
|
|
110
|
+
}
|