@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,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'
|
|
@@ -61,8 +63,10 @@ export const ToolManager: React.FC = () => {
|
|
|
61
63
|
const curvingFence = useEditor((state) => state.curvingFence)
|
|
62
64
|
const editingHole = useEditor((state) => state.editingHole)
|
|
63
65
|
const selectedZoneId = useViewer((state) => state.selection.zoneId)
|
|
66
|
+
const selectedLevelId = useViewer((state) => state.selection.levelId)
|
|
64
67
|
const buildingId = useViewer((state) => state.selection.buildingId)
|
|
65
68
|
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
|
69
|
+
const setSelection = useViewer((state) => state.setSelection)
|
|
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
|
+
setSelection({ selectedIds: [nodeId] })
|
|
132
|
+
}
|
|
126
133
|
|
|
127
134
|
return (
|
|
128
135
|
<>
|
|
129
136
|
{showSiteBoundaryEditor && <SiteBoundaryEditor />}
|
|
130
137
|
{/* World-space tools: site boundary and building movement operate in world coordinates */}
|
|
131
|
-
{movingNode?.type === 'building' && <MoveTool />}
|
|
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 && showBuildTool && tool === 'spawn' && (
|
|
166
|
+
<SpawnTool currentLevelId={selectedLevelId} onPlaced={handlePlacedNodeSelected} />
|
|
167
|
+
)}
|
|
168
|
+
{!movingNode && showBuildTool && tool === 'column' && (
|
|
169
|
+
<ColumnTool currentLevelId={selectedLevelId} onPlaced={handlePlacedNodeSelected} />
|
|
170
|
+
)}
|
|
171
|
+
{!movingNode && BuildToolComponent && tool !== 'column' && <BuildToolComponent />}
|
|
157
172
|
</group>
|
|
158
173
|
</>
|
|
159
174
|
)
|
|
@@ -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: {
|
|
@@ -64,6 +124,7 @@ function getLinkedWallSnapshots(args: {
|
|
|
64
124
|
id: node.id,
|
|
65
125
|
start: [...node.start] as WallPlanPoint,
|
|
66
126
|
end: [...node.end] as WallPlanPoint,
|
|
127
|
+
curveOffset: node.curveOffset,
|
|
67
128
|
})
|
|
68
129
|
}
|
|
69
130
|
|
|
@@ -79,6 +140,7 @@ function getLinkedWallUpdates(
|
|
|
79
140
|
) {
|
|
80
141
|
return linkedWalls.map((wall) => ({
|
|
81
142
|
id: wall.id,
|
|
143
|
+
curveOffset: wall.curveOffset,
|
|
82
144
|
start: samePoint(wall.start, originalStart)
|
|
83
145
|
? nextStart
|
|
84
146
|
: samePoint(wall.start, originalEnd)
|
|
@@ -114,6 +176,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
|
|
|
114
176
|
}),
|
|
115
177
|
)
|
|
116
178
|
const previewRef = useRef<{ start: WallPlanPoint; end: WallPlanPoint } | null>(null)
|
|
179
|
+
const [angleLabel, setAngleLabel] = useState<AngleLabelState>(null)
|
|
117
180
|
|
|
118
181
|
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
|
|
119
182
|
const point = target.endpoint === 'start' ? target.wall.start : target.wall.end
|
|
@@ -155,24 +218,43 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
|
|
|
155
218
|
const applyPreview = (movingPoint: WallPlanPoint, detachLinkedWalls = false) => {
|
|
156
219
|
const nextStart = target.endpoint === 'start' ? movingPoint : fixedPoint
|
|
157
220
|
const nextEnd = target.endpoint === 'end' ? movingPoint : fixedPoint
|
|
221
|
+
const linkedUpdates = detachLinkedWalls
|
|
222
|
+
? []
|
|
223
|
+
: getLinkedWallUpdates(
|
|
224
|
+
linkedOriginalsRef.current,
|
|
225
|
+
originalStart,
|
|
226
|
+
originalEnd,
|
|
227
|
+
nextStart,
|
|
228
|
+
nextEnd,
|
|
229
|
+
)
|
|
158
230
|
previewRef.current = { start: nextStart, end: nextEnd }
|
|
159
231
|
setCursorLocalPos([movingPoint[0], 0, movingPoint[1]])
|
|
160
|
-
|
|
161
|
-
{
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
232
|
+
setAngleLabel(
|
|
233
|
+
getEndpointAngleLabel({
|
|
234
|
+
preview: { start: nextStart, end: nextEnd, curveOffset: target.wall.curveOffset },
|
|
235
|
+
walls: [
|
|
236
|
+
...levelWalls.map((wall) => ({
|
|
237
|
+
id: wall.id,
|
|
238
|
+
start: wall.start,
|
|
239
|
+
end: wall.end,
|
|
240
|
+
curveOffset: wall.curveOffset,
|
|
241
|
+
})),
|
|
242
|
+
...linkedUpdates,
|
|
243
|
+
],
|
|
244
|
+
nodeId,
|
|
245
|
+
}),
|
|
246
|
+
)
|
|
247
|
+
applyNodePreview([{ id: nodeId, start: nextStart, end: nextEnd }, ...linkedUpdates])
|
|
172
248
|
}
|
|
173
249
|
|
|
174
|
-
const restoreOriginal = () => {
|
|
175
|
-
applyNodePreview([
|
|
250
|
+
const restoreOriginal = (clearAngleLabel = true) => {
|
|
251
|
+
applyNodePreview([
|
|
252
|
+
{ id: nodeId, start: originalStart, end: originalEnd },
|
|
253
|
+
...linkedOriginalsRef.current,
|
|
254
|
+
])
|
|
255
|
+
if (clearAngleLabel) {
|
|
256
|
+
setAngleLabel(null)
|
|
257
|
+
}
|
|
176
258
|
}
|
|
177
259
|
|
|
178
260
|
const onGridMove = (event: GridEvent) => {
|
|
@@ -235,6 +317,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
|
|
|
235
317
|
}
|
|
236
318
|
|
|
237
319
|
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
|
320
|
+
setAngleLabel(null)
|
|
238
321
|
exitMoveMode()
|
|
239
322
|
event.nativeEvent?.stopPropagation?.()
|
|
240
323
|
}
|
|
@@ -243,6 +326,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
|
|
|
243
326
|
restoreOriginal()
|
|
244
327
|
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
|
245
328
|
resumeSceneHistory(useScene)
|
|
329
|
+
setAngleLabel(null)
|
|
246
330
|
markToolCancelConsumed()
|
|
247
331
|
exitMoveMode()
|
|
248
332
|
}
|
|
@@ -285,7 +369,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
|
|
|
285
369
|
|
|
286
370
|
return () => {
|
|
287
371
|
if (!wasCommitted) {
|
|
288
|
-
restoreOriginal()
|
|
372
|
+
restoreOriginal(false)
|
|
289
373
|
}
|
|
290
374
|
resumeSceneHistory(useScene)
|
|
291
375
|
emitter.off('grid:move', onGridMove)
|
|
@@ -317,6 +401,23 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
|
|
|
317
401
|
</div>
|
|
318
402
|
</div>
|
|
319
403
|
</Html>
|
|
404
|
+
{angleLabel && <EndpointAngleLabel label={angleLabel.label} position={angleLabel.position} />}
|
|
320
405
|
</group>
|
|
321
406
|
)
|
|
322
407
|
}
|
|
408
|
+
|
|
409
|
+
function EndpointAngleLabel({
|
|
410
|
+
label,
|
|
411
|
+
position,
|
|
412
|
+
}: {
|
|
413
|
+
label: string
|
|
414
|
+
position: [number, number, number]
|
|
415
|
+
}) {
|
|
416
|
+
return (
|
|
417
|
+
<Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
|
|
418
|
+
<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">
|
|
419
|
+
{label}
|
|
420
|
+
</div>
|
|
421
|
+
</Html>
|
|
422
|
+
)
|
|
423
|
+
}
|
|
@@ -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
|
|
@@ -1,14 +1,100 @@
|
|
|
1
1
|
import { emitter, type GridEvent, type LevelNode, useScene, type WallNode } from '@pascal-app/core'
|
|
2
2
|
import { useViewer } from '@pascal-app/viewer'
|
|
3
|
-
import {
|
|
3
|
+
import { Html } from '@react-three/drei'
|
|
4
|
+
import { useEffect, useRef, useState } from 'react'
|
|
4
5
|
import { DoubleSide, type Group, type Mesh, Shape, ShapeGeometry, Vector3 } from 'three'
|
|
5
6
|
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
|
6
7
|
import { EDITOR_LAYER } from '../../../lib/constants'
|
|
7
8
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
8
9
|
import { CursorSphere } from '../shared/cursor-sphere'
|
|
10
|
+
import {
|
|
11
|
+
formatAngleRadians,
|
|
12
|
+
getAngleToSegmentReference,
|
|
13
|
+
getSegmentAngleReferenceAtPoint,
|
|
14
|
+
} from '../shared/segment-angle'
|
|
9
15
|
import { createWallOnCurrentLevel, snapWallDraftPoint, type WallPlanPoint } from './wall-drafting'
|
|
10
16
|
|
|
11
17
|
const WALL_HEIGHT = 2.5
|
|
18
|
+
const DRAFT_LABEL_Y = WALL_HEIGHT + 0.22
|
|
19
|
+
const DRAFT_ANGLE_LABEL_Y = 0.28
|
|
20
|
+
|
|
21
|
+
type DraftAngleLabel = {
|
|
22
|
+
id: string
|
|
23
|
+
label: string
|
|
24
|
+
position: [number, number, number]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type DraftMeasurementState = {
|
|
28
|
+
lengthLabel: string
|
|
29
|
+
lengthPosition: [number, number, number]
|
|
30
|
+
angleLabels: DraftAngleLabel[]
|
|
31
|
+
} | null
|
|
32
|
+
|
|
33
|
+
function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
|
|
34
|
+
if (unit === 'imperial') {
|
|
35
|
+
const feet = value * 3.280_84
|
|
36
|
+
const wholeFeet = Math.floor(feet)
|
|
37
|
+
const inches = Math.round((feet - wholeFeet) * 12)
|
|
38
|
+
if (inches === 12) return `${wholeFeet + 1}'0"`
|
|
39
|
+
return `${wholeFeet}'${inches}"`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return `${Number.parseFloat(value.toFixed(2))}m`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function getDraftAngleLabels(
|
|
46
|
+
start: WallPlanPoint,
|
|
47
|
+
end: WallPlanPoint,
|
|
48
|
+
walls: WallNode[],
|
|
49
|
+
): DraftAngleLabel[] {
|
|
50
|
+
const draftFromStart: WallPlanPoint = [end[0] - start[0], end[1] - start[1]]
|
|
51
|
+
const draftFromEnd: WallPlanPoint = [start[0] - end[0], start[1] - end[1]]
|
|
52
|
+
const endpoints = [
|
|
53
|
+
{ id: 'start', point: start, draftVector: draftFromStart },
|
|
54
|
+
{ id: 'end', point: end, draftVector: draftFromEnd },
|
|
55
|
+
]
|
|
56
|
+
const labels: DraftAngleLabel[] = []
|
|
57
|
+
|
|
58
|
+
for (const endpoint of endpoints) {
|
|
59
|
+
const connectedWall = walls.find((wall) =>
|
|
60
|
+
Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, wall)),
|
|
61
|
+
)
|
|
62
|
+
if (!connectedWall) continue
|
|
63
|
+
|
|
64
|
+
const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedWall)
|
|
65
|
+
if (!connectedReference) continue
|
|
66
|
+
|
|
67
|
+
const angle = getAngleToSegmentReference(endpoint.draftVector, connectedReference)
|
|
68
|
+
if (angle === null) continue
|
|
69
|
+
|
|
70
|
+
labels.push({
|
|
71
|
+
id: endpoint.id,
|
|
72
|
+
label: formatAngleRadians(angle),
|
|
73
|
+
position: [endpoint.point[0], DRAFT_ANGLE_LABEL_Y, endpoint.point[1]],
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return labels
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function getDraftMeasurementState(
|
|
81
|
+
start: WallPlanPoint,
|
|
82
|
+
end: WallPlanPoint,
|
|
83
|
+
walls: WallNode[],
|
|
84
|
+
unit: 'metric' | 'imperial',
|
|
85
|
+
): DraftMeasurementState {
|
|
86
|
+
const dx = end[0] - start[0]
|
|
87
|
+
const dz = end[1] - start[1]
|
|
88
|
+
const length = Math.hypot(dx, dz)
|
|
89
|
+
|
|
90
|
+
if (length < 0.01) return null
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
lengthLabel: formatMeasurement(length, unit),
|
|
94
|
+
lengthPosition: [(start[0] + end[0]) / 2, DRAFT_LABEL_Y, (start[1] + end[1]) / 2],
|
|
95
|
+
angleLabels: getDraftAngleLabels(start, end, walls),
|
|
96
|
+
}
|
|
97
|
+
}
|
|
12
98
|
|
|
13
99
|
/**
|
|
14
100
|
* Update wall preview mesh geometry to create a vertical plane between two points
|
|
@@ -67,12 +153,14 @@ const getCurrentLevelWalls = (): WallNode[] => {
|
|
|
67
153
|
}
|
|
68
154
|
|
|
69
155
|
export const WallTool: React.FC = () => {
|
|
156
|
+
const unit = useViewer((state) => state.unit)
|
|
70
157
|
const cursorRef = useRef<Group>(null)
|
|
71
158
|
const wallPreviewRef = useRef<Mesh>(null!)
|
|
72
159
|
const startingPoint = useRef(new Vector3(0, 0, 0))
|
|
73
160
|
const endingPoint = useRef(new Vector3(0, 0, 0))
|
|
74
161
|
const buildingState = useRef(0)
|
|
75
162
|
const shiftPressed = useRef(false)
|
|
163
|
+
const [draftMeasurement, setDraftMeasurement] = useState<DraftMeasurementState>(null)
|
|
76
164
|
|
|
77
165
|
useEffect(() => {
|
|
78
166
|
let gridPosition: WallPlanPoint = [0, 0]
|
|
@@ -109,9 +197,18 @@ export const WallTool: React.FC = () => {
|
|
|
109
197
|
previousWallEnd = currentWallEnd
|
|
110
198
|
|
|
111
199
|
updateWallPreview(wallPreviewRef.current, startingPoint.current, endingPoint.current)
|
|
200
|
+
setDraftMeasurement(
|
|
201
|
+
getDraftMeasurementState(
|
|
202
|
+
[startingPoint.current.x, startingPoint.current.z],
|
|
203
|
+
snappedLocal,
|
|
204
|
+
walls,
|
|
205
|
+
unit,
|
|
206
|
+
),
|
|
207
|
+
)
|
|
112
208
|
} else {
|
|
113
209
|
// Not drawing a wall yet, show the snapped anchor point.
|
|
114
210
|
cursorRef.current.position.set(gridPosition[0], event.localPosition[1], gridPosition[1])
|
|
211
|
+
setDraftMeasurement(null)
|
|
115
212
|
}
|
|
116
213
|
}
|
|
117
214
|
|
|
@@ -126,6 +223,7 @@ export const WallTool: React.FC = () => {
|
|
|
126
223
|
endingPoint.current.copy(startingPoint.current)
|
|
127
224
|
buildingState.current = 1
|
|
128
225
|
wallPreviewRef.current.visible = true
|
|
226
|
+
setDraftMeasurement(null)
|
|
129
227
|
} else if (buildingState.current === 1) {
|
|
130
228
|
const snappedEnd = snapWallDraftPoint({
|
|
131
229
|
point: localClick,
|
|
@@ -140,6 +238,7 @@ export const WallTool: React.FC = () => {
|
|
|
140
238
|
createWallOnCurrentLevel([startingPoint.current.x, startingPoint.current.z], snappedEnd)
|
|
141
239
|
wallPreviewRef.current.visible = false
|
|
142
240
|
buildingState.current = 0
|
|
241
|
+
setDraftMeasurement(null)
|
|
143
242
|
}
|
|
144
243
|
}
|
|
145
244
|
|
|
@@ -160,6 +259,7 @@ export const WallTool: React.FC = () => {
|
|
|
160
259
|
markToolCancelConsumed()
|
|
161
260
|
buildingState.current = 0
|
|
162
261
|
wallPreviewRef.current.visible = false
|
|
262
|
+
setDraftMeasurement(null)
|
|
163
263
|
}
|
|
164
264
|
}
|
|
165
265
|
|
|
@@ -176,7 +276,7 @@ export const WallTool: React.FC = () => {
|
|
|
176
276
|
window.removeEventListener('keydown', onKeyDown)
|
|
177
277
|
window.removeEventListener('keyup', onKeyUp)
|
|
178
278
|
}
|
|
179
|
-
}, [])
|
|
279
|
+
}, [unit])
|
|
180
280
|
|
|
181
281
|
return (
|
|
182
282
|
<group>
|
|
@@ -195,6 +295,38 @@ export const WallTool: React.FC = () => {
|
|
|
195
295
|
transparent
|
|
196
296
|
/>
|
|
197
297
|
</mesh>
|
|
298
|
+
|
|
299
|
+
{draftMeasurement && (
|
|
300
|
+
<>
|
|
301
|
+
<DraftMeasurementLabel
|
|
302
|
+
label={draftMeasurement.lengthLabel}
|
|
303
|
+
position={draftMeasurement.lengthPosition}
|
|
304
|
+
/>
|
|
305
|
+
{draftMeasurement.angleLabels.map((angleLabel) => (
|
|
306
|
+
<DraftMeasurementLabel
|
|
307
|
+
key={angleLabel.id}
|
|
308
|
+
label={angleLabel.label}
|
|
309
|
+
position={angleLabel.position}
|
|
310
|
+
/>
|
|
311
|
+
))}
|
|
312
|
+
</>
|
|
313
|
+
)}
|
|
198
314
|
</group>
|
|
199
315
|
)
|
|
200
316
|
}
|
|
317
|
+
|
|
318
|
+
function DraftMeasurementLabel({
|
|
319
|
+
label,
|
|
320
|
+
position,
|
|
321
|
+
}: {
|
|
322
|
+
label: string
|
|
323
|
+
position: [number, number, number]
|
|
324
|
+
}) {
|
|
325
|
+
return (
|
|
326
|
+
<Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
|
|
327
|
+
<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">
|
|
328
|
+
{label}
|
|
329
|
+
</div>
|
|
330
|
+
</Html>
|
|
331
|
+
)
|
|
332
|
+
}
|