@pascal-app/editor 0.5.1 → 0.7.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 +12 -7
- package/src/components/editor/bottom-sheet.tsx +149 -0
- package/src/components/editor/custom-camera-controls.tsx +75 -7
- package/src/components/editor/editor-layout-mobile.tsx +264 -0
- package/src/components/editor/editor-layout-v2.tsx +29 -0
- package/src/components/editor/first-person/build-collider-world.ts +365 -0
- package/src/components/editor/first-person/bvh-ecctrl.tsx +795 -0
- package/src/components/editor/first-person-controls.tsx +496 -143
- package/src/components/editor/floating-action-menu.tsx +281 -83
- package/src/components/editor/floating-building-action-menu.tsx +4 -3
- package/src/components/editor/floorplan-background-selection.ts +113 -0
- package/src/components/editor/floorplan-panel.tsx +10442 -3275
- package/src/components/editor/index.tsx +270 -20
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/selection-manager.tsx +766 -12
- package/src/components/editor/site-edge-labels.tsx +9 -3
- package/src/components/editor/thumbnail-generator.tsx +350 -157
- package/src/components/editor/use-floorplan-background-placement.ts +257 -0
- package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
- package/src/components/editor/use-floorplan-scene-data.ts +189 -0
- package/src/components/editor/wall-measurement-label.tsx +377 -58
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
- package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
- package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +119 -0
- package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +197 -0
- package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
- package/src/components/editor-2d/svg-paths.ts +119 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +272 -0
- package/src/components/systems/roof/roof-edit-system.tsx +5 -5
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +2 -0
- package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
- package/src/components/tools/column/column-tool.tsx +97 -0
- package/src/components/tools/column/move-column-tool.tsx +105 -0
- package/src/components/tools/door/door-tool.tsx +19 -0
- package/src/components/tools/door/move-door-tool.tsx +38 -8
- package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
- package/src/components/tools/fence/fence-drafting.ts +27 -8
- package/src/components/tools/fence/fence-tool.tsx +159 -3
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +438 -0
- package/src/components/tools/fence/move-fence-tool.tsx +102 -27
- package/src/components/tools/item/move-tool.tsx +19 -1
- package/src/components/tools/item/placement-math.ts +44 -7
- package/src/components/tools/item/placement-strategies.ts +111 -33
- package/src/components/tools/item/placement-types.ts +7 -0
- package/src/components/tools/item/use-draft-node.ts +2 -0
- package/src/components/tools/item/use-placement-coordinator.tsx +701 -61
- package/src/components/tools/roof/move-roof-tool.tsx +111 -43
- package/src/components/tools/shared/polygon-editor.tsx +244 -29
- package/src/components/tools/shared/segment-angle.ts +156 -0
- package/src/components/tools/slab/move-slab-tool.tsx +182 -0
- package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +2 -0
- package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
- package/src/components/tools/spawn/spawn-tool.tsx +130 -0
- package/src/components/tools/stair/stair-tool.tsx +11 -3
- package/src/components/tools/tool-manager.tsx +30 -3
- package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +423 -0
- package/src/components/tools/wall/move-wall-tool.tsx +356 -0
- package/src/components/tools/wall/wall-drafting.ts +348 -17
- package/src/components/tools/wall/wall-tool.tsx +134 -2
- package/src/components/tools/window/move-window-tool.tsx +28 -0
- package/src/components/tools/window/window-tool.tsx +17 -0
- package/src/components/ui/action-menu/camera-actions.tsx +37 -33
- package/src/components/ui/action-menu/control-modes.tsx +37 -5
- package/src/components/ui/action-menu/index.tsx +91 -1
- package/src/components/ui/action-menu/structure-tools.tsx +2 -0
- package/src/components/ui/action-menu/view-toggles.tsx +424 -35
- package/src/components/ui/command-palette/editor-commands.tsx +27 -5
- package/src/components/ui/command-palette/index.tsx +0 -1
- package/src/components/ui/controls/material-picker.tsx +189 -169
- package/src/components/ui/controls/slider-control.tsx +88 -26
- package/src/components/ui/floating-level-selector.tsx +286 -55
- package/src/components/ui/helpers/helper-manager.tsx +5 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1121 -1219
- package/src/components/ui/item-catalog/item-catalog.tsx +42 -175
- package/src/components/ui/level-duplicate-dialog.tsx +115 -0
- package/src/components/ui/panels/ceiling-panel.tsx +47 -27
- package/src/components/ui/panels/column-panel.tsx +715 -0
- package/src/components/ui/panels/door-panel.tsx +986 -294
- package/src/components/ui/panels/fence-panel.tsx +55 -12
- package/src/components/ui/panels/item-panel.tsx +5 -5
- package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
- package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
- package/src/components/ui/panels/node-display.ts +39 -0
- package/src/components/ui/panels/paint-panel.tsx +138 -0
- package/src/components/ui/panels/panel-manager.tsx +241 -30
- package/src/components/ui/panels/panel-wrapper.tsx +48 -39
- package/src/components/ui/panels/reference-panel.tsx +243 -9
- package/src/components/ui/panels/roof-panel.tsx +30 -62
- package/src/components/ui/panels/roof-segment-panel.tsx +8 -23
- package/src/components/ui/panels/slab-panel.tsx +46 -24
- package/src/components/ui/panels/spawn-panel.tsx +155 -0
- package/src/components/ui/panels/stair-panel.tsx +117 -69
- package/src/components/ui/panels/stair-segment-panel.tsx +13 -27
- package/src/components/ui/panels/wall-panel.tsx +71 -17
- package/src/components/ui/panels/window-panel.tsx +665 -146
- package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +9 -5
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +138 -56
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +9 -5
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +82 -0
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +3 -3
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +12 -6
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +15 -8
- package/src/components/ui/sidebar/tab-bar.tsx +3 -0
- package/src/components/ui/viewer-toolbar.tsx +96 -2
- package/src/components/viewer-overlay.tsx +25 -19
- package/src/hooks/use-auto-frame.ts +45 -0
- package/src/hooks/use-contextual-tools.ts +14 -13
- package/src/hooks/use-keyboard.ts +67 -9
- package/src/hooks/use-mobile.ts +12 -12
- package/src/index.tsx +2 -1
- package/src/lib/door-interaction.ts +88 -0
- package/src/lib/floorplan/geometry.ts +263 -0
- package/src/lib/floorplan/index.ts +38 -0
- package/src/lib/floorplan/items.ts +179 -0
- package/src/lib/floorplan/selection-tool.ts +231 -0
- package/src/lib/floorplan/stairs.ts +478 -0
- package/src/lib/floorplan/types.ts +57 -0
- package/src/lib/floorplan/walls.ts +23 -0
- package/src/lib/guide-events.ts +10 -0
- package/src/lib/history.ts +20 -0
- package/src/lib/level-duplication.test.ts +72 -0
- package/src/lib/level-duplication.ts +153 -0
- package/src/lib/local-guide-image.ts +42 -0
- package/src/lib/material-paint.ts +284 -0
- package/src/lib/roof-duplication.ts +214 -0
- package/src/lib/scene-bounds.test.ts +183 -0
- package/src/lib/scene-bounds.ts +169 -0
- package/src/lib/sfx-player.ts +96 -13
- package/src/lib/stair-duplication.ts +126 -0
- package/src/lib/window-interaction.ts +86 -0
- package/src/store/use-editor.tsx +279 -15
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type CeilingNode,
|
|
5
|
+
emitter,
|
|
6
|
+
resolveLevelId,
|
|
7
|
+
sceneRegistry,
|
|
8
|
+
useScene,
|
|
9
|
+
} from '@pascal-app/core'
|
|
10
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
11
|
+
import { createPortal, type ThreeEvent } from '@react-three/fiber'
|
|
12
|
+
import { useEffect, useMemo, useState } from 'react'
|
|
13
|
+
import type { Object3D } from 'three'
|
|
14
|
+
import { useShallow } from 'zustand/react/shallow'
|
|
15
|
+
import useEditor from '../../../store/use-editor'
|
|
16
|
+
|
|
17
|
+
const BRACKET_THICKNESS = 0.04
|
|
18
|
+
const BRACKET_HEIGHT = 0.04
|
|
19
|
+
const BRACKET_Y_OFFSET = 0.035
|
|
20
|
+
const HIT_BOX_SIZE: [number, number, number] = [0.28, 0.08, 0.28]
|
|
21
|
+
|
|
22
|
+
type CornerBracketData = {
|
|
23
|
+
corner: [number, number]
|
|
24
|
+
incomingDirection: [number, number]
|
|
25
|
+
outgoingDirection: [number, number]
|
|
26
|
+
incomingLength: number
|
|
27
|
+
outgoingLength: number
|
|
28
|
+
cornerStrength: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const CeilingSelectionAffordanceSystem = () => {
|
|
32
|
+
const phase = useEditor((state) => state.phase)
|
|
33
|
+
const mode = useEditor((state) => state.mode)
|
|
34
|
+
const structureLayer = useEditor((state) => state.structureLayer)
|
|
35
|
+
const movingNode = useEditor((state) => state.movingNode)
|
|
36
|
+
const curvingWall = useEditor((state) => state.curvingWall)
|
|
37
|
+
const currentLevelId = useViewer((state) => state.selection.levelId)
|
|
38
|
+
|
|
39
|
+
const ceilings = useScene(
|
|
40
|
+
useShallow((state) =>
|
|
41
|
+
Object.values(state.nodes).filter((node): node is CeilingNode => {
|
|
42
|
+
return (
|
|
43
|
+
node.type === 'ceiling' &&
|
|
44
|
+
node.visible !== false &&
|
|
45
|
+
currentLevelId !== null &&
|
|
46
|
+
resolveLevelId(node, state.nodes) === currentLevelId
|
|
47
|
+
)
|
|
48
|
+
}),
|
|
49
|
+
),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
const shouldRender =
|
|
53
|
+
phase === 'structure' &&
|
|
54
|
+
mode === 'select' &&
|
|
55
|
+
structureLayer === 'elements' &&
|
|
56
|
+
!movingNode &&
|
|
57
|
+
!curvingWall &&
|
|
58
|
+
currentLevelId !== null
|
|
59
|
+
|
|
60
|
+
if (!shouldRender) return null
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<>
|
|
64
|
+
{ceilings.map((ceiling) => (
|
|
65
|
+
<CeilingSelectionAffordance ceiling={ceiling} key={ceiling.id} levelId={currentLevelId} />
|
|
66
|
+
))}
|
|
67
|
+
</>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const CeilingSelectionAffordance = ({
|
|
72
|
+
ceiling,
|
|
73
|
+
levelId,
|
|
74
|
+
}: {
|
|
75
|
+
ceiling: CeilingNode
|
|
76
|
+
levelId: string
|
|
77
|
+
}) => {
|
|
78
|
+
const [levelObject, setLevelObject] = useState<Object3D | null>(() => sceneRegistry.nodes.get(levelId) ?? null)
|
|
79
|
+
|
|
80
|
+
const corners = useMemo(() => buildCornerBrackets(ceiling.polygon), [ceiling.polygon])
|
|
81
|
+
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
let frameId = 0
|
|
84
|
+
|
|
85
|
+
const resolveLevelObject = () => {
|
|
86
|
+
const nextLevelObject = sceneRegistry.nodes.get(levelId) ?? null
|
|
87
|
+
setLevelObject((currentLevelObject) => {
|
|
88
|
+
if (currentLevelObject === nextLevelObject) {
|
|
89
|
+
return currentLevelObject
|
|
90
|
+
}
|
|
91
|
+
return nextLevelObject
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
if (!nextLevelObject) {
|
|
95
|
+
frameId = window.requestAnimationFrame(resolveLevelObject)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
resolveLevelObject()
|
|
100
|
+
|
|
101
|
+
return () => {
|
|
102
|
+
if (frameId) {
|
|
103
|
+
window.cancelAnimationFrame(frameId)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}, [levelId])
|
|
107
|
+
|
|
108
|
+
if (!levelObject || corners.length === 0) return null
|
|
109
|
+
|
|
110
|
+
return createPortal(
|
|
111
|
+
<group position={[0, (ceiling.height ?? 2.5) + BRACKET_Y_OFFSET, 0]}>
|
|
112
|
+
{corners.map((corner, index) => (
|
|
113
|
+
<CornerBracket
|
|
114
|
+
ceiling={ceiling}
|
|
115
|
+
corner={corner}
|
|
116
|
+
key={`${ceiling.id}-corner-${index}`}
|
|
117
|
+
/>
|
|
118
|
+
))}
|
|
119
|
+
</group>,
|
|
120
|
+
levelObject,
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const CornerBracket = ({
|
|
125
|
+
ceiling,
|
|
126
|
+
corner,
|
|
127
|
+
}: {
|
|
128
|
+
ceiling: CeilingNode
|
|
129
|
+
corner: CornerBracketData
|
|
130
|
+
}) => {
|
|
131
|
+
const [isHovered, setIsHovered] = useState(false)
|
|
132
|
+
const color = '#d4d4d4'
|
|
133
|
+
const opacity = 0.72
|
|
134
|
+
const cubeColor = isHovered ? '#818cf8' : '#d4d4d4'
|
|
135
|
+
const cubeOpacity = isHovered ? 0.92 : 0.72
|
|
136
|
+
|
|
137
|
+
const handleClick = (e: ThreeEvent<MouseEvent>) => {
|
|
138
|
+
e.stopPropagation()
|
|
139
|
+
|
|
140
|
+
const nodes = useScene.getState().nodes
|
|
141
|
+
|
|
142
|
+
useEditor.getState().setMovingNode(null)
|
|
143
|
+
useEditor.getState().setMovingWallEndpoint(null)
|
|
144
|
+
useEditor.getState().setCurvingWall(null)
|
|
145
|
+
useEditor.getState().setEditingHole(null)
|
|
146
|
+
useEditor.getState().setMode('select')
|
|
147
|
+
|
|
148
|
+
emitter.emit('ceiling:click' as any, {
|
|
149
|
+
node: ceiling,
|
|
150
|
+
nativeEvent: e.nativeEvent,
|
|
151
|
+
localPosition: [0, 0, 0],
|
|
152
|
+
position: [corner.corner[0], ceiling.height ?? 2.5, corner.corner[1]],
|
|
153
|
+
stopPropagation: () => e.stopPropagation(),
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
<group position={[corner.corner[0], 0, corner.corner[1]]}>
|
|
159
|
+
<BracketLeg
|
|
160
|
+
color={color}
|
|
161
|
+
direction={corner.incomingDirection}
|
|
162
|
+
length={corner.incomingLength}
|
|
163
|
+
onClick={handleClick}
|
|
164
|
+
opacity={opacity}
|
|
165
|
+
/>
|
|
166
|
+
<BracketLeg
|
|
167
|
+
color={color}
|
|
168
|
+
direction={corner.outgoingDirection}
|
|
169
|
+
length={corner.outgoingLength}
|
|
170
|
+
onClick={handleClick}
|
|
171
|
+
opacity={opacity}
|
|
172
|
+
/>
|
|
173
|
+
|
|
174
|
+
<mesh
|
|
175
|
+
onClick={handleClick}
|
|
176
|
+
onPointerEnter={(e) => {
|
|
177
|
+
e.stopPropagation()
|
|
178
|
+
setIsHovered(true)
|
|
179
|
+
}}
|
|
180
|
+
onPointerLeave={(e) => {
|
|
181
|
+
e.stopPropagation()
|
|
182
|
+
setIsHovered(false)
|
|
183
|
+
}}
|
|
184
|
+
>
|
|
185
|
+
<boxGeometry args={HIT_BOX_SIZE} />
|
|
186
|
+
<meshBasicMaterial color={cubeColor} depthWrite={false} opacity={cubeOpacity} transparent />
|
|
187
|
+
</mesh>
|
|
188
|
+
</group>
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const BracketLeg = ({
|
|
193
|
+
direction,
|
|
194
|
+
length,
|
|
195
|
+
color,
|
|
196
|
+
onClick,
|
|
197
|
+
opacity,
|
|
198
|
+
}: {
|
|
199
|
+
direction: [number, number]
|
|
200
|
+
length: number
|
|
201
|
+
color: string
|
|
202
|
+
onClick: (e: ThreeEvent<MouseEvent>) => void
|
|
203
|
+
opacity: number
|
|
204
|
+
}) => {
|
|
205
|
+
const angle = Math.atan2(direction[1], direction[0])
|
|
206
|
+
const position: [number, number, number] = [
|
|
207
|
+
direction[0] * (length / 2),
|
|
208
|
+
0,
|
|
209
|
+
direction[1] * (length / 2),
|
|
210
|
+
]
|
|
211
|
+
|
|
212
|
+
return (
|
|
213
|
+
<mesh
|
|
214
|
+
onClick={onClick}
|
|
215
|
+
position={position}
|
|
216
|
+
rotation={[0, angle, 0]}
|
|
217
|
+
>
|
|
218
|
+
<boxGeometry args={[length, BRACKET_HEIGHT, BRACKET_THICKNESS]} />
|
|
219
|
+
<meshBasicMaterial color={color} depthWrite={false} opacity={opacity} transparent />
|
|
220
|
+
</mesh>
|
|
221
|
+
)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function buildCornerBrackets(polygon: Array<[number, number]>): CornerBracketData[] {
|
|
225
|
+
if (polygon.length < 3) return []
|
|
226
|
+
|
|
227
|
+
const allCorners = polygon.map((corner, index) => {
|
|
228
|
+
const previous = polygon[(index - 1 + polygon.length) % polygon.length]!
|
|
229
|
+
const next = polygon[(index + 1) % polygon.length]!
|
|
230
|
+
const incomingVector = [previous[0] - corner[0], previous[1] - corner[1]] as [number, number]
|
|
231
|
+
const outgoingVector = [next[0] - corner[0], next[1] - corner[1]] as [number, number]
|
|
232
|
+
const incomingDirection = normalize2D(incomingVector)
|
|
233
|
+
const outgoingDirection = normalize2D(outgoingVector)
|
|
234
|
+
|
|
235
|
+
const incomingLength = Math.hypot(incomingVector[0], incomingVector[1])
|
|
236
|
+
const outgoingLength = Math.hypot(outgoingVector[0], outgoingVector[1])
|
|
237
|
+
const cornerStrength = 1 - Math.abs(incomingDirection[0] * outgoingDirection[0] + incomingDirection[1] * outgoingDirection[1])
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
corner,
|
|
241
|
+
incomingDirection,
|
|
242
|
+
outgoingDirection,
|
|
243
|
+
incomingLength: getBracketLength(incomingLength),
|
|
244
|
+
outgoingLength: getBracketLength(outgoingLength),
|
|
245
|
+
cornerStrength,
|
|
246
|
+
}
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
if (allCorners.length <= 4) {
|
|
250
|
+
return allCorners
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const selectedIndices = new Set(
|
|
254
|
+
allCorners
|
|
255
|
+
.map((corner, index) => ({ index, strength: corner.cornerStrength }))
|
|
256
|
+
.sort((a, b) => b.strength - a.strength)
|
|
257
|
+
.slice(0, 4)
|
|
258
|
+
.map(({ index }) => index),
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
return allCorners.filter((_, index) => selectedIndices.has(index))
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function normalize2D(vector: [number, number]): [number, number] {
|
|
265
|
+
const length = Math.hypot(vector[0], vector[1])
|
|
266
|
+
if (length < 1e-6) return [1, 0]
|
|
267
|
+
return [vector[0] / length, vector[1] / length]
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function getBracketLength(edgeLength: number): number {
|
|
271
|
+
return Math.max(0.14, Math.min(0.38, edgeLength * 0.22))
|
|
272
|
+
}
|
|
@@ -6,7 +6,7 @@ import { useEffect, useRef } from 'react'
|
|
|
6
6
|
* Imperatively toggles the Three.js visibility of roof objects based on the
|
|
7
7
|
* editor selection — without causing React re-renders in RoofRenderer.
|
|
8
8
|
*
|
|
9
|
-
* When a roof
|
|
9
|
+
* When a roof-segment is selected:
|
|
10
10
|
* - merged-roof mesh is hidden
|
|
11
11
|
* - segments-wrapper group is shown (individual segments visible for editing)
|
|
12
12
|
* - all children are marked dirty so RoofSystem rebuilds their geometry
|
|
@@ -22,14 +22,14 @@ export const RoofEditSystem = () => {
|
|
|
22
22
|
useEffect(() => {
|
|
23
23
|
const nodes = useScene.getState().nodes
|
|
24
24
|
|
|
25
|
-
// Collect which roof nodes should be in "edit mode"
|
|
25
|
+
// Collect which roof nodes should be in "edit mode".
|
|
26
|
+
// Selecting the roof itself should keep the merged visual intact so
|
|
27
|
+
// material appearance does not jump between merged and per-segment meshes.
|
|
26
28
|
const activeRoofIds = new Set<string>()
|
|
27
29
|
for (const id of selectedIds) {
|
|
28
30
|
const node = nodes[id as AnyNodeId]
|
|
29
31
|
if (!node) continue
|
|
30
|
-
if (node.type === 'roof') {
|
|
31
|
-
activeRoofIds.add(id)
|
|
32
|
-
} else if (node.type === 'roof-segment' && node.parentId) {
|
|
32
|
+
if (node.type === 'roof-segment' && node.parentId) {
|
|
33
33
|
activeRoofIds.add(node.parentId)
|
|
34
34
|
}
|
|
35
35
|
}
|
|
@@ -36,6 +36,8 @@ export const CeilingHoleEditor: React.FC<CeilingHoleEditorProps> = ({ ceilingId,
|
|
|
36
36
|
|
|
37
37
|
return (
|
|
38
38
|
<PolygonEditor
|
|
39
|
+
allowEdgeMove
|
|
40
|
+
allowPolygonMove
|
|
39
41
|
color="#ef4444"
|
|
40
42
|
levelId={resolveLevelId(ceiling, useScene.getState().nodes)} // red for holes
|
|
41
43
|
minVertices={3}
|
|
@@ -111,15 +111,15 @@ export const CeilingTool: React.FC = () => {
|
|
|
111
111
|
const onGridMove = (event: GridEvent) => {
|
|
112
112
|
if (!(cursorRef.current && gridCursorRef.current)) return
|
|
113
113
|
|
|
114
|
-
const gridX = Math.round(event.
|
|
115
|
-
const gridZ = Math.round(event.
|
|
114
|
+
const gridX = Math.round(event.localPosition[0] * 2) / 2
|
|
115
|
+
const gridZ = Math.round(event.localPosition[2] * 2) / 2
|
|
116
116
|
const gridPosition: [number, number] = [gridX, gridZ]
|
|
117
117
|
|
|
118
118
|
setCursorPosition(gridPosition)
|
|
119
|
-
setLevelY(event.
|
|
119
|
+
setLevelY(event.localPosition[1])
|
|
120
120
|
|
|
121
|
-
const ceilingY = event.
|
|
122
|
-
const gridY = event.
|
|
121
|
+
const ceilingY = event.localPosition[1] + CEILING_HEIGHT
|
|
122
|
+
const gridY = event.localPosition[1] + GRID_OFFSET
|
|
123
123
|
|
|
124
124
|
// Calculate snapped display position (bypass snap when Shift is held)
|
|
125
125
|
const lastPoint = points[points.length - 1]
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { type AnyNodeId, emitter, type GridEvent, useScene, type CeilingNode } from '@pascal-app/core'
|
|
4
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
5
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
6
|
+
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
|
7
|
+
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
8
|
+
import useEditor from '../../../store/use-editor'
|
|
9
|
+
import { CursorSphere } from '../shared/cursor-sphere'
|
|
10
|
+
import { BufferGeometry, DoubleSide, Path, Shape, ShapeGeometry, Vector3 } from 'three'
|
|
11
|
+
|
|
12
|
+
function snap(value: number) {
|
|
13
|
+
return Math.round(value * 2) / 2
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function translatePolygon(
|
|
17
|
+
polygon: Array<[number, number]>,
|
|
18
|
+
deltaX: number,
|
|
19
|
+
deltaZ: number,
|
|
20
|
+
): Array<[number, number]> {
|
|
21
|
+
return polygon.map(([x, z]) => [x + deltaX, z + deltaZ] as [number, number])
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getPolygonCenter(polygon: Array<[number, number]>): [number, number] {
|
|
25
|
+
if (polygon.length === 0) return [0, 0]
|
|
26
|
+
let sumX = 0
|
|
27
|
+
let sumZ = 0
|
|
28
|
+
for (const [x, z] of polygon) {
|
|
29
|
+
sumX += x
|
|
30
|
+
sumZ += z
|
|
31
|
+
}
|
|
32
|
+
return [sumX / polygon.length, sumZ / polygon.length]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
|
|
36
|
+
const activatedAtRef = useRef<number>(Date.now())
|
|
37
|
+
const originalPolygonRef = useRef(node.polygon.map(([x, z]) => [x, z] as [number, number]))
|
|
38
|
+
const originalHolesRef = useRef(
|
|
39
|
+
(node.holes ?? []).map((hole) => hole.map(([x, z]) => [x, z] as [number, number])),
|
|
40
|
+
)
|
|
41
|
+
const dragAnchorRef = useRef<[number, number] | null>(null)
|
|
42
|
+
const previousGridPosRef = useRef<[number, number] | null>(null)
|
|
43
|
+
const previousCursorPosRef = useRef<[number, number, number] | null>(null)
|
|
44
|
+
const previousDeltaRef = useRef<[number, number] | null>(null)
|
|
45
|
+
const previewRef = useRef<{
|
|
46
|
+
polygon: Array<[number, number]>
|
|
47
|
+
holes: Array<Array<[number, number]>>
|
|
48
|
+
} | null>(null)
|
|
49
|
+
|
|
50
|
+
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
|
|
51
|
+
const center = getPolygonCenter(node.polygon)
|
|
52
|
+
return [center[0], node.height ?? 2.5, center[1]]
|
|
53
|
+
})
|
|
54
|
+
const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]>>(node.polygon)
|
|
55
|
+
const [previewHoles, setPreviewHoles] = useState<Array<Array<[number, number]>>>(node.holes ?? [])
|
|
56
|
+
|
|
57
|
+
const exitMoveMode = useCallback(() => {
|
|
58
|
+
useEditor.getState().setMovingNode(null)
|
|
59
|
+
}, [])
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
const originalPolygon = originalPolygonRef.current
|
|
63
|
+
const originalHoles = originalHolesRef.current
|
|
64
|
+
|
|
65
|
+
useScene.temporal.getState().pause()
|
|
66
|
+
let wasCommitted = false
|
|
67
|
+
|
|
68
|
+
const applyPreview = (
|
|
69
|
+
polygon: Array<[number, number]>,
|
|
70
|
+
holes: Array<Array<[number, number]>>,
|
|
71
|
+
) => {
|
|
72
|
+
previewRef.current = { polygon, holes }
|
|
73
|
+
setPreviewPolygon(polygon)
|
|
74
|
+
setPreviewHoles(holes)
|
|
75
|
+
const center = getPolygonCenter(polygon)
|
|
76
|
+
const nextCursorPos: [number, number, number] = [center[0], node.height ?? 2.5, center[1]]
|
|
77
|
+
if (
|
|
78
|
+
!previousCursorPosRef.current ||
|
|
79
|
+
previousCursorPosRef.current[0] !== nextCursorPos[0] ||
|
|
80
|
+
previousCursorPosRef.current[1] !== nextCursorPos[1] ||
|
|
81
|
+
previousCursorPosRef.current[2] !== nextCursorPos[2]
|
|
82
|
+
) {
|
|
83
|
+
previousCursorPosRef.current = nextCursorPos
|
|
84
|
+
setCursorLocalPos(nextCursorPos)
|
|
85
|
+
}
|
|
86
|
+
useScene.getState().updateNode(node.id, { polygon, holes })
|
|
87
|
+
useScene.getState().markDirty(node.id as AnyNodeId)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const restoreOriginal = () => {
|
|
91
|
+
setPreviewPolygon(originalPolygon)
|
|
92
|
+
setPreviewHoles(originalHoles)
|
|
93
|
+
useScene.getState().updateNode(node.id, {
|
|
94
|
+
holes: originalHoles,
|
|
95
|
+
polygon: originalPolygon,
|
|
96
|
+
})
|
|
97
|
+
useScene.getState().markDirty(node.id as AnyNodeId)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const onGridMove = (event: GridEvent) => {
|
|
101
|
+
const localX = snap(event.localPosition[0])
|
|
102
|
+
const localZ = snap(event.localPosition[2])
|
|
103
|
+
|
|
104
|
+
if (
|
|
105
|
+
previousGridPosRef.current &&
|
|
106
|
+
(localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1])
|
|
107
|
+
) {
|
|
108
|
+
sfxEmitter.emit('sfx:grid-snap')
|
|
109
|
+
}
|
|
110
|
+
previousGridPosRef.current = [localX, localZ]
|
|
111
|
+
|
|
112
|
+
const anchor = dragAnchorRef.current ?? [localX, localZ]
|
|
113
|
+
dragAnchorRef.current = anchor
|
|
114
|
+
|
|
115
|
+
const deltaX = localX - anchor[0]
|
|
116
|
+
const deltaZ = localZ - anchor[1]
|
|
117
|
+
|
|
118
|
+
if (
|
|
119
|
+
previousDeltaRef.current &&
|
|
120
|
+
previousDeltaRef.current[0] === deltaX &&
|
|
121
|
+
previousDeltaRef.current[1] === deltaZ
|
|
122
|
+
) {
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
previousDeltaRef.current = [deltaX, deltaZ]
|
|
126
|
+
|
|
127
|
+
applyPreview(
|
|
128
|
+
translatePolygon(originalPolygon, deltaX, deltaZ),
|
|
129
|
+
originalHoles.map((hole) => translatePolygon(hole, deltaX, deltaZ)),
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const onGridClick = (event: GridEvent) => {
|
|
134
|
+
if (Date.now() - activatedAtRef.current < 150) {
|
|
135
|
+
event.nativeEvent?.stopPropagation?.()
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const preview = previewRef.current ?? { polygon: originalPolygon, holes: originalHoles }
|
|
140
|
+
|
|
141
|
+
wasCommitted = true
|
|
142
|
+
|
|
143
|
+
// Restore original baseline while paused so the next resume+update
|
|
144
|
+
// registers as a single tracked change (undo reverts to original).
|
|
145
|
+
useScene.getState().updateNode(node.id, {
|
|
146
|
+
polygon: originalPolygon,
|
|
147
|
+
holes: originalHoles,
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
useScene.temporal.getState().resume()
|
|
151
|
+
useScene.getState().updateNode(node.id, preview)
|
|
152
|
+
useScene.getState().markDirty(node.id as AnyNodeId)
|
|
153
|
+
useScene.temporal.getState().pause()
|
|
154
|
+
|
|
155
|
+
sfxEmitter.emit('sfx:item-place')
|
|
156
|
+
useViewer.getState().setSelection({ selectedIds: [node.id] })
|
|
157
|
+
exitMoveMode()
|
|
158
|
+
event.nativeEvent?.stopPropagation?.()
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const onCancel = () => {
|
|
162
|
+
restoreOriginal()
|
|
163
|
+
useViewer.getState().setSelection({ selectedIds: [node.id] })
|
|
164
|
+
useScene.temporal.getState().resume()
|
|
165
|
+
markToolCancelConsumed()
|
|
166
|
+
exitMoveMode()
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
emitter.on('grid:move', onGridMove)
|
|
170
|
+
emitter.on('grid:click', onGridClick)
|
|
171
|
+
emitter.on('tool:cancel', onCancel)
|
|
172
|
+
|
|
173
|
+
return () => {
|
|
174
|
+
if (!wasCommitted) {
|
|
175
|
+
restoreOriginal()
|
|
176
|
+
}
|
|
177
|
+
useScene.temporal.getState().resume()
|
|
178
|
+
emitter.off('grid:move', onGridMove)
|
|
179
|
+
emitter.off('grid:click', onGridClick)
|
|
180
|
+
emitter.off('tool:cancel', onCancel)
|
|
181
|
+
}
|
|
182
|
+
}, [exitMoveMode, node.height, node.id])
|
|
183
|
+
|
|
184
|
+
const previewFillGeometry = useMemo(
|
|
185
|
+
() => createCeilingPreviewGeometry(previewPolygon, previewHoles),
|
|
186
|
+
[previewHoles, previewPolygon],
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
const previewOutlineGeometry = useMemo(
|
|
190
|
+
() => createCeilingOutlineGeometry(previewPolygon),
|
|
191
|
+
[previewPolygon],
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
return (
|
|
195
|
+
<group>
|
|
196
|
+
<mesh geometry={previewFillGeometry} position={[0, (node.height ?? 2.5) + 0.012, 0]}>
|
|
197
|
+
<meshBasicMaterial
|
|
198
|
+
color="#f5f5f4"
|
|
199
|
+
depthWrite={false}
|
|
200
|
+
opacity={0.3}
|
|
201
|
+
side={DoubleSide}
|
|
202
|
+
transparent
|
|
203
|
+
/>
|
|
204
|
+
</mesh>
|
|
205
|
+
<line geometry={previewOutlineGeometry} position={[0, (node.height ?? 2.5) + 0.02, 0]}>
|
|
206
|
+
<lineBasicMaterial color="#ffffff" depthWrite={false} opacity={0.95} transparent />
|
|
207
|
+
</line>
|
|
208
|
+
<CursorSphere position={cursorLocalPos} showTooltip={false} />
|
|
209
|
+
</group>
|
|
210
|
+
)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function createCeilingPreviewGeometry(
|
|
214
|
+
polygon: Array<[number, number]>,
|
|
215
|
+
holes: Array<Array<[number, number]>>,
|
|
216
|
+
): BufferGeometry {
|
|
217
|
+
if (polygon.length < 3) return new BufferGeometry()
|
|
218
|
+
|
|
219
|
+
const shape = new Shape()
|
|
220
|
+
const [firstX, firstZ] = polygon[0]!
|
|
221
|
+
shape.moveTo(firstX, -firstZ)
|
|
222
|
+
|
|
223
|
+
for (let i = 1; i < polygon.length; i++) {
|
|
224
|
+
const [x, z] = polygon[i]!
|
|
225
|
+
shape.lineTo(x, -z)
|
|
226
|
+
}
|
|
227
|
+
shape.closePath()
|
|
228
|
+
|
|
229
|
+
for (const holePolygon of holes) {
|
|
230
|
+
if (holePolygon.length < 3) continue
|
|
231
|
+
const hole = new Path()
|
|
232
|
+
const [hx, hz] = holePolygon[0]!
|
|
233
|
+
hole.moveTo(hx, -hz)
|
|
234
|
+
for (let i = 1; i < holePolygon.length; i++) {
|
|
235
|
+
const [x, z] = holePolygon[i]!
|
|
236
|
+
hole.lineTo(x, -z)
|
|
237
|
+
}
|
|
238
|
+
hole.closePath()
|
|
239
|
+
shape.holes.push(hole)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const geometry = new ShapeGeometry(shape)
|
|
243
|
+
geometry.rotateX(-Math.PI / 2)
|
|
244
|
+
geometry.computeVertexNormals()
|
|
245
|
+
return geometry
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function createCeilingOutlineGeometry(polygon: Array<[number, number]>): BufferGeometry {
|
|
249
|
+
const geometry = new BufferGeometry()
|
|
250
|
+
if (polygon.length < 2) return geometry
|
|
251
|
+
|
|
252
|
+
const points = polygon.map(([x, z]) => new Vector3(x, 0, z))
|
|
253
|
+
const [firstX, firstZ] = polygon[0]!
|
|
254
|
+
points.push(new Vector3(firstX, 0, firstZ))
|
|
255
|
+
geometry.setFromPoints(points)
|
|
256
|
+
return geometry
|
|
257
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import '../../../three-types'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
COLUMN_PRESETS,
|
|
5
|
+
ColumnNode,
|
|
6
|
+
type ColumnNode as ColumnNodeType,
|
|
7
|
+
type ColumnPresetId,
|
|
8
|
+
emitter,
|
|
9
|
+
type GridEvent,
|
|
10
|
+
type LevelNode,
|
|
11
|
+
useScene,
|
|
12
|
+
} from '@pascal-app/core'
|
|
13
|
+
import { useEffect, useRef, useState } from 'react'
|
|
14
|
+
import type { Group } from 'three'
|
|
15
|
+
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
16
|
+
import useEditor from '../../../store/use-editor'
|
|
17
|
+
import { CursorSphere } from '../shared/cursor-sphere'
|
|
18
|
+
|
|
19
|
+
const COLUMN_ICON = (
|
|
20
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
21
|
+
<img
|
|
22
|
+
alt="Column"
|
|
23
|
+
src="/icons/column.png"
|
|
24
|
+
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
|
25
|
+
/>
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
const roundToHalf = (value: number) => Math.round(value * 2) / 2
|
|
29
|
+
const DEFAULT_COLUMN_PRESET_ID = 'basicPillar' satisfies ColumnPresetId
|
|
30
|
+
|
|
31
|
+
function createColumnFromPreset(presetId: ColumnPresetId, position: [number, number, number]) {
|
|
32
|
+
const { label, ...preset } = COLUMN_PRESETS[presetId]
|
|
33
|
+
return ColumnNode.parse({
|
|
34
|
+
name: label,
|
|
35
|
+
position,
|
|
36
|
+
rotation: 0,
|
|
37
|
+
...preset,
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type ColumnToolProps = {
|
|
42
|
+
currentLevelId: LevelNode['id'] | null
|
|
43
|
+
onPlaced?: (nodeId: ColumnNodeType['id']) => void
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const ColumnTool: React.FC<ColumnToolProps> = ({ currentLevelId, onPlaced }) => {
|
|
47
|
+
const [, setCursorPosition] = useState<[number, number, number] | null>(null)
|
|
48
|
+
const cursorRef = useRef<Group>(null)
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (!currentLevelId) return
|
|
52
|
+
|
|
53
|
+
const onGridMove = (event: GridEvent) => {
|
|
54
|
+
const nextPosition: [number, number, number] = [
|
|
55
|
+
roundToHalf(event.localPosition[0]),
|
|
56
|
+
0,
|
|
57
|
+
roundToHalf(event.localPosition[2]),
|
|
58
|
+
]
|
|
59
|
+
setCursorPosition(nextPosition)
|
|
60
|
+
cursorRef.current?.position.set(nextPosition[0], event.localPosition[1], nextPosition[2])
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const onGridClick = (event: GridEvent) => {
|
|
64
|
+
const position: [number, number, number] = [
|
|
65
|
+
roundToHalf(event.localPosition[0]),
|
|
66
|
+
0,
|
|
67
|
+
roundToHalf(event.localPosition[2]),
|
|
68
|
+
]
|
|
69
|
+
const column = createColumnFromPreset(DEFAULT_COLUMN_PRESET_ID, position)
|
|
70
|
+
useScene.getState().createNode(column, currentLevelId)
|
|
71
|
+
onPlaced?.(column.id)
|
|
72
|
+
sfxEmitter.emit('sfx:structure-build')
|
|
73
|
+
useEditor.getState().setTool(null)
|
|
74
|
+
useEditor.getState().setMode('select')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
emitter.on('grid:move', onGridMove)
|
|
78
|
+
emitter.on('grid:click', onGridClick)
|
|
79
|
+
|
|
80
|
+
return () => {
|
|
81
|
+
emitter.off('grid:move', onGridMove)
|
|
82
|
+
emitter.off('grid:click', onGridClick)
|
|
83
|
+
}
|
|
84
|
+
}, [currentLevelId, onPlaced])
|
|
85
|
+
|
|
86
|
+
if (!currentLevelId) return null
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<CursorSphere
|
|
90
|
+
color="#a78bfa"
|
|
91
|
+
height={2.8}
|
|
92
|
+
ref={cursorRef}
|
|
93
|
+
showTooltip
|
|
94
|
+
tooltipContent={COLUMN_ICON}
|
|
95
|
+
/>
|
|
96
|
+
)
|
|
97
|
+
}
|