@pascal-app/editor 0.6.0 → 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 +9 -5
- 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 +20 -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 +32 -55
- package/src/components/editor/floorplan-background-selection.ts +113 -0
- package/src/components/editor/floorplan-panel.tsx +9855 -3298
- package/src/components/editor/index.tsx +269 -21
- package/src/components/editor/selection-manager.tsx +575 -13
- package/src/components/editor/thumbnail-generator.tsx +38 -7
- 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 +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/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/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/fence-drafting.ts +10 -3
- package/src/components/tools/fence/fence-tool.tsx +159 -3
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +129 -18
- package/src/components/tools/fence/move-fence-tool.tsx +101 -34
- package/src/components/tools/item/move-tool.tsx +10 -1
- package/src/components/tools/item/placement-math.ts +30 -1
- package/src/components/tools/item/placement-strategies.ts +109 -31
- 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 +660 -52
- package/src/components/tools/roof/move-roof-tool.tsx +22 -15
- 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 +18 -3
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +121 -20
- package/src/components/tools/wall/wall-drafting.ts +18 -9
- package/src/components/tools/wall/wall-tool.tsx +134 -2
- package/src/components/tools/window/move-window-tool.tsx +18 -0
- package/src/components/tools/window/window-tool.tsx +5 -0
- package/src/components/ui/action-menu/camera-actions.tsx +37 -33
- package/src/components/ui/action-menu/control-modes.tsx +28 -1
- 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 +18 -1
- package/src/components/ui/controls/material-picker.tsx +152 -165
- 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 +5 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1116 -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 +1 -25
- package/src/components/ui/panels/column-panel.tsx +715 -0
- package/src/components/ui/panels/door-panel.tsx +981 -289
- package/src/components/ui/panels/fence-panel.tsx +3 -45
- 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 +210 -1
- package/src/components/ui/panels/panel-wrapper.tsx +48 -39
- package/src/components/ui/panels/reference-panel.tsx +238 -5
- package/src/components/ui/panels/roof-panel.tsx +4 -105
- 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 +155 -0
- package/src/components/ui/panels/stair-panel.tsx +11 -117
- package/src/components/ui/panels/stair-segment-panel.tsx +0 -25
- package/src/components/ui/panels/wall-panel.tsx +1 -95
- package/src/components/ui/panels/window-panel.tsx +660 -139
- 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 +2 -2
- package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +109 -24
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +82 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +9 -3
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +8 -5
- package/src/components/ui/sidebar/tab-bar.tsx +3 -0
- package/src/components/ui/viewer-toolbar.tsx +42 -1
- package/src/hooks/use-auto-frame.ts +45 -0
- package/src/hooks/use-keyboard.ts +64 -7
- package/src/hooks/use-mobile.ts +12 -12
- 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 +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/stair-duplication.ts +126 -0
- package/src/lib/window-interaction.ts +86 -0
- package/src/store/use-editor.tsx +164 -8
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -248,6 +248,13 @@ export const DoorTool: React.FC = () => {
|
|
|
248
248
|
parentId: event.node.id,
|
|
249
249
|
width: draft.width,
|
|
250
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,
|
|
251
258
|
frameThickness: draft.frameThickness,
|
|
252
259
|
frameDepth: draft.frameDepth,
|
|
253
260
|
threshold: draft.threshold,
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
isCurvedWall,
|
|
6
6
|
sceneRegistry,
|
|
7
7
|
spatialGridManager,
|
|
8
|
+
useLiveTransforms,
|
|
8
9
|
useScene,
|
|
9
10
|
type WallEvent,
|
|
10
11
|
} from '@pascal-app/core'
|
|
@@ -97,6 +98,18 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
|
|
97
98
|
edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44)
|
|
98
99
|
}
|
|
99
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
|
+
|
|
100
113
|
const onWallEnter = (event: WallEvent) => {
|
|
101
114
|
if (!isValidWallSideFace(event.normal)) return
|
|
102
115
|
if (isCurvedWall(event.node)) {
|
|
@@ -105,9 +118,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
|
|
105
118
|
}
|
|
106
119
|
if (event.node.parentId !== getLevelId()) return
|
|
107
120
|
|
|
108
|
-
const side =
|
|
109
|
-
const itemRotation = calculateItemRotation(event.normal)
|
|
110
|
-
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
|
121
|
+
const { side, itemRotation, cursorRotation } = getPlacementOrientation(event)
|
|
111
122
|
|
|
112
123
|
const localX = snapToHalf(event.localPosition[0])
|
|
113
124
|
const { clampedX, clampedY } = clampToWall(
|
|
@@ -127,6 +138,10 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
|
|
127
138
|
parentId: event.node.id,
|
|
128
139
|
wallId: event.node.id,
|
|
129
140
|
})
|
|
141
|
+
useLiveTransforms.getState().set(movingDoorNode.id, {
|
|
142
|
+
position: [clampedX, clampedY, 0],
|
|
143
|
+
rotation: itemRotation,
|
|
144
|
+
})
|
|
130
145
|
|
|
131
146
|
if (prevWallId && prevWallId !== event.node.id) markWallDirty(prevWallId)
|
|
132
147
|
markWallDirty(event.node.id)
|
|
@@ -162,9 +177,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
|
|
162
177
|
}
|
|
163
178
|
if (event.node.parentId !== getLevelId()) return
|
|
164
179
|
|
|
165
|
-
const side =
|
|
166
|
-
const itemRotation = calculateItemRotation(event.normal)
|
|
167
|
-
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
|
180
|
+
const { side, itemRotation, cursorRotation } = getPlacementOrientation(event)
|
|
168
181
|
|
|
169
182
|
const localX = snapToHalf(event.localPosition[0])
|
|
170
183
|
const { clampedX, clampedY } = clampToWall(
|
|
@@ -195,6 +208,10 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
|
|
195
208
|
doorMesh.updateMatrixWorld(true)
|
|
196
209
|
}
|
|
197
210
|
}
|
|
211
|
+
useLiveTransforms.getState().set(movingDoorNode.id, {
|
|
212
|
+
position: [clampedX, clampedY, 0],
|
|
213
|
+
rotation: itemRotation,
|
|
214
|
+
})
|
|
198
215
|
markWallDirty(event.node.id)
|
|
199
216
|
|
|
200
217
|
const valid = !hasWallChildOverlap(
|
|
@@ -225,8 +242,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
|
|
225
242
|
if (isCurvedWall(event.node)) return
|
|
226
243
|
if (event.node.parentId !== getLevelId()) return
|
|
227
244
|
|
|
228
|
-
const side =
|
|
229
|
-
const itemRotation = calculateItemRotation(event.normal)
|
|
245
|
+
const { side, itemRotation } = getPlacementOrientation(event)
|
|
230
246
|
|
|
231
247
|
const localX = snapToHalf(event.localPosition[0])
|
|
232
248
|
const { clampedX, clampedY } = clampToWall(
|
|
@@ -291,6 +307,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
|
|
291
307
|
}
|
|
292
308
|
|
|
293
309
|
markWallDirty(event.node.id)
|
|
310
|
+
useLiveTransforms.getState().clear(movingDoorNode.id)
|
|
294
311
|
useScene.temporal.getState().pause()
|
|
295
312
|
|
|
296
313
|
sfxEmitter.emit('sfx:item-place')
|
|
@@ -302,6 +319,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
|
|
302
319
|
|
|
303
320
|
const onWallLeave = () => {
|
|
304
321
|
hideCursor()
|
|
322
|
+
useLiveTransforms.getState().clear(movingDoorNode.id)
|
|
305
323
|
if (isNew) return
|
|
306
324
|
if (currentWallId && currentWallId !== original.parentId) {
|
|
307
325
|
markWallDirty(currentWallId)
|
|
@@ -318,6 +336,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
|
|
318
336
|
}
|
|
319
337
|
|
|
320
338
|
const onCancel = () => {
|
|
339
|
+
useLiveTransforms.getState().clear(movingDoorNode.id)
|
|
321
340
|
if (isNew) {
|
|
322
341
|
useScene.getState().deleteNode(movingDoorNode.id)
|
|
323
342
|
if (currentWallId) markWallDirty(currentWallId)
|
|
@@ -364,6 +383,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
|
|
364
383
|
if (original.parentId) markWallDirty(original.parentId)
|
|
365
384
|
}
|
|
366
385
|
}
|
|
386
|
+
useLiveTransforms.getState().clear(movingDoorNode.id)
|
|
367
387
|
useScene.temporal.getState().resume()
|
|
368
388
|
emitter.off('wall:enter', onWallEnter)
|
|
369
389
|
emitter.off('wall:move', onWallMove)
|
|
@@ -1,14 +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 {
|
|
12
|
+
findWallSnapTarget,
|
|
5
13
|
getWallAngleSnapStep,
|
|
6
14
|
getWallGridStep,
|
|
7
|
-
type WallPlanPoint,
|
|
8
|
-
findWallSnapTarget,
|
|
9
15
|
isWallLongEnough,
|
|
10
16
|
snapPointTo45Degrees,
|
|
11
17
|
snapPointToGrid,
|
|
18
|
+
type WallPlanPoint,
|
|
12
19
|
} from '../wall/wall-drafting'
|
|
13
20
|
|
|
14
21
|
export type FencePlanPoint = WallPlanPoint
|
|
@@ -7,19 +7,129 @@ import {
|
|
|
7
7
|
type WallNode,
|
|
8
8
|
} from '@pascal-app/core'
|
|
9
9
|
import { useViewer } from '@pascal-app/viewer'
|
|
10
|
-
import {
|
|
10
|
+
import { Html } from '@react-three/drei'
|
|
11
|
+
import { useEffect, useRef, useState } from 'react'
|
|
11
12
|
import { DoubleSide, type Group, type Mesh, Shape, ShapeGeometry, Vector3 } from 'three'
|
|
12
13
|
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
|
13
14
|
import { EDITOR_LAYER } from '../../../lib/constants'
|
|
14
15
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
15
16
|
import { CursorSphere } from '../shared/cursor-sphere'
|
|
17
|
+
import {
|
|
18
|
+
formatAngleRadians,
|
|
19
|
+
getAngleToSegmentReference,
|
|
20
|
+
getSegmentAngleReferenceAtPoint,
|
|
21
|
+
} from '../shared/segment-angle'
|
|
16
22
|
import {
|
|
17
23
|
createFenceOnCurrentLevel,
|
|
18
|
-
snapFenceDraftPoint,
|
|
19
24
|
type FencePlanPoint,
|
|
25
|
+
snapFenceDraftPoint,
|
|
20
26
|
} from './fence-drafting'
|
|
21
27
|
|
|
22
28
|
const FENCE_PREVIEW_HEIGHT = 1.8
|
|
29
|
+
const DRAFT_LABEL_Y = FENCE_PREVIEW_HEIGHT + 0.22
|
|
30
|
+
const DRAFT_ANGLE_LABEL_Y = 0.28
|
|
31
|
+
|
|
32
|
+
type DraftAngleLabel = {
|
|
33
|
+
id: string
|
|
34
|
+
label: string
|
|
35
|
+
position: [number, number, number]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type DraftMeasurementState = {
|
|
39
|
+
lengthLabel: string
|
|
40
|
+
lengthPosition: [number, number, number]
|
|
41
|
+
angleLabels: DraftAngleLabel[]
|
|
42
|
+
} | null
|
|
43
|
+
|
|
44
|
+
type SegmentLike = {
|
|
45
|
+
id: string
|
|
46
|
+
start: FencePlanPoint
|
|
47
|
+
end: FencePlanPoint
|
|
48
|
+
curveOffset?: number
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
|
|
52
|
+
if (unit === 'imperial') {
|
|
53
|
+
const feet = value * 3.280_84
|
|
54
|
+
const wholeFeet = Math.floor(feet)
|
|
55
|
+
const inches = Math.round((feet - wholeFeet) * 12)
|
|
56
|
+
if (inches === 12) return `${wholeFeet + 1}'0"`
|
|
57
|
+
return `${wholeFeet}'${inches}"`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return `${Number.parseFloat(value.toFixed(2))}m`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function getDraftAngleLabels(
|
|
64
|
+
start: FencePlanPoint,
|
|
65
|
+
end: FencePlanPoint,
|
|
66
|
+
segments: SegmentLike[],
|
|
67
|
+
): DraftAngleLabel[] {
|
|
68
|
+
const draftFromStart: FencePlanPoint = [end[0] - start[0], end[1] - start[1]]
|
|
69
|
+
const draftFromEnd: FencePlanPoint = [start[0] - end[0], start[1] - end[1]]
|
|
70
|
+
const endpoints = [
|
|
71
|
+
{ id: 'start', point: start, draftVector: draftFromStart },
|
|
72
|
+
{ id: 'end', point: end, draftVector: draftFromEnd },
|
|
73
|
+
]
|
|
74
|
+
const labels: DraftAngleLabel[] = []
|
|
75
|
+
|
|
76
|
+
for (const endpoint of endpoints) {
|
|
77
|
+
const connectedSegment = segments.find((segment) =>
|
|
78
|
+
Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, segment)),
|
|
79
|
+
)
|
|
80
|
+
if (!connectedSegment) continue
|
|
81
|
+
|
|
82
|
+
const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedSegment)
|
|
83
|
+
if (!connectedReference) continue
|
|
84
|
+
|
|
85
|
+
const angle = getAngleToSegmentReference(endpoint.draftVector, connectedReference)
|
|
86
|
+
if (angle === null) continue
|
|
87
|
+
|
|
88
|
+
labels.push({
|
|
89
|
+
id: endpoint.id,
|
|
90
|
+
label: formatAngleRadians(angle),
|
|
91
|
+
position: [endpoint.point[0], DRAFT_ANGLE_LABEL_Y, endpoint.point[1]],
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return labels
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function getDraftMeasurementState(
|
|
99
|
+
start: FencePlanPoint,
|
|
100
|
+
end: FencePlanPoint,
|
|
101
|
+
segments: SegmentLike[],
|
|
102
|
+
unit: 'metric' | 'imperial',
|
|
103
|
+
): DraftMeasurementState {
|
|
104
|
+
const dx = end[0] - start[0]
|
|
105
|
+
const dz = end[1] - start[1]
|
|
106
|
+
const length = Math.hypot(dx, dz)
|
|
107
|
+
|
|
108
|
+
if (length < 0.01) return null
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
lengthLabel: formatMeasurement(length, unit),
|
|
112
|
+
lengthPosition: [(start[0] + end[0]) / 2, DRAFT_LABEL_Y, (start[1] + end[1]) / 2],
|
|
113
|
+
angleLabels: getDraftAngleLabels(start, end, segments),
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function getReferenceSegments(walls: WallNode[], fences: FenceNode[]): SegmentLike[] {
|
|
118
|
+
return [
|
|
119
|
+
...walls.map((wall) => ({
|
|
120
|
+
id: wall.id,
|
|
121
|
+
start: wall.start,
|
|
122
|
+
end: wall.end,
|
|
123
|
+
curveOffset: wall.curveOffset,
|
|
124
|
+
})),
|
|
125
|
+
...fences.map((fence) => ({
|
|
126
|
+
id: fence.id,
|
|
127
|
+
start: fence.start,
|
|
128
|
+
end: fence.end,
|
|
129
|
+
curveOffset: fence.curveOffset,
|
|
130
|
+
})),
|
|
131
|
+
]
|
|
132
|
+
}
|
|
23
133
|
|
|
24
134
|
const updateFencePreview = (mesh: Mesh, start: Vector3, end: Vector3) => {
|
|
25
135
|
const direction = new Vector3(end.x - start.x, 0, end.z - start.z)
|
|
@@ -70,12 +180,14 @@ const getCurrentLevelElements = (): { walls: WallNode[]; fences: FenceNode[] } =
|
|
|
70
180
|
}
|
|
71
181
|
|
|
72
182
|
export const FenceTool: React.FC = () => {
|
|
183
|
+
const unit = useViewer((state) => state.unit)
|
|
73
184
|
const cursorRef = useRef<Group>(null)
|
|
74
185
|
const previewRef = useRef<Mesh>(null!)
|
|
75
186
|
const startingPoint = useRef(new Vector3(0, 0, 0))
|
|
76
187
|
const endingPoint = useRef(new Vector3(0, 0, 0))
|
|
77
188
|
const buildingState = useRef(0)
|
|
78
189
|
const shiftPressed = useRef(false)
|
|
190
|
+
const [draftMeasurement, setDraftMeasurement] = useState<DraftMeasurementState>(null)
|
|
79
191
|
|
|
80
192
|
useEffect(() => {
|
|
81
193
|
let previousFenceEnd: [number, number] | null = null
|
|
@@ -107,9 +219,18 @@ export const FenceTool: React.FC = () => {
|
|
|
107
219
|
previousFenceEnd = currentFenceEnd
|
|
108
220
|
|
|
109
221
|
updateFencePreview(previewRef.current, startingPoint.current, endingPoint.current)
|
|
222
|
+
setDraftMeasurement(
|
|
223
|
+
getDraftMeasurementState(
|
|
224
|
+
[startingPoint.current.x, startingPoint.current.z],
|
|
225
|
+
snappedLocal,
|
|
226
|
+
getReferenceSegments(walls, fences),
|
|
227
|
+
unit,
|
|
228
|
+
),
|
|
229
|
+
)
|
|
110
230
|
} else {
|
|
111
231
|
const snappedPoint = snapFenceDraftPoint({ point: localPoint, walls, fences })
|
|
112
232
|
cursorRef.current.position.set(snappedPoint[0], event.localPosition[1], snappedPoint[1])
|
|
233
|
+
setDraftMeasurement(null)
|
|
113
234
|
}
|
|
114
235
|
}
|
|
115
236
|
|
|
@@ -123,6 +244,7 @@ export const FenceTool: React.FC = () => {
|
|
|
123
244
|
endingPoint.current.copy(startingPoint.current)
|
|
124
245
|
buildingState.current = 1
|
|
125
246
|
previewRef.current.visible = true
|
|
247
|
+
setDraftMeasurement(null)
|
|
126
248
|
} else {
|
|
127
249
|
const snappedEnd = snapFenceDraftPoint({
|
|
128
250
|
point: localClick,
|
|
@@ -137,6 +259,7 @@ export const FenceTool: React.FC = () => {
|
|
|
137
259
|
createFenceOnCurrentLevel([startingPoint.current.x, startingPoint.current.z], snappedEnd)
|
|
138
260
|
previewRef.current.visible = false
|
|
139
261
|
buildingState.current = 0
|
|
262
|
+
setDraftMeasurement(null)
|
|
140
263
|
}
|
|
141
264
|
}
|
|
142
265
|
|
|
@@ -153,6 +276,7 @@ export const FenceTool: React.FC = () => {
|
|
|
153
276
|
markToolCancelConsumed()
|
|
154
277
|
buildingState.current = 0
|
|
155
278
|
previewRef.current.visible = false
|
|
279
|
+
setDraftMeasurement(null)
|
|
156
280
|
}
|
|
157
281
|
}
|
|
158
282
|
|
|
@@ -169,7 +293,7 @@ export const FenceTool: React.FC = () => {
|
|
|
169
293
|
window.removeEventListener('keydown', onKeyDown)
|
|
170
294
|
window.removeEventListener('keyup', onKeyUp)
|
|
171
295
|
}
|
|
172
|
-
}, [])
|
|
296
|
+
}, [unit])
|
|
173
297
|
|
|
174
298
|
return (
|
|
175
299
|
<group>
|
|
@@ -185,6 +309,38 @@ export const FenceTool: React.FC = () => {
|
|
|
185
309
|
transparent
|
|
186
310
|
/>
|
|
187
311
|
</mesh>
|
|
312
|
+
|
|
313
|
+
{draftMeasurement && (
|
|
314
|
+
<>
|
|
315
|
+
<DraftMeasurementLabel
|
|
316
|
+
label={draftMeasurement.lengthLabel}
|
|
317
|
+
position={draftMeasurement.lengthPosition}
|
|
318
|
+
/>
|
|
319
|
+
{draftMeasurement.angleLabels.map((angleLabel) => (
|
|
320
|
+
<DraftMeasurementLabel
|
|
321
|
+
key={angleLabel.id}
|
|
322
|
+
label={angleLabel.label}
|
|
323
|
+
position={angleLabel.position}
|
|
324
|
+
/>
|
|
325
|
+
))}
|
|
326
|
+
</>
|
|
327
|
+
)}
|
|
188
328
|
</group>
|
|
189
329
|
)
|
|
190
330
|
}
|
|
331
|
+
|
|
332
|
+
function DraftMeasurementLabel({
|
|
333
|
+
label,
|
|
334
|
+
position,
|
|
335
|
+
}: {
|
|
336
|
+
label: string
|
|
337
|
+
position: [number, number, number]
|
|
338
|
+
}) {
|
|
339
|
+
return (
|
|
340
|
+
<Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
|
|
341
|
+
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono text-[11px] font-semibold text-foreground shadow-lg backdrop-blur-md">
|
|
342
|
+
{label}
|
|
343
|
+
</div>
|
|
344
|
+
</Html>
|
|
345
|
+
)
|
|
346
|
+
}
|