@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,105 @@
|
|
|
1
|
+
import '../../../three-types'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type AnyNodeId,
|
|
5
|
+
ColumnNode,
|
|
6
|
+
type ColumnNode as ColumnNodeType,
|
|
7
|
+
emitter,
|
|
8
|
+
type GridEvent,
|
|
9
|
+
sceneRegistry,
|
|
10
|
+
useLiveTransforms,
|
|
11
|
+
useScene,
|
|
12
|
+
} from '@pascal-app/core'
|
|
13
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
14
|
+
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
|
15
|
+
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
16
|
+
import useEditor from '../../../store/use-editor'
|
|
17
|
+
import { CursorSphere } from '../shared/cursor-sphere'
|
|
18
|
+
|
|
19
|
+
const roundToHalf = (value: number) => Math.round(value * 2) / 2
|
|
20
|
+
|
|
21
|
+
export function MoveColumnTool({ node }: { node: ColumnNodeType }) {
|
|
22
|
+
const [previewPosition, setPreviewPosition] = useState<[number, number, number]>(node.position)
|
|
23
|
+
|
|
24
|
+
const exitMoveMode = useCallback(() => {
|
|
25
|
+
useEditor.getState().setMovingNode(null)
|
|
26
|
+
}, [])
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
useScene.temporal.getState().pause()
|
|
30
|
+
let committed = false
|
|
31
|
+
|
|
32
|
+
const applyPreview = (position: [number, number, number]) => {
|
|
33
|
+
setPreviewPosition(position)
|
|
34
|
+
useLiveTransforms.getState().set(node.id, {
|
|
35
|
+
position,
|
|
36
|
+
rotation: node.rotation,
|
|
37
|
+
})
|
|
38
|
+
sceneRegistry.nodes.get(node.id)?.position.set(position[0], position[1], position[2])
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const onGridMove = (event: GridEvent) => {
|
|
42
|
+
applyPreview([roundToHalf(event.localPosition[0]), 0, roundToHalf(event.localPosition[2])])
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const onGridClick = (event: GridEvent) => {
|
|
46
|
+
const position: [number, number, number] = [
|
|
47
|
+
roundToHalf(event.localPosition[0]),
|
|
48
|
+
0,
|
|
49
|
+
roundToHalf(event.localPosition[2]),
|
|
50
|
+
]
|
|
51
|
+
const nodeId = (node as { id?: ColumnNodeType['id'] }).id
|
|
52
|
+
|
|
53
|
+
if (nodeId && useScene.getState().nodes[nodeId]) {
|
|
54
|
+
committed = true
|
|
55
|
+
useLiveTransforms.getState().clear(nodeId)
|
|
56
|
+
useScene.temporal.getState().resume()
|
|
57
|
+
useScene.getState().updateNode(nodeId, { position })
|
|
58
|
+
} else if (node.parentId) {
|
|
59
|
+
const column = ColumnNode.parse({
|
|
60
|
+
...node,
|
|
61
|
+
id: undefined,
|
|
62
|
+
metadata: {},
|
|
63
|
+
position,
|
|
64
|
+
})
|
|
65
|
+
committed = true
|
|
66
|
+
useScene.temporal.getState().resume()
|
|
67
|
+
useScene.getState().createNode(column, node.parentId as AnyNodeId)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
useLiveTransforms.getState().clear(node.id)
|
|
71
|
+
sfxEmitter.emit('sfx:item-place')
|
|
72
|
+
exitMoveMode()
|
|
73
|
+
event.nativeEvent?.stopPropagation?.()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const onCancel = () => {
|
|
77
|
+
useLiveTransforms.getState().clear(node.id)
|
|
78
|
+
sceneRegistry.nodes
|
|
79
|
+
.get(node.id)
|
|
80
|
+
?.position.set(node.position[0], node.position[1], node.position[2])
|
|
81
|
+
useScene.temporal.getState().resume()
|
|
82
|
+
markToolCancelConsumed()
|
|
83
|
+
exitMoveMode()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
emitter.on('grid:move', onGridMove)
|
|
87
|
+
emitter.on('grid:click', onGridClick)
|
|
88
|
+
emitter.on('tool:cancel', onCancel)
|
|
89
|
+
|
|
90
|
+
return () => {
|
|
91
|
+
emitter.off('grid:move', onGridMove)
|
|
92
|
+
emitter.off('grid:click', onGridClick)
|
|
93
|
+
emitter.off('tool:cancel', onCancel)
|
|
94
|
+
useLiveTransforms.getState().clear(node.id)
|
|
95
|
+
if (!committed) {
|
|
96
|
+
sceneRegistry.nodes
|
|
97
|
+
.get(node.id)
|
|
98
|
+
?.position.set(node.position[0], node.position[1], node.position[2])
|
|
99
|
+
useScene.temporal.getState().resume()
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}, [exitMoveMode, node])
|
|
103
|
+
|
|
104
|
+
return <CursorSphere color="#a78bfa" height={node.height} position={previewPosition} />
|
|
105
|
+
}
|
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
type AnyNodeId,
|
|
3
3
|
DoorNode,
|
|
4
4
|
emitter,
|
|
5
|
+
isCurvedWall,
|
|
5
6
|
sceneRegistry,
|
|
6
7
|
spatialGridManager,
|
|
7
8
|
useScene,
|
|
@@ -84,6 +85,11 @@ export const DoorTool: React.FC = () => {
|
|
|
84
85
|
|
|
85
86
|
const onWallEnter = (event: WallEvent) => {
|
|
86
87
|
if (!isValidWallSideFace(event.normal)) return
|
|
88
|
+
if (isCurvedWall(event.node)) {
|
|
89
|
+
destroyDraft()
|
|
90
|
+
hideCursor()
|
|
91
|
+
return
|
|
92
|
+
}
|
|
87
93
|
const levelId = getLevelId()
|
|
88
94
|
if (!levelId) return
|
|
89
95
|
if (event.node.parentId !== levelId) return
|
|
@@ -130,6 +136,11 @@ export const DoorTool: React.FC = () => {
|
|
|
130
136
|
|
|
131
137
|
const onWallMove = (event: WallEvent) => {
|
|
132
138
|
if (!isValidWallSideFace(event.normal)) return
|
|
139
|
+
if (isCurvedWall(event.node)) {
|
|
140
|
+
destroyDraft()
|
|
141
|
+
hideCursor()
|
|
142
|
+
return
|
|
143
|
+
}
|
|
133
144
|
if (event.node.parentId !== getLevelId()) return
|
|
134
145
|
|
|
135
146
|
const side = getSideFromNormal(event.normal)
|
|
@@ -190,6 +201,7 @@ export const DoorTool: React.FC = () => {
|
|
|
190
201
|
const onWallClick = (event: WallEvent) => {
|
|
191
202
|
if (!draftRef.current) return
|
|
192
203
|
if (!isValidWallSideFace(event.normal)) return
|
|
204
|
+
if (isCurvedWall(event.node)) return
|
|
193
205
|
if (event.node.parentId !== getLevelId()) return
|
|
194
206
|
|
|
195
207
|
const side = getSideFromNormal(event.normal)
|
|
@@ -236,6 +248,13 @@ export const DoorTool: React.FC = () => {
|
|
|
236
248
|
parentId: event.node.id,
|
|
237
249
|
width: draft.width,
|
|
238
250
|
height: draft.height,
|
|
251
|
+
doorCategory: draft.doorCategory,
|
|
252
|
+
doorType: draft.doorType,
|
|
253
|
+
leafCount: draft.leafCount,
|
|
254
|
+
operationState: draft.operationState,
|
|
255
|
+
slideDirection: draft.slideDirection,
|
|
256
|
+
trackStyle: draft.trackStyle,
|
|
257
|
+
garagePanelCount: draft.garagePanelCount,
|
|
239
258
|
frameThickness: draft.frameThickness,
|
|
240
259
|
frameDepth: draft.frameDepth,
|
|
241
260
|
threshold: draft.threshold,
|
|
@@ -2,8 +2,10 @@ import {
|
|
|
2
2
|
type AnyNodeId,
|
|
3
3
|
DoorNode,
|
|
4
4
|
emitter,
|
|
5
|
+
isCurvedWall,
|
|
5
6
|
sceneRegistry,
|
|
6
7
|
spatialGridManager,
|
|
8
|
+
useLiveTransforms,
|
|
7
9
|
useScene,
|
|
8
10
|
type WallEvent,
|
|
9
11
|
} from '@pascal-app/core'
|
|
@@ -96,13 +98,27 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
|
|
96
98
|
edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44)
|
|
97
99
|
}
|
|
98
100
|
|
|
101
|
+
const getPlacementOrientation = (event: WallEvent) => {
|
|
102
|
+
const faceSide = getSideFromNormal(event.normal)
|
|
103
|
+
const side = movingDoorNode.side ?? faceSide
|
|
104
|
+
const rotationOffset = side !== faceSide ? Math.PI : 0
|
|
105
|
+
return {
|
|
106
|
+
side,
|
|
107
|
+
itemRotation: calculateItemRotation(event.normal) + rotationOffset,
|
|
108
|
+
cursorRotation:
|
|
109
|
+
calculateCursorRotation(event.normal, event.node.start, event.node.end) + rotationOffset,
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
99
113
|
const onWallEnter = (event: WallEvent) => {
|
|
100
114
|
if (!isValidWallSideFace(event.normal)) return
|
|
115
|
+
if (isCurvedWall(event.node)) {
|
|
116
|
+
hideCursor()
|
|
117
|
+
return
|
|
118
|
+
}
|
|
101
119
|
if (event.node.parentId !== getLevelId()) return
|
|
102
120
|
|
|
103
|
-
const side =
|
|
104
|
-
const itemRotation = calculateItemRotation(event.normal)
|
|
105
|
-
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
|
121
|
+
const { side, itemRotation, cursorRotation } = getPlacementOrientation(event)
|
|
106
122
|
|
|
107
123
|
const localX = snapToHalf(event.localPosition[0])
|
|
108
124
|
const { clampedX, clampedY } = clampToWall(
|
|
@@ -122,6 +138,10 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
|
|
122
138
|
parentId: event.node.id,
|
|
123
139
|
wallId: event.node.id,
|
|
124
140
|
})
|
|
141
|
+
useLiveTransforms.getState().set(movingDoorNode.id, {
|
|
142
|
+
position: [clampedX, clampedY, 0],
|
|
143
|
+
rotation: itemRotation,
|
|
144
|
+
})
|
|
125
145
|
|
|
126
146
|
if (prevWallId && prevWallId !== event.node.id) markWallDirty(prevWallId)
|
|
127
147
|
markWallDirty(event.node.id)
|
|
@@ -151,11 +171,13 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
|
|
151
171
|
|
|
152
172
|
const onWallMove = (event: WallEvent) => {
|
|
153
173
|
if (!isValidWallSideFace(event.normal)) return
|
|
174
|
+
if (isCurvedWall(event.node)) {
|
|
175
|
+
hideCursor()
|
|
176
|
+
return
|
|
177
|
+
}
|
|
154
178
|
if (event.node.parentId !== getLevelId()) return
|
|
155
179
|
|
|
156
|
-
const side =
|
|
157
|
-
const itemRotation = calculateItemRotation(event.normal)
|
|
158
|
-
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
|
180
|
+
const { side, itemRotation, cursorRotation } = getPlacementOrientation(event)
|
|
159
181
|
|
|
160
182
|
const localX = snapToHalf(event.localPosition[0])
|
|
161
183
|
const { clampedX, clampedY } = clampToWall(
|
|
@@ -186,6 +208,10 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
|
|
186
208
|
doorMesh.updateMatrixWorld(true)
|
|
187
209
|
}
|
|
188
210
|
}
|
|
211
|
+
useLiveTransforms.getState().set(movingDoorNode.id, {
|
|
212
|
+
position: [clampedX, clampedY, 0],
|
|
213
|
+
rotation: itemRotation,
|
|
214
|
+
})
|
|
189
215
|
markWallDirty(event.node.id)
|
|
190
216
|
|
|
191
217
|
const valid = !hasWallChildOverlap(
|
|
@@ -213,10 +239,10 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
|
|
213
239
|
|
|
214
240
|
const onWallClick = (event: WallEvent) => {
|
|
215
241
|
if (!isValidWallSideFace(event.normal)) return
|
|
242
|
+
if (isCurvedWall(event.node)) return
|
|
216
243
|
if (event.node.parentId !== getLevelId()) return
|
|
217
244
|
|
|
218
|
-
const side =
|
|
219
|
-
const itemRotation = calculateItemRotation(event.normal)
|
|
245
|
+
const { side, itemRotation } = getPlacementOrientation(event)
|
|
220
246
|
|
|
221
247
|
const localX = snapToHalf(event.localPosition[0])
|
|
222
248
|
const { clampedX, clampedY } = clampToWall(
|
|
@@ -281,6 +307,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
|
|
281
307
|
}
|
|
282
308
|
|
|
283
309
|
markWallDirty(event.node.id)
|
|
310
|
+
useLiveTransforms.getState().clear(movingDoorNode.id)
|
|
284
311
|
useScene.temporal.getState().pause()
|
|
285
312
|
|
|
286
313
|
sfxEmitter.emit('sfx:item-place')
|
|
@@ -292,6 +319,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
|
|
292
319
|
|
|
293
320
|
const onWallLeave = () => {
|
|
294
321
|
hideCursor()
|
|
322
|
+
useLiveTransforms.getState().clear(movingDoorNode.id)
|
|
295
323
|
if (isNew) return
|
|
296
324
|
if (currentWallId && currentWallId !== original.parentId) {
|
|
297
325
|
markWallDirty(currentWallId)
|
|
@@ -308,6 +336,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
|
|
308
336
|
}
|
|
309
337
|
|
|
310
338
|
const onCancel = () => {
|
|
339
|
+
useLiveTransforms.getState().clear(movingDoorNode.id)
|
|
311
340
|
if (isNew) {
|
|
312
341
|
useScene.getState().deleteNode(movingDoorNode.id)
|
|
313
342
|
if (currentWallId) markWallDirty(currentWallId)
|
|
@@ -354,6 +383,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
|
|
354
383
|
if (original.parentId) markWallDirty(original.parentId)
|
|
355
384
|
}
|
|
356
385
|
}
|
|
386
|
+
useLiveTransforms.getState().clear(movingDoorNode.id)
|
|
357
387
|
useScene.temporal.getState().resume()
|
|
358
388
|
emitter.off('wall:enter', onWallEnter)
|
|
359
389
|
emitter.off('wall:move', onWallMove)
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type AnyNodeId,
|
|
5
|
+
emitter,
|
|
6
|
+
type FenceNode,
|
|
7
|
+
type GridEvent,
|
|
8
|
+
getClampedWallCurveOffset,
|
|
9
|
+
getMaxWallCurveOffset,
|
|
10
|
+
getWallChordFrame,
|
|
11
|
+
getWallMidpointHandlePoint,
|
|
12
|
+
normalizeWallCurveOffset,
|
|
13
|
+
pauseSceneHistory,
|
|
14
|
+
resumeSceneHistory,
|
|
15
|
+
useScene,
|
|
16
|
+
} from '@pascal-app/core'
|
|
17
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
18
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
19
|
+
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
|
20
|
+
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
21
|
+
import useEditor from '../../../store/use-editor'
|
|
22
|
+
import { CursorSphere } from '../shared/cursor-sphere'
|
|
23
|
+
import { getWallGridStep, snapScalarToGrid } from '../wall/wall-drafting'
|
|
24
|
+
|
|
25
|
+
export const CurveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
|
26
|
+
const activatedAtRef = useRef<number>(Date.now())
|
|
27
|
+
const originalCurveOffsetRef = useRef(getClampedWallCurveOffset(node))
|
|
28
|
+
const previousCurveOffsetRef = useRef<number | null>(null)
|
|
29
|
+
const shiftPressedRef = useRef(false)
|
|
30
|
+
const previewOffsetRef = useRef<number>(originalCurveOffsetRef.current)
|
|
31
|
+
|
|
32
|
+
const initialHandle = getWallMidpointHandlePoint(node)
|
|
33
|
+
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>([
|
|
34
|
+
initialHandle.x,
|
|
35
|
+
0,
|
|
36
|
+
initialHandle.y,
|
|
37
|
+
])
|
|
38
|
+
|
|
39
|
+
const exitCurveMode = useCallback(() => {
|
|
40
|
+
useEditor.getState().setCurvingFence(null)
|
|
41
|
+
}, [])
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
const nodeId = node.id
|
|
45
|
+
const originalCurveOffset = originalCurveOffsetRef.current
|
|
46
|
+
const chord = getWallChordFrame(node)
|
|
47
|
+
const maxCurveOffset = getMaxWallCurveOffset(node)
|
|
48
|
+
|
|
49
|
+
pauseSceneHistory(useScene)
|
|
50
|
+
let wasCommitted = false
|
|
51
|
+
|
|
52
|
+
const applyPreview = (curveOffset: number) => {
|
|
53
|
+
if (previewOffsetRef.current === curveOffset) {
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
previewOffsetRef.current = curveOffset
|
|
57
|
+
|
|
58
|
+
const nextNode = {
|
|
59
|
+
...node,
|
|
60
|
+
curveOffset,
|
|
61
|
+
}
|
|
62
|
+
const handlePoint = getWallMidpointHandlePoint(nextNode)
|
|
63
|
+
setCursorLocalPos([handlePoint.x, 0, handlePoint.y])
|
|
64
|
+
useScene.getState().updateNode(nodeId, { curveOffset })
|
|
65
|
+
useScene.getState().markDirty(nodeId as AnyNodeId)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const restoreOriginal = () => {
|
|
69
|
+
if (previewOffsetRef.current === originalCurveOffset) {
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
previewOffsetRef.current = originalCurveOffset
|
|
73
|
+
useScene.getState().updateNode(nodeId, { curveOffset: originalCurveOffset })
|
|
74
|
+
useScene.getState().markDirty(nodeId as AnyNodeId)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const onGridMove = (event: GridEvent) => {
|
|
78
|
+
const snapStep = getWallGridStep()
|
|
79
|
+
const localX = shiftPressedRef.current
|
|
80
|
+
? event.localPosition[0]
|
|
81
|
+
: snapScalarToGrid(event.localPosition[0], snapStep)
|
|
82
|
+
const localZ = shiftPressedRef.current
|
|
83
|
+
? event.localPosition[2]
|
|
84
|
+
: snapScalarToGrid(event.localPosition[2], snapStep)
|
|
85
|
+
|
|
86
|
+
const offsetFromMidpoint =
|
|
87
|
+
-(
|
|
88
|
+
(localX - chord.midpoint.x) * chord.normal.x +
|
|
89
|
+
(localZ - chord.midpoint.y) * chord.normal.y
|
|
90
|
+
)
|
|
91
|
+
const snappedOffset = shiftPressedRef.current
|
|
92
|
+
? offsetFromMidpoint
|
|
93
|
+
: snapScalarToGrid(offsetFromMidpoint, snapStep)
|
|
94
|
+
const nextCurveOffset = normalizeWallCurveOffset(
|
|
95
|
+
node,
|
|
96
|
+
Math.max(-maxCurveOffset, Math.min(maxCurveOffset, snappedOffset)),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
if (
|
|
100
|
+
previousCurveOffsetRef.current !== null &&
|
|
101
|
+
nextCurveOffset !== previousCurveOffsetRef.current
|
|
102
|
+
) {
|
|
103
|
+
sfxEmitter.emit('sfx:grid-snap')
|
|
104
|
+
}
|
|
105
|
+
previousCurveOffsetRef.current = nextCurveOffset
|
|
106
|
+
|
|
107
|
+
applyPreview(nextCurveOffset)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const onGridClick = (event: GridEvent) => {
|
|
111
|
+
if (Date.now() - activatedAtRef.current < 150) {
|
|
112
|
+
event.nativeEvent?.stopPropagation?.()
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const curveOffset = previewOffsetRef.current
|
|
117
|
+
wasCommitted = true
|
|
118
|
+
|
|
119
|
+
if (curveOffset !== originalCurveOffset) {
|
|
120
|
+
useScene.getState().updateNode(nodeId, { curveOffset: originalCurveOffset })
|
|
121
|
+
useScene.getState().markDirty(nodeId as AnyNodeId)
|
|
122
|
+
|
|
123
|
+
resumeSceneHistory(useScene)
|
|
124
|
+
useScene.getState().updateNode(nodeId, { curveOffset })
|
|
125
|
+
useScene.getState().markDirty(nodeId as AnyNodeId)
|
|
126
|
+
pauseSceneHistory(useScene)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
sfxEmitter.emit('sfx:item-place')
|
|
130
|
+
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
|
131
|
+
exitCurveMode()
|
|
132
|
+
event.nativeEvent?.stopPropagation?.()
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const onCancel = () => {
|
|
136
|
+
restoreOriginal()
|
|
137
|
+
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
|
138
|
+
resumeSceneHistory(useScene)
|
|
139
|
+
markToolCancelConsumed()
|
|
140
|
+
exitCurveMode()
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
144
|
+
if (event.key === 'Shift') {
|
|
145
|
+
shiftPressedRef.current = true
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const onKeyUp = (event: KeyboardEvent) => {
|
|
150
|
+
if (event.key === 'Shift') {
|
|
151
|
+
shiftPressedRef.current = false
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
emitter.on('grid:move', onGridMove)
|
|
156
|
+
emitter.on('grid:click', onGridClick)
|
|
157
|
+
emitter.on('tool:cancel', onCancel)
|
|
158
|
+
window.addEventListener('keydown', onKeyDown)
|
|
159
|
+
window.addEventListener('keyup', onKeyUp)
|
|
160
|
+
|
|
161
|
+
return () => {
|
|
162
|
+
if (!wasCommitted) {
|
|
163
|
+
restoreOriginal()
|
|
164
|
+
}
|
|
165
|
+
resumeSceneHistory(useScene)
|
|
166
|
+
emitter.off('grid:move', onGridMove)
|
|
167
|
+
emitter.off('grid:click', onGridClick)
|
|
168
|
+
emitter.off('tool:cancel', onCancel)
|
|
169
|
+
window.removeEventListener('keydown', onKeyDown)
|
|
170
|
+
window.removeEventListener('keyup', onKeyUp)
|
|
171
|
+
}
|
|
172
|
+
}, [exitCurveMode, node])
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<group>
|
|
176
|
+
<CursorSphere position={cursorLocalPos} showTooltip={false} />
|
|
177
|
+
</group>
|
|
178
|
+
)
|
|
179
|
+
}
|
|
@@ -1,12 +1,21 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
FenceNode,
|
|
3
|
+
getWallCurveFrameAt,
|
|
4
|
+
getWallCurveLength,
|
|
5
|
+
isCurvedWall,
|
|
6
|
+
useScene,
|
|
7
|
+
type WallNode,
|
|
8
|
+
} from '@pascal-app/core'
|
|
2
9
|
import { useViewer } from '@pascal-app/viewer'
|
|
3
10
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
4
11
|
import {
|
|
5
|
-
type WallPlanPoint,
|
|
6
12
|
findWallSnapTarget,
|
|
13
|
+
getWallAngleSnapStep,
|
|
14
|
+
getWallGridStep,
|
|
7
15
|
isWallLongEnough,
|
|
8
16
|
snapPointTo45Degrees,
|
|
9
17
|
snapPointToGrid,
|
|
18
|
+
type WallPlanPoint,
|
|
10
19
|
} from '../wall/wall-drafting'
|
|
11
20
|
|
|
12
21
|
export type FencePlanPoint = WallPlanPoint
|
|
@@ -58,11 +67,16 @@ function findFenceSnapTarget(
|
|
|
58
67
|
continue
|
|
59
68
|
}
|
|
60
69
|
|
|
61
|
-
const candidates: Array<FencePlanPoint | null> = [
|
|
62
|
-
|
|
63
|
-
fence.
|
|
64
|
-
|
|
65
|
-
|
|
70
|
+
const candidates: Array<FencePlanPoint | null> = [fence.start, fence.end]
|
|
71
|
+
if (isCurvedWall(fence)) {
|
|
72
|
+
const sampleCount = Math.max(8, Math.ceil(getWallCurveLength(fence) / 0.3))
|
|
73
|
+
for (let index = 0; index <= sampleCount; index += 1) {
|
|
74
|
+
const frame = getWallCurveFrameAt(fence, index / sampleCount)
|
|
75
|
+
candidates.push([frame.point.x, frame.point.y])
|
|
76
|
+
}
|
|
77
|
+
} else {
|
|
78
|
+
candidates.push(projectPointOntoSegment(point, fence))
|
|
79
|
+
}
|
|
66
80
|
|
|
67
81
|
for (const candidate of candidates) {
|
|
68
82
|
if (!candidate) {
|
|
@@ -94,7 +108,12 @@ export function snapFenceDraftPoint(args: {
|
|
|
94
108
|
ignoreFenceIds?: string[]
|
|
95
109
|
}): FencePlanPoint {
|
|
96
110
|
const { point, walls, fences, start, angleSnap = false, ignoreFenceIds } = args
|
|
97
|
-
const
|
|
111
|
+
const gridStep = getWallGridStep()
|
|
112
|
+
const angleStep = getWallAngleSnapStep(gridStep)
|
|
113
|
+
const basePoint =
|
|
114
|
+
start && angleSnap
|
|
115
|
+
? snapPointTo45Degrees(start, point, gridStep, angleStep)
|
|
116
|
+
: snapPointToGrid(point, gridStep)
|
|
98
117
|
const fenceSnapTarget = findFenceSnapTarget(basePoint, fences, ignoreFenceIds)
|
|
99
118
|
|
|
100
119
|
return fenceSnapTarget ?? findWallSnapTarget(basePoint, walls) ?? basePoint
|