@pascal-app/editor 0.6.0 → 0.8.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 +13 -9
- package/src/components/editor/bottom-sheet.tsx +149 -0
- package/src/components/editor/custom-camera-controls.tsx +74 -5
- package/src/components/editor/editor-layout-mobile.tsx +264 -0
- package/src/components/editor/editor-layout-v2.tsx +24 -3
- package/src/components/editor/first-person/build-collider-world.ts +363 -0
- package/src/components/editor/first-person/bvh-ecctrl.tsx +860 -0
- package/src/components/editor/first-person-controls.tsx +496 -143
- package/src/components/editor/floating-action-menu.tsx +32 -55
- package/src/components/editor/floorplan-background-selection.ts +113 -0
- package/src/components/editor/floorplan-panel.tsx +9861 -3297
- package/src/components/editor/index.tsx +295 -32
- package/src/components/editor/selection-manager.tsx +575 -13
- package/src/components/editor/snapshot-capture-overlay.tsx +465 -0
- package/src/components/editor/thumbnail-generator.tsx +56 -68
- 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 +267 -36
- 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 +124 -0
- package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +202 -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 +10 -12
- package/src/components/systems/roof/roof-edit-system.tsx +1 -1
- package/src/components/systems/stair/stair-edit-system.tsx +1 -1
- package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
- package/src/components/systems/zone/zone-system.tsx +0 -0
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
- package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +9 -2
- 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 +7 -0
- package/src/components/tools/door/move-door-tool.tsx +28 -8
- package/src/components/tools/fence/curve-fence-tool.tsx +4 -5
- package/src/components/tools/fence/fence-drafting.ts +10 -3
- package/src/components/tools/fence/fence-tool.tsx +160 -4
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +139 -25
- package/src/components/tools/fence/move-fence-tool.tsx +111 -40
- package/src/components/tools/item/move-tool.tsx +7 -1
- package/src/components/tools/item/placement-math.ts +32 -5
- package/src/components/tools/item/placement-strategies.ts +110 -31
- package/src/components/tools/item/placement-types.ts +7 -0
- package/src/components/tools/item/use-draft-node.ts +1 -0
- package/src/components/tools/item/use-placement-coordinator.tsx +558 -52
- package/src/components/tools/roof/move-roof-tool.tsx +29 -17
- package/src/components/tools/select/box-select-tool.tsx +12 -17
- package/src/components/tools/shared/polygon-editor.tsx +153 -28
- package/src/components/tools/shared/segment-angle.ts +156 -0
- package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +1 -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/tool-manager.tsx +20 -5
- package/src/components/tools/wall/curve-wall-tool.tsx +8 -6
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +131 -27
- package/src/components/tools/wall/move-wall-tool.tsx +6 -4
- package/src/components/tools/wall/wall-drafting.ts +18 -9
- package/src/components/tools/wall/wall-tool.tsx +136 -4
- package/src/components/tools/window/move-window-tool.tsx +18 -0
- package/src/components/tools/window/window-tool.tsx +5 -0
- package/src/components/tools/zone/zone-tool.tsx +20 -5
- package/src/components/ui/action-menu/camera-actions.tsx +37 -33
- package/src/components/ui/action-menu/control-modes.tsx +34 -1
- package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
- package/src/components/ui/action-menu/index.tsx +98 -59
- package/src/components/ui/action-menu/structure-tools.tsx +2 -0
- package/src/components/ui/action-menu/view-toggles.tsx +418 -41
- package/src/components/ui/command-palette/editor-commands.tsx +24 -5
- package/src/components/ui/command-palette/index.tsx +4 -255
- package/src/components/ui/controls/material-picker.tsx +154 -164
- package/src/components/ui/controls/slider-control.tsx +66 -18
- package/src/components/ui/floating-level-selector.tsx +286 -55
- package/src/components/ui/helpers/helper-manager.tsx +10 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +2563 -1239
- package/src/components/ui/item-catalog/item-catalog.tsx +96 -187
- package/src/components/ui/level-duplicate-dialog.tsx +113 -0
- package/src/components/ui/panels/ceiling-panel.tsx +3 -28
- package/src/components/ui/panels/column-panel.tsx +759 -0
- package/src/components/ui/panels/door-panel.tsx +989 -290
- package/src/components/ui/panels/fence-panel.tsx +2 -49
- 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 +163 -0
- package/src/components/ui/panels/panel-manager.tsx +208 -28
- package/src/components/ui/panels/panel-wrapper.tsx +48 -39
- package/src/components/ui/panels/reference-panel.tsx +253 -5
- package/src/components/ui/panels/roof-panel.tsx +13 -64
- package/src/components/ui/panels/roof-segment-panel.tsx +0 -25
- package/src/components/ui/panels/slab-panel.tsx +4 -30
- package/src/components/ui/panels/spawn-panel.tsx +161 -0
- package/src/components/ui/panels/stair-panel.tsx +20 -74
- package/src/components/ui/panels/stair-segment-panel.tsx +0 -25
- package/src/components/ui/panels/wall-panel.tsx +10 -8
- package/src/components/ui/panels/window-panel.tsx +668 -139
- package/src/components/ui/primitives/number-input.tsx +1 -1
- package/src/components/ui/primitives/sidebar.tsx +0 -0
- package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
- package/src/components/ui/sidebar/icon-rail.tsx +0 -0
- package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
- package/src/components/ui/sidebar/panels/items-panel/index.tsx +330 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +0 -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 +2 -2
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +0 -0
- package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +105 -22
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +0 -0
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +76 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +11 -3
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +10 -5
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +1 -1
- package/src/components/ui/sidebar/tab-bar.tsx +3 -0
- package/src/components/ui/slider.tsx +1 -1
- package/src/components/viewer-overlay.tsx +0 -0
- package/src/components/viewer-zone-system.tsx +0 -0
- package/src/hooks/use-auto-frame.ts +45 -0
- package/src/hooks/use-auto-save.ts +14 -0
- package/src/hooks/use-keyboard.ts +74 -7
- package/src/hooks/use-mobile.ts +12 -12
- package/src/index.tsx +8 -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/level-duplication.test.ts +70 -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/scene.ts +0 -0
- package/src/lib/sfx-bus.ts +2 -0
- package/src/lib/sfx-player.ts +5 -5
- package/src/lib/stair-duplication.ts +126 -0
- package/src/lib/window-interaction.ts +86 -0
- package/src/store/use-editor.tsx +186 -62
- package/tsconfig.json +2 -1
- package/src/components/feedback-dialog.tsx +0 -265
- package/src/components/pascal-radio.tsx +0 -280
- package/src/components/preview-button.tsx +0 -16
- package/src/components/ui/viewer-toolbar.tsx +0 -395
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import '../../../three-types'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
emitter,
|
|
5
|
+
type GridEvent,
|
|
6
|
+
type SpawnNode,
|
|
7
|
+
sceneRegistry,
|
|
8
|
+
useLiveTransforms,
|
|
9
|
+
useScene,
|
|
10
|
+
} from '@pascal-app/core'
|
|
11
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
12
|
+
import { Vector3 } from 'three'
|
|
13
|
+
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
14
|
+
import useEditor from '../../../store/use-editor'
|
|
15
|
+
import { CursorSphere } from '../shared/cursor-sphere'
|
|
16
|
+
|
|
17
|
+
const roundToHalf = (value: number) => Math.round(value * 2) / 2
|
|
18
|
+
const worldVector = new Vector3()
|
|
19
|
+
|
|
20
|
+
function getLevelLocalSpawnPosition(node: SpawnNode, event: GridEvent): [number, number, number] {
|
|
21
|
+
const levelObject = node.parentId ? sceneRegistry.nodes.get(node.parentId) : null
|
|
22
|
+
if (!levelObject) {
|
|
23
|
+
return [
|
|
24
|
+
roundToHalf(event.localPosition[0]),
|
|
25
|
+
event.localPosition[1],
|
|
26
|
+
roundToHalf(event.localPosition[2]),
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
worldVector.set(event.position[0], event.position[1], event.position[2])
|
|
31
|
+
levelObject.updateWorldMatrix(true, false)
|
|
32
|
+
levelObject.worldToLocal(worldVector)
|
|
33
|
+
|
|
34
|
+
return [roundToHalf(worldVector.x), worldVector.y, roundToHalf(worldVector.z)]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const MoveSpawnTool: React.FC<{
|
|
38
|
+
node: SpawnNode
|
|
39
|
+
onCommitted?: (nodeId: SpawnNode['id']) => void
|
|
40
|
+
}> = ({ node, onCommitted }) => {
|
|
41
|
+
const [previewPosition, setPreviewPosition] = useState<[number, number, number]>(node.position)
|
|
42
|
+
|
|
43
|
+
const exitMoveMode = useCallback(() => {
|
|
44
|
+
useEditor.getState().setMovingNode(null)
|
|
45
|
+
}, [])
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
useScene.temporal.getState().pause()
|
|
49
|
+
|
|
50
|
+
let committed = false
|
|
51
|
+
|
|
52
|
+
const onGridMove = (event: GridEvent) => {
|
|
53
|
+
const nextPosition: [number, number, number] = [
|
|
54
|
+
roundToHalf(event.localPosition[0]),
|
|
55
|
+
event.localPosition[1],
|
|
56
|
+
roundToHalf(event.localPosition[2]),
|
|
57
|
+
]
|
|
58
|
+
setPreviewPosition(nextPosition)
|
|
59
|
+
useLiveTransforms.getState().set(node.id, {
|
|
60
|
+
position: [...nextPosition],
|
|
61
|
+
rotation: node.rotation,
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const onGridClick = (event: GridEvent) => {
|
|
66
|
+
const nextPosition = getLevelLocalSpawnPosition(node, event)
|
|
67
|
+
|
|
68
|
+
committed = true
|
|
69
|
+
useScene.temporal.getState().resume()
|
|
70
|
+
useScene.getState().updateNode(node.id, { position: nextPosition })
|
|
71
|
+
onCommitted?.(node.id)
|
|
72
|
+
useLiveTransforms.getState().clear(node.id)
|
|
73
|
+
sfxEmitter.emit('sfx:item-place')
|
|
74
|
+
exitMoveMode()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const onCancel = () => {
|
|
78
|
+
useLiveTransforms.getState().clear(node.id)
|
|
79
|
+
useScene.temporal.getState().resume()
|
|
80
|
+
exitMoveMode()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
emitter.on('grid:move', onGridMove)
|
|
84
|
+
emitter.on('grid:click', onGridClick)
|
|
85
|
+
emitter.on('tool:cancel', onCancel)
|
|
86
|
+
|
|
87
|
+
return () => {
|
|
88
|
+
emitter.off('grid:move', onGridMove)
|
|
89
|
+
emitter.off('grid:click', onGridClick)
|
|
90
|
+
emitter.off('tool:cancel', onCancel)
|
|
91
|
+
useLiveTransforms.getState().clear(node.id)
|
|
92
|
+
if (!committed) {
|
|
93
|
+
useScene.temporal.getState().resume()
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}, [exitMoveMode, node, onCommitted])
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<CursorSphere color="#60a5fa" height={2.2} position={previewPosition} showTooltip={false} />
|
|
100
|
+
)
|
|
101
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import '../../../three-types'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
emitter,
|
|
5
|
+
type GridEvent,
|
|
6
|
+
type LevelNode,
|
|
7
|
+
SpawnNode,
|
|
8
|
+
type SpawnNode as SpawnNodeType,
|
|
9
|
+
sceneRegistry,
|
|
10
|
+
useScene,
|
|
11
|
+
} from '@pascal-app/core'
|
|
12
|
+
import { useEffect, useRef, useState } from 'react'
|
|
13
|
+
import type { Group } from 'three'
|
|
14
|
+
import { Vector3 } 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 SPAWN_ICON = (
|
|
20
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
21
|
+
<img
|
|
22
|
+
alt="Spawn Point"
|
|
23
|
+
src="/icons/site.png"
|
|
24
|
+
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
|
25
|
+
/>
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
const roundToHalf = (value: number) => Math.round(value * 2) / 2
|
|
29
|
+
const worldVector = new Vector3()
|
|
30
|
+
|
|
31
|
+
function getExistingSpawnIds() {
|
|
32
|
+
const nodes = useScene.getState().nodes
|
|
33
|
+
return Object.values(nodes)
|
|
34
|
+
.filter((node) => node.type === 'spawn')
|
|
35
|
+
.map((node) => node.id)
|
|
36
|
+
.sort()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getLevelLocalSpawnPosition(
|
|
40
|
+
levelId: LevelNode['id'],
|
|
41
|
+
event: GridEvent,
|
|
42
|
+
): [number, number, number] {
|
|
43
|
+
const levelObject = sceneRegistry.nodes.get(levelId)
|
|
44
|
+
if (!levelObject) {
|
|
45
|
+
return [
|
|
46
|
+
roundToHalf(event.localPosition[0]),
|
|
47
|
+
event.localPosition[1],
|
|
48
|
+
roundToHalf(event.localPosition[2]),
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
worldVector.set(event.position[0], event.position[1], event.position[2])
|
|
53
|
+
levelObject.updateWorldMatrix(true, false)
|
|
54
|
+
levelObject.worldToLocal(worldVector)
|
|
55
|
+
|
|
56
|
+
return [roundToHalf(worldVector.x), worldVector.y, roundToHalf(worldVector.z)]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type SpawnToolProps = {
|
|
60
|
+
currentLevelId: LevelNode['id'] | null
|
|
61
|
+
onPlaced?: (spawnId: SpawnNodeType['id']) => void
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const SpawnTool: React.FC<SpawnToolProps> = ({ currentLevelId, onPlaced }) => {
|
|
65
|
+
const [, setCursorPosition] = useState<[number, number, number] | null>(null)
|
|
66
|
+
const cursorRef = useRef<Group>(null)
|
|
67
|
+
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (!currentLevelId) return
|
|
70
|
+
|
|
71
|
+
const onGridMove = (event: GridEvent) => {
|
|
72
|
+
const nextPosition: [number, number, number] = [
|
|
73
|
+
roundToHalf(event.localPosition[0]),
|
|
74
|
+
event.localPosition[1],
|
|
75
|
+
roundToHalf(event.localPosition[2]),
|
|
76
|
+
]
|
|
77
|
+
setCursorPosition(nextPosition)
|
|
78
|
+
cursorRef.current?.position.set(nextPosition[0], nextPosition[1], nextPosition[2])
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const onGridClick = (event: GridEvent) => {
|
|
82
|
+
const nextPosition = getLevelLocalSpawnPosition(currentLevelId, event)
|
|
83
|
+
|
|
84
|
+
const [existingSpawnId, ...duplicateSpawnIds] = getExistingSpawnIds()
|
|
85
|
+
if (existingSpawnId) {
|
|
86
|
+
useScene.getState().updateNode(existingSpawnId, {
|
|
87
|
+
parentId: currentLevelId,
|
|
88
|
+
position: nextPosition,
|
|
89
|
+
rotation: 0,
|
|
90
|
+
})
|
|
91
|
+
if (duplicateSpawnIds.length > 0) {
|
|
92
|
+
useScene.getState().deleteNodes(duplicateSpawnIds)
|
|
93
|
+
}
|
|
94
|
+
onPlaced?.(existingSpawnId)
|
|
95
|
+
} else {
|
|
96
|
+
const spawn = SpawnNode.parse({
|
|
97
|
+
name: 'Spawn Point',
|
|
98
|
+
position: nextPosition,
|
|
99
|
+
rotation: 0,
|
|
100
|
+
})
|
|
101
|
+
useScene.getState().createNode(spawn, currentLevelId)
|
|
102
|
+
onPlaced?.(spawn.id)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
sfxEmitter.emit('sfx:structure-build')
|
|
106
|
+
useEditor.getState().setTool(null)
|
|
107
|
+
useEditor.getState().setMode('select')
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
emitter.on('grid:move', onGridMove)
|
|
111
|
+
emitter.on('grid:click', onGridClick)
|
|
112
|
+
|
|
113
|
+
return () => {
|
|
114
|
+
emitter.off('grid:move', onGridMove)
|
|
115
|
+
emitter.off('grid:click', onGridClick)
|
|
116
|
+
}
|
|
117
|
+
}, [currentLevelId, onPlaced])
|
|
118
|
+
|
|
119
|
+
if (!currentLevelId) return null
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<CursorSphere
|
|
123
|
+
color="#60a5fa"
|
|
124
|
+
height={2.2}
|
|
125
|
+
ref={cursorRef}
|
|
126
|
+
showTooltip
|
|
127
|
+
tooltipContent={SPAWN_ICON}
|
|
128
|
+
/>
|
|
129
|
+
)
|
|
130
|
+
}
|
|
@@ -10,6 +10,7 @@ import useEditor, { type Phase, type Tool } from '../../store/use-editor'
|
|
|
10
10
|
import { CeilingBoundaryEditor } from './ceiling/ceiling-boundary-editor'
|
|
11
11
|
import { CeilingHoleEditor } from './ceiling/ceiling-hole-editor'
|
|
12
12
|
import { CeilingTool } from './ceiling/ceiling-tool'
|
|
13
|
+
import { ColumnTool } from './column/column-tool'
|
|
13
14
|
import { DoorTool } from './door/door-tool'
|
|
14
15
|
import { CurveFenceTool } from './fence/curve-fence-tool'
|
|
15
16
|
import { FenceTool } from './fence/fence-tool'
|
|
@@ -21,6 +22,7 @@ import { SiteBoundaryEditor } from './site/site-boundary-editor'
|
|
|
21
22
|
import { SlabBoundaryEditor } from './slab/slab-boundary-editor'
|
|
22
23
|
import { SlabHoleEditor } from './slab/slab-hole-editor'
|
|
23
24
|
import { SlabTool } from './slab/slab-tool'
|
|
25
|
+
import { SpawnTool } from './spawn/spawn-tool'
|
|
24
26
|
import { StairTool } from './stair/stair-tool'
|
|
25
27
|
import { CurveWallTool } from './wall/curve-wall-tool'
|
|
26
28
|
import { MoveWallEndpointTool } from './wall/move-wall-endpoint-tool'
|
|
@@ -43,6 +45,7 @@ const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = {
|
|
|
43
45
|
door: DoorTool,
|
|
44
46
|
item: ItemTool,
|
|
45
47
|
zone: ZoneTool,
|
|
48
|
+
spawn: SpawnTool,
|
|
46
49
|
window: WindowTool,
|
|
47
50
|
},
|
|
48
51
|
furnish: {
|
|
@@ -61,8 +64,9 @@ export const ToolManager: React.FC = () => {
|
|
|
61
64
|
const curvingFence = useEditor((state) => state.curvingFence)
|
|
62
65
|
const editingHole = useEditor((state) => state.editingHole)
|
|
63
66
|
const selectedZoneId = useViewer((state) => state.selection.zoneId)
|
|
64
|
-
const buildingId = useViewer((state) => state.selection.buildingId)
|
|
65
67
|
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
|
68
|
+
const buildingId = useViewer((state) => state.selection.buildingId)
|
|
69
|
+
const activeLevelId = useViewer((state) => state.selection.levelId)
|
|
66
70
|
const nodes = useScene((state) => state.nodes)
|
|
67
71
|
|
|
68
72
|
// Building transform for the local group — all building-relative tools live inside this group
|
|
@@ -123,12 +127,15 @@ export const ToolManager: React.FC = () => {
|
|
|
123
127
|
const showBuildTool = mode === 'build' && tool !== null
|
|
124
128
|
|
|
125
129
|
const BuildToolComponent = showBuildTool ? tools[phase]?.[tool] : null
|
|
130
|
+
const handlePlacedNodeSelected = (nodeId: AnyNodeId) => {
|
|
131
|
+
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
|
132
|
+
}
|
|
126
133
|
|
|
127
134
|
return (
|
|
128
135
|
<>
|
|
129
|
-
{showSiteBoundaryEditor && <SiteBoundaryEditor />}
|
|
130
136
|
{/* World-space tools: site boundary and building movement operate in world coordinates */}
|
|
131
|
-
{
|
|
137
|
+
{showSiteBoundaryEditor && <SiteBoundaryEditor />}
|
|
138
|
+
{movingNode?.type === 'building' && <MoveTool onSpawnMoved={handlePlacedNodeSelected} />}
|
|
132
139
|
|
|
133
140
|
{/* Building-local group: all other tools are relative to the selected building.
|
|
134
141
|
Cursor visuals set positions in building-local space; this group applies the
|
|
@@ -152,8 +159,16 @@ export const ToolManager: React.FC = () => {
|
|
|
152
159
|
{movingFenceEndpoint && <MoveFenceEndpointTool target={movingFenceEndpoint} />}
|
|
153
160
|
{curvingWall && <CurveWallTool node={curvingWall} />}
|
|
154
161
|
{curvingFence && <CurveFenceTool node={curvingFence} />}
|
|
155
|
-
{movingNode && movingNode.type !== 'building' &&
|
|
156
|
-
|
|
162
|
+
{movingNode && movingNode.type !== 'building' && (
|
|
163
|
+
<MoveTool onSpawnMoved={handlePlacedNodeSelected} />
|
|
164
|
+
)}
|
|
165
|
+
{!movingNode && BuildToolComponent && tool === 'spawn' ? (
|
|
166
|
+
<SpawnTool currentLevelId={activeLevelId ?? null} onPlaced={handlePlacedNodeSelected} />
|
|
167
|
+
) : !movingNode && showBuildTool && tool === 'column' ? (
|
|
168
|
+
<ColumnTool currentLevelId={activeLevelId ?? null} onPlaced={handlePlacedNodeSelected} />
|
|
169
|
+
) : !movingNode && BuildToolComponent && tool !== 'column' ? (
|
|
170
|
+
<BuildToolComponent />
|
|
171
|
+
) : null}
|
|
157
172
|
</group>
|
|
158
173
|
</>
|
|
159
174
|
)
|
|
@@ -81,15 +81,17 @@ export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
|
|
81
81
|
? event.localPosition[2]
|
|
82
82
|
: snapScalarToGrid(event.localPosition[2], snapStep)
|
|
83
83
|
|
|
84
|
-
const offsetFromMidpoint =
|
|
85
|
-
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
)
|
|
84
|
+
const offsetFromMidpoint = -(
|
|
85
|
+
(localX - chord.midpoint.x) * chord.normal.x +
|
|
86
|
+
(localZ - chord.midpoint.y) * chord.normal.y
|
|
87
|
+
)
|
|
89
88
|
const snappedOffset = shiftPressedRef.current
|
|
90
89
|
? offsetFromMidpoint
|
|
91
90
|
: snapScalarToGrid(offsetFromMidpoint, snapStep)
|
|
92
|
-
const nextCurveOffset = normalizeWallCurveOffset(
|
|
91
|
+
const nextCurveOffset = normalizeWallCurveOffset(
|
|
92
|
+
node,
|
|
93
|
+
Math.max(-maxCurveOffset, Math.min(maxCurveOffset, snappedOffset)),
|
|
94
|
+
)
|
|
93
95
|
|
|
94
96
|
if (
|
|
95
97
|
previousCurveOffsetRef.current !== null &&
|
|
@@ -9,27 +9,87 @@ import {
|
|
|
9
9
|
useScene,
|
|
10
10
|
type WallNode,
|
|
11
11
|
} from '@pascal-app/core'
|
|
12
|
-
import { Html } from '@react-three/drei'
|
|
13
12
|
import { useViewer } from '@pascal-app/viewer'
|
|
13
|
+
import { Html } from '@react-three/drei'
|
|
14
14
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
15
15
|
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
|
16
16
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
17
17
|
import useEditor, { type MovingWallEndpoint } from '../../../store/use-editor'
|
|
18
18
|
import { CursorSphere } from '../shared/cursor-sphere'
|
|
19
19
|
import {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
} from '
|
|
20
|
+
formatAngleRadians,
|
|
21
|
+
getAngleToSegmentReference,
|
|
22
|
+
getSegmentAngleReferenceAtPoint,
|
|
23
|
+
} from '../shared/segment-angle'
|
|
24
|
+
import { isWallLongEnough, snapWallDraftPoint, type WallPlanPoint } from './wall-drafting'
|
|
24
25
|
|
|
25
26
|
function samePoint(a: WallPlanPoint, b: WallPlanPoint) {
|
|
26
27
|
return a[0] === b[0] && a[1] === b[1]
|
|
27
28
|
}
|
|
28
29
|
|
|
30
|
+
type WallSegmentLike = {
|
|
31
|
+
id: WallNode['id']
|
|
32
|
+
start: WallPlanPoint
|
|
33
|
+
end: WallPlanPoint
|
|
34
|
+
curveOffset?: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type AngleLabelState = {
|
|
38
|
+
label: string
|
|
39
|
+
position: [number, number, number]
|
|
40
|
+
} | null
|
|
41
|
+
|
|
42
|
+
function getEndpointAngleLabel(args: {
|
|
43
|
+
preview: { start: WallPlanPoint; end: WallPlanPoint; curveOffset?: number }
|
|
44
|
+
walls: WallSegmentLike[]
|
|
45
|
+
nodeId: WallNode['id']
|
|
46
|
+
}): AngleLabelState {
|
|
47
|
+
const { preview, walls, nodeId } = args
|
|
48
|
+
const endpoints = [
|
|
49
|
+
{
|
|
50
|
+
point: preview.start,
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
point: preview.end,
|
|
54
|
+
},
|
|
55
|
+
]
|
|
56
|
+
const targetSegment: WallSegmentLike = {
|
|
57
|
+
id: nodeId,
|
|
58
|
+
start: preview.start,
|
|
59
|
+
end: preview.end,
|
|
60
|
+
curveOffset: preview.curveOffset,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
for (const endpoint of endpoints) {
|
|
64
|
+
const targetReference = getSegmentAngleReferenceAtPoint(endpoint.point, targetSegment)
|
|
65
|
+
if (!targetReference) continue
|
|
66
|
+
|
|
67
|
+
const connectedWall = walls.find(
|
|
68
|
+
(wall) =>
|
|
69
|
+
wall.id !== nodeId && Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, wall)),
|
|
70
|
+
)
|
|
71
|
+
if (!connectedWall) continue
|
|
72
|
+
|
|
73
|
+
const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedWall)
|
|
74
|
+
if (!connectedReference) continue
|
|
75
|
+
|
|
76
|
+
const angle = getAngleToSegmentReference(targetReference.vector, connectedReference)
|
|
77
|
+
if (angle === null) continue
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
label: formatAngleRadians(angle),
|
|
81
|
+
position: [endpoint.point[0], 0.34, endpoint.point[1]],
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return null
|
|
86
|
+
}
|
|
87
|
+
|
|
29
88
|
type LinkedWallSnapshot = {
|
|
30
89
|
id: WallNode['id']
|
|
31
90
|
start: WallPlanPoint
|
|
32
91
|
end: WallPlanPoint
|
|
92
|
+
curveOffset?: number
|
|
33
93
|
}
|
|
34
94
|
|
|
35
95
|
function getLinkedWallSnapshots(args: {
|
|
@@ -52,10 +112,12 @@ function getLinkedWallSnapshots(args: {
|
|
|
52
112
|
}
|
|
53
113
|
|
|
54
114
|
if (
|
|
55
|
-
!
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
115
|
+
!(
|
|
116
|
+
samePoint(node.start, originalStart) ||
|
|
117
|
+
samePoint(node.start, originalEnd) ||
|
|
118
|
+
samePoint(node.end, originalStart) ||
|
|
119
|
+
samePoint(node.end, originalEnd)
|
|
120
|
+
)
|
|
59
121
|
) {
|
|
60
122
|
continue
|
|
61
123
|
}
|
|
@@ -64,6 +126,7 @@ function getLinkedWallSnapshots(args: {
|
|
|
64
126
|
id: node.id,
|
|
65
127
|
start: [...node.start] as WallPlanPoint,
|
|
66
128
|
end: [...node.end] as WallPlanPoint,
|
|
129
|
+
curveOffset: node.curveOffset,
|
|
67
130
|
})
|
|
68
131
|
}
|
|
69
132
|
|
|
@@ -79,6 +142,7 @@ function getLinkedWallUpdates(
|
|
|
79
142
|
) {
|
|
80
143
|
return linkedWalls.map((wall) => ({
|
|
81
144
|
id: wall.id,
|
|
145
|
+
curveOffset: wall.curveOffset,
|
|
82
146
|
start: samePoint(wall.start, originalStart)
|
|
83
147
|
? nextStart
|
|
84
148
|
: samePoint(wall.start, originalEnd)
|
|
@@ -114,6 +178,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
|
|
|
114
178
|
}),
|
|
115
179
|
)
|
|
116
180
|
const previewRef = useRef<{ start: WallPlanPoint; end: WallPlanPoint } | null>(null)
|
|
181
|
+
const [angleLabel, setAngleLabel] = useState<AngleLabelState>(null)
|
|
117
182
|
|
|
118
183
|
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
|
|
119
184
|
const point = target.endpoint === 'start' ? target.wall.start : target.wall.end
|
|
@@ -155,24 +220,43 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
|
|
|
155
220
|
const applyPreview = (movingPoint: WallPlanPoint, detachLinkedWalls = false) => {
|
|
156
221
|
const nextStart = target.endpoint === 'start' ? movingPoint : fixedPoint
|
|
157
222
|
const nextEnd = target.endpoint === 'end' ? movingPoint : fixedPoint
|
|
223
|
+
const linkedUpdates = detachLinkedWalls
|
|
224
|
+
? []
|
|
225
|
+
: getLinkedWallUpdates(
|
|
226
|
+
linkedOriginalsRef.current,
|
|
227
|
+
originalStart,
|
|
228
|
+
originalEnd,
|
|
229
|
+
nextStart,
|
|
230
|
+
nextEnd,
|
|
231
|
+
)
|
|
158
232
|
previewRef.current = { start: nextStart, end: nextEnd }
|
|
159
233
|
setCursorLocalPos([movingPoint[0], 0, movingPoint[1]])
|
|
160
|
-
|
|
161
|
-
{
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
234
|
+
setAngleLabel(
|
|
235
|
+
getEndpointAngleLabel({
|
|
236
|
+
preview: { start: nextStart, end: nextEnd, curveOffset: target.wall.curveOffset },
|
|
237
|
+
walls: [
|
|
238
|
+
...levelWalls.map((wall) => ({
|
|
239
|
+
id: wall.id,
|
|
240
|
+
start: wall.start,
|
|
241
|
+
end: wall.end,
|
|
242
|
+
curveOffset: wall.curveOffset,
|
|
243
|
+
})),
|
|
244
|
+
...linkedUpdates,
|
|
245
|
+
],
|
|
246
|
+
nodeId,
|
|
247
|
+
}),
|
|
248
|
+
)
|
|
249
|
+
applyNodePreview([{ id: nodeId, start: nextStart, end: nextEnd }, ...linkedUpdates])
|
|
172
250
|
}
|
|
173
251
|
|
|
174
|
-
const restoreOriginal = () => {
|
|
175
|
-
applyNodePreview([
|
|
252
|
+
const restoreOriginal = (clearAngleLabel = true) => {
|
|
253
|
+
applyNodePreview([
|
|
254
|
+
{ id: nodeId, start: originalStart, end: originalEnd },
|
|
255
|
+
...linkedOriginalsRef.current,
|
|
256
|
+
])
|
|
257
|
+
if (clearAngleLabel) {
|
|
258
|
+
setAngleLabel(null)
|
|
259
|
+
}
|
|
176
260
|
}
|
|
177
261
|
|
|
178
262
|
const onGridMove = (event: GridEvent) => {
|
|
@@ -204,8 +288,9 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
|
|
|
204
288
|
}
|
|
205
289
|
|
|
206
290
|
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
|
|
207
|
-
const hasChanged =
|
|
208
|
-
|
|
291
|
+
const hasChanged = !(
|
|
292
|
+
samePoint(preview.start, originalStart) && samePoint(preview.end, originalEnd)
|
|
293
|
+
)
|
|
209
294
|
|
|
210
295
|
if (hasChanged && isWallLongEnough(preview.start, preview.end)) {
|
|
211
296
|
wasCommitted = true
|
|
@@ -235,6 +320,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
|
|
|
235
320
|
}
|
|
236
321
|
|
|
237
322
|
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
|
323
|
+
setAngleLabel(null)
|
|
238
324
|
exitMoveMode()
|
|
239
325
|
event.nativeEvent?.stopPropagation?.()
|
|
240
326
|
}
|
|
@@ -243,6 +329,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
|
|
|
243
329
|
restoreOriginal()
|
|
244
330
|
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
|
245
331
|
resumeSceneHistory(useScene)
|
|
332
|
+
setAngleLabel(null)
|
|
246
333
|
markToolCancelConsumed()
|
|
247
334
|
exitMoveMode()
|
|
248
335
|
}
|
|
@@ -285,7 +372,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
|
|
|
285
372
|
|
|
286
373
|
return () => {
|
|
287
374
|
if (!wasCommitted) {
|
|
288
|
-
restoreOriginal()
|
|
375
|
+
restoreOriginal(false)
|
|
289
376
|
}
|
|
290
377
|
resumeSceneHistory(useScene)
|
|
291
378
|
emitter.off('grid:move', onGridMove)
|
|
@@ -307,7 +394,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
|
|
|
307
394
|
>
|
|
308
395
|
<div className="translate-y-10">
|
|
309
396
|
<div
|
|
310
|
-
className={`whitespace-nowrap rounded-full border px-2 py-1 text-[11px]
|
|
397
|
+
className={`whitespace-nowrap rounded-full border px-2 py-1 font-medium text-[11px] shadow-lg backdrop-blur-md transition-colors ${
|
|
311
398
|
altPressed
|
|
312
399
|
? 'border-amber-500/80 bg-amber-500/15 text-amber-100'
|
|
313
400
|
: 'border-border bg-background/95 text-muted-foreground'
|
|
@@ -317,6 +404,23 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
|
|
|
317
404
|
</div>
|
|
318
405
|
</div>
|
|
319
406
|
</Html>
|
|
407
|
+
{angleLabel && <EndpointAngleLabel label={angleLabel.label} position={angleLabel.position} />}
|
|
320
408
|
</group>
|
|
321
409
|
)
|
|
322
410
|
}
|
|
411
|
+
|
|
412
|
+
function EndpointAngleLabel({
|
|
413
|
+
label,
|
|
414
|
+
position,
|
|
415
|
+
}: {
|
|
416
|
+
label: string
|
|
417
|
+
position: [number, number, number]
|
|
418
|
+
}) {
|
|
419
|
+
return (
|
|
420
|
+
<Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
|
|
421
|
+
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono font-semibold text-[11px] text-foreground shadow-lg backdrop-blur-md">
|
|
422
|
+
{label}
|
|
423
|
+
</div>
|
|
424
|
+
</Html>
|
|
425
|
+
)
|
|
426
|
+
}
|
|
@@ -63,10 +63,12 @@ function getLinkedWallSnapshots(args: {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
if (
|
|
66
|
-
!
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
66
|
+
!(
|
|
67
|
+
samePoint(node.start, originalStart) ||
|
|
68
|
+
samePoint(node.start, originalEnd) ||
|
|
69
|
+
samePoint(node.end, originalStart) ||
|
|
70
|
+
samePoint(node.end, originalEnd)
|
|
71
|
+
)
|
|
70
72
|
) {
|
|
71
73
|
continue
|
|
72
74
|
}
|
|
@@ -3,7 +3,10 @@ import {
|
|
|
3
3
|
type AnyNodeId,
|
|
4
4
|
type DoorNode,
|
|
5
5
|
getScaledDimensions,
|
|
6
|
+
getWallCurveFrameAt,
|
|
7
|
+
getWallCurveLength,
|
|
6
8
|
type ItemNode,
|
|
9
|
+
isCurvedWall,
|
|
7
10
|
useScene,
|
|
8
11
|
type WallNode,
|
|
9
12
|
WallNode as WallSchema,
|
|
@@ -62,10 +65,10 @@ export function snapPointTo45Degrees(
|
|
|
62
65
|
const snappedAngle = Math.round(angle / angleStep) * angleStep
|
|
63
66
|
const distance = Math.sqrt(dx * dx + dz * dz)
|
|
64
67
|
|
|
65
|
-
return snapPointToGrid(
|
|
66
|
-
start[0] + Math.cos(snappedAngle) * distance,
|
|
67
|
-
|
|
68
|
-
|
|
68
|
+
return snapPointToGrid(
|
|
69
|
+
[start[0] + Math.cos(snappedAngle) * distance, start[1] + Math.sin(snappedAngle) * distance],
|
|
70
|
+
step,
|
|
71
|
+
)
|
|
69
72
|
}
|
|
70
73
|
|
|
71
74
|
export function getWallAngleSnapStep(step = getWallGridStep()): number {
|
|
@@ -336,11 +339,17 @@ export function findWallSnapTarget(
|
|
|
336
339
|
continue
|
|
337
340
|
}
|
|
338
341
|
|
|
339
|
-
const candidates: Array<WallPlanPoint | null> = [
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
342
|
+
const candidates: Array<WallPlanPoint | null> = [wall.start, wall.end]
|
|
343
|
+
|
|
344
|
+
if (isCurvedWall(wall)) {
|
|
345
|
+
const sampleCount = Math.max(8, Math.ceil(getWallCurveLength(wall) / 0.3))
|
|
346
|
+
for (let index = 0; index <= sampleCount; index += 1) {
|
|
347
|
+
const frame = getWallCurveFrameAt(wall, index / sampleCount)
|
|
348
|
+
candidates.push([frame.point.x, frame.point.y])
|
|
349
|
+
}
|
|
350
|
+
} else {
|
|
351
|
+
candidates.push(projectPointOntoWall(point, wall))
|
|
352
|
+
}
|
|
344
353
|
for (const candidate of candidates) {
|
|
345
354
|
if (!candidate) {
|
|
346
355
|
continue
|