@pascal-app/editor 0.5.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +12 -7
- package/src/components/editor/bottom-sheet.tsx +149 -0
- package/src/components/editor/custom-camera-controls.tsx +75 -7
- package/src/components/editor/editor-layout-mobile.tsx +264 -0
- package/src/components/editor/editor-layout-v2.tsx +29 -0
- package/src/components/editor/first-person/build-collider-world.ts +365 -0
- package/src/components/editor/first-person/bvh-ecctrl.tsx +795 -0
- package/src/components/editor/first-person-controls.tsx +496 -143
- package/src/components/editor/floating-action-menu.tsx +281 -83
- package/src/components/editor/floating-building-action-menu.tsx +4 -3
- package/src/components/editor/floorplan-background-selection.ts +113 -0
- package/src/components/editor/floorplan-panel.tsx +10442 -3275
- package/src/components/editor/index.tsx +270 -20
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/selection-manager.tsx +766 -12
- package/src/components/editor/site-edge-labels.tsx +9 -3
- package/src/components/editor/thumbnail-generator.tsx +350 -157
- package/src/components/editor/use-floorplan-background-placement.ts +257 -0
- package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
- package/src/components/editor/use-floorplan-scene-data.ts +189 -0
- package/src/components/editor/wall-measurement-label.tsx +377 -58
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
- package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
- package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +119 -0
- package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +197 -0
- package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
- package/src/components/editor-2d/svg-paths.ts +119 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +272 -0
- package/src/components/systems/roof/roof-edit-system.tsx +5 -5
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +2 -0
- package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
- package/src/components/tools/column/column-tool.tsx +97 -0
- package/src/components/tools/column/move-column-tool.tsx +105 -0
- package/src/components/tools/door/door-tool.tsx +19 -0
- package/src/components/tools/door/move-door-tool.tsx +38 -8
- package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
- package/src/components/tools/fence/fence-drafting.ts +27 -8
- package/src/components/tools/fence/fence-tool.tsx +159 -3
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +438 -0
- package/src/components/tools/fence/move-fence-tool.tsx +102 -27
- package/src/components/tools/item/move-tool.tsx +19 -1
- package/src/components/tools/item/placement-math.ts +44 -7
- package/src/components/tools/item/placement-strategies.ts +111 -33
- package/src/components/tools/item/placement-types.ts +7 -0
- package/src/components/tools/item/use-draft-node.ts +2 -0
- package/src/components/tools/item/use-placement-coordinator.tsx +701 -61
- package/src/components/tools/roof/move-roof-tool.tsx +111 -43
- package/src/components/tools/shared/polygon-editor.tsx +244 -29
- package/src/components/tools/shared/segment-angle.ts +156 -0
- package/src/components/tools/slab/move-slab-tool.tsx +182 -0
- package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +2 -0
- package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
- package/src/components/tools/spawn/spawn-tool.tsx +130 -0
- package/src/components/tools/stair/stair-tool.tsx +11 -3
- package/src/components/tools/tool-manager.tsx +30 -3
- package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +423 -0
- package/src/components/tools/wall/move-wall-tool.tsx +356 -0
- package/src/components/tools/wall/wall-drafting.ts +348 -17
- package/src/components/tools/wall/wall-tool.tsx +134 -2
- package/src/components/tools/window/move-window-tool.tsx +28 -0
- package/src/components/tools/window/window-tool.tsx +17 -0
- package/src/components/ui/action-menu/camera-actions.tsx +37 -33
- package/src/components/ui/action-menu/control-modes.tsx +37 -5
- package/src/components/ui/action-menu/index.tsx +91 -1
- package/src/components/ui/action-menu/structure-tools.tsx +2 -0
- package/src/components/ui/action-menu/view-toggles.tsx +424 -35
- package/src/components/ui/command-palette/editor-commands.tsx +27 -5
- package/src/components/ui/command-palette/index.tsx +0 -1
- package/src/components/ui/controls/material-picker.tsx +189 -169
- package/src/components/ui/controls/slider-control.tsx +88 -26
- package/src/components/ui/floating-level-selector.tsx +286 -55
- package/src/components/ui/helpers/helper-manager.tsx +5 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1121 -1219
- package/src/components/ui/item-catalog/item-catalog.tsx +42 -175
- package/src/components/ui/level-duplicate-dialog.tsx +115 -0
- package/src/components/ui/panels/ceiling-panel.tsx +47 -27
- package/src/components/ui/panels/column-panel.tsx +715 -0
- package/src/components/ui/panels/door-panel.tsx +986 -294
- package/src/components/ui/panels/fence-panel.tsx +55 -12
- package/src/components/ui/panels/item-panel.tsx +5 -5
- package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
- package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
- package/src/components/ui/panels/node-display.ts +39 -0
- package/src/components/ui/panels/paint-panel.tsx +138 -0
- package/src/components/ui/panels/panel-manager.tsx +241 -30
- package/src/components/ui/panels/panel-wrapper.tsx +48 -39
- package/src/components/ui/panels/reference-panel.tsx +243 -9
- package/src/components/ui/panels/roof-panel.tsx +30 -62
- package/src/components/ui/panels/roof-segment-panel.tsx +8 -23
- package/src/components/ui/panels/slab-panel.tsx +46 -24
- package/src/components/ui/panels/spawn-panel.tsx +155 -0
- package/src/components/ui/panels/stair-panel.tsx +117 -69
- package/src/components/ui/panels/stair-segment-panel.tsx +13 -27
- package/src/components/ui/panels/wall-panel.tsx +71 -17
- package/src/components/ui/panels/window-panel.tsx +665 -146
- package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +9 -5
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +138 -56
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +9 -5
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +82 -0
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +3 -3
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +12 -6
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +15 -8
- package/src/components/ui/sidebar/tab-bar.tsx +3 -0
- package/src/components/ui/viewer-toolbar.tsx +96 -2
- package/src/components/viewer-overlay.tsx +25 -19
- package/src/hooks/use-auto-frame.ts +45 -0
- package/src/hooks/use-contextual-tools.ts +14 -13
- package/src/hooks/use-keyboard.ts +67 -9
- package/src/hooks/use-mobile.ts +12 -12
- package/src/index.tsx +2 -1
- package/src/lib/door-interaction.ts +88 -0
- package/src/lib/floorplan/geometry.ts +263 -0
- package/src/lib/floorplan/index.ts +38 -0
- package/src/lib/floorplan/items.ts +179 -0
- package/src/lib/floorplan/selection-tool.ts +231 -0
- package/src/lib/floorplan/stairs.ts +478 -0
- package/src/lib/floorplan/types.ts +57 -0
- package/src/lib/floorplan/walls.ts +23 -0
- package/src/lib/guide-events.ts +10 -0
- package/src/lib/history.ts +20 -0
- package/src/lib/level-duplication.test.ts +72 -0
- package/src/lib/level-duplication.ts +153 -0
- package/src/lib/local-guide-image.ts +42 -0
- package/src/lib/material-paint.ts +284 -0
- package/src/lib/roof-duplication.ts +214 -0
- package/src/lib/scene-bounds.test.ts +183 -0
- package/src/lib/scene-bounds.ts +169 -0
- package/src/lib/sfx-player.ts +96 -13
- package/src/lib/stair-duplication.ts +126 -0
- package/src/lib/window-interaction.ts +86 -0
- package/src/store/use-editor.tsx +279 -15
|
@@ -1,12 +1,39 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type AnyNode,
|
|
3
|
+
type AnyNodeId,
|
|
4
|
+
type DoorNode,
|
|
5
|
+
getScaledDimensions,
|
|
6
|
+
getWallCurveFrameAt,
|
|
7
|
+
getWallCurveLength,
|
|
8
|
+
type ItemNode,
|
|
9
|
+
isCurvedWall,
|
|
10
|
+
useScene,
|
|
11
|
+
type WallNode,
|
|
12
|
+
WallNode as WallSchema,
|
|
13
|
+
type WindowNode,
|
|
14
|
+
} from '@pascal-app/core'
|
|
2
15
|
import { useViewer } from '@pascal-app/viewer'
|
|
3
16
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
17
|
+
import useEditor from '../../../store/use-editor'
|
|
4
18
|
|
|
5
19
|
export type WallPlanPoint = [number, number]
|
|
6
20
|
|
|
7
21
|
export const WALL_GRID_STEP = 0.5
|
|
8
22
|
export const WALL_JOIN_SNAP_RADIUS = 0.35
|
|
9
23
|
export const WALL_MIN_LENGTH = 0.01
|
|
24
|
+
const DEFAULT_WALL_ANGLE_SNAP_STEP = Math.PI / 4
|
|
25
|
+
|
|
26
|
+
const WALL_ANGLE_SNAP_BY_GRID_STEP: Record<number, number> = {
|
|
27
|
+
0.5: Math.PI / 4,
|
|
28
|
+
0.25: Math.PI / 8,
|
|
29
|
+
0.1: Math.PI / 12,
|
|
30
|
+
0.05: Math.PI / 36,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type WallSplitIntersection = {
|
|
34
|
+
wallId: WallNode['id']
|
|
35
|
+
point: WallPlanPoint
|
|
36
|
+
}
|
|
10
37
|
|
|
11
38
|
function distanceSquared(a: WallPlanPoint, b: WallPlanPoint): number {
|
|
12
39
|
const dx = a[0] - b[0]
|
|
@@ -14,7 +41,11 @@ function distanceSquared(a: WallPlanPoint, b: WallPlanPoint): number {
|
|
|
14
41
|
return dx * dx + dz * dz
|
|
15
42
|
}
|
|
16
43
|
|
|
17
|
-
function
|
|
44
|
+
export function getWallGridStep(): number {
|
|
45
|
+
return useEditor.getState().gridSnapStep
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function snapScalarToGrid(value: number, step = WALL_GRID_STEP): number {
|
|
18
49
|
return Math.round(value / step) * step
|
|
19
50
|
}
|
|
20
51
|
|
|
@@ -22,17 +53,26 @@ export function snapPointToGrid(point: WallPlanPoint, step = WALL_GRID_STEP): Wa
|
|
|
22
53
|
return [snapScalarToGrid(point[0], step), snapScalarToGrid(point[1], step)]
|
|
23
54
|
}
|
|
24
55
|
|
|
25
|
-
export function snapPointTo45Degrees(
|
|
56
|
+
export function snapPointTo45Degrees(
|
|
57
|
+
start: WallPlanPoint,
|
|
58
|
+
cursor: WallPlanPoint,
|
|
59
|
+
step = WALL_GRID_STEP,
|
|
60
|
+
angleStep = DEFAULT_WALL_ANGLE_SNAP_STEP,
|
|
61
|
+
): WallPlanPoint {
|
|
26
62
|
const dx = cursor[0] - start[0]
|
|
27
63
|
const dz = cursor[1] - start[1]
|
|
28
64
|
const angle = Math.atan2(dz, dx)
|
|
29
|
-
const snappedAngle = Math.round(angle /
|
|
65
|
+
const snappedAngle = Math.round(angle / angleStep) * angleStep
|
|
30
66
|
const distance = Math.sqrt(dx * dx + dz * dz)
|
|
31
67
|
|
|
32
|
-
return snapPointToGrid(
|
|
33
|
-
start[0] + Math.cos(snappedAngle) * distance,
|
|
34
|
-
|
|
35
|
-
|
|
68
|
+
return snapPointToGrid(
|
|
69
|
+
[start[0] + Math.cos(snappedAngle) * distance, start[1] + Math.sin(snappedAngle) * distance],
|
|
70
|
+
step,
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function getWallAngleSnapStep(step = getWallGridStep()): number {
|
|
75
|
+
return WALL_ANGLE_SNAP_BY_GRID_STEP[step] ?? DEFAULT_WALL_ANGLE_SNAP_STEP
|
|
36
76
|
}
|
|
37
77
|
|
|
38
78
|
function projectPointOntoWall(point: WallPlanPoint, wall: WallNode): WallPlanPoint | null {
|
|
@@ -53,6 +93,237 @@ function projectPointOntoWall(point: WallPlanPoint, wall: WallNode): WallPlanPoi
|
|
|
53
93
|
return [x1 + dx * t, z1 + dz * t]
|
|
54
94
|
}
|
|
55
95
|
|
|
96
|
+
function splitWallAtPoint(wall: WallNode, splitPoint: WallPlanPoint): [WallNode, WallNode] {
|
|
97
|
+
const { id: _id, parentId: _parentId, children, ...rest } = wall
|
|
98
|
+
|
|
99
|
+
const first = WallSchema.parse({
|
|
100
|
+
...rest,
|
|
101
|
+
start: wall.start,
|
|
102
|
+
end: splitPoint,
|
|
103
|
+
children: [],
|
|
104
|
+
})
|
|
105
|
+
const second = WallSchema.parse({
|
|
106
|
+
...rest,
|
|
107
|
+
start: splitPoint,
|
|
108
|
+
end: wall.end,
|
|
109
|
+
children: [],
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
return [first, second]
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function pointsEqual(a: WallPlanPoint, b: WallPlanPoint, tolerance = 1e-6): boolean {
|
|
116
|
+
return distanceSquared(a, b) <= tolerance * tolerance
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function findWallIntersection(
|
|
120
|
+
point: WallPlanPoint,
|
|
121
|
+
walls: WallNode[],
|
|
122
|
+
ignoreWallIds?: string[],
|
|
123
|
+
): WallSplitIntersection | null {
|
|
124
|
+
const ignore = new Set(ignoreWallIds ?? [])
|
|
125
|
+
let best: WallSplitIntersection | null = null
|
|
126
|
+
let bestDistanceSquared = Number.POSITIVE_INFINITY
|
|
127
|
+
|
|
128
|
+
for (const wall of walls) {
|
|
129
|
+
if (ignore.has(wall.id)) continue
|
|
130
|
+
|
|
131
|
+
const projected = projectPointOntoWall(point, wall)
|
|
132
|
+
if (!projected) continue
|
|
133
|
+
|
|
134
|
+
const candidateDistanceSquared = distanceSquared(point, projected)
|
|
135
|
+
if (
|
|
136
|
+
candidateDistanceSquared > WALL_JOIN_SNAP_RADIUS * WALL_JOIN_SNAP_RADIUS ||
|
|
137
|
+
candidateDistanceSquared >= bestDistanceSquared
|
|
138
|
+
) {
|
|
139
|
+
continue
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
best = { wallId: wall.id, point: projected }
|
|
143
|
+
bestDistanceSquared = candidateDistanceSquared
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return best
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function wallHasAttachments(wall: WallNode, nodes: ReturnType<typeof useScene.getState>['nodes']) {
|
|
150
|
+
if ((wall.children?.length ?? 0) > 0) {
|
|
151
|
+
return true
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return Object.values(nodes).some((node) => {
|
|
155
|
+
if (!node) return false
|
|
156
|
+
if ('parentId' in node && node.parentId === wall.id) return true
|
|
157
|
+
if ('wallId' in node && typeof node.wallId === 'string' && node.wallId === wall.id) return true
|
|
158
|
+
return false
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function wallLength(wall: Pick<WallNode, 'start' | 'end'>) {
|
|
163
|
+
return Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1])
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function getWallAttachmentSpan(node: AnyNode): { min: number; max: number; center: number } | null {
|
|
167
|
+
if (node.type === 'door') {
|
|
168
|
+
const door = node as DoorNode
|
|
169
|
+
return {
|
|
170
|
+
min: door.position[0] - door.width / 2,
|
|
171
|
+
max: door.position[0] + door.width / 2,
|
|
172
|
+
center: door.position[0],
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (node.type === 'window') {
|
|
177
|
+
const win = node as WindowNode
|
|
178
|
+
return {
|
|
179
|
+
min: win.position[0] - win.width / 2,
|
|
180
|
+
max: win.position[0] + win.width / 2,
|
|
181
|
+
center: win.position[0],
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (node.type === 'item') {
|
|
186
|
+
const item = node as ItemNode
|
|
187
|
+
if (item.asset.attachTo !== 'wall' && item.asset.attachTo !== 'wall-side') {
|
|
188
|
+
return null
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const [width] = getScaledDimensions(item)
|
|
192
|
+
return {
|
|
193
|
+
min: item.position[0] - width / 2,
|
|
194
|
+
max: item.position[0] + width / 2,
|
|
195
|
+
center: item.position[0],
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return null
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function remapAttachmentToWall(
|
|
203
|
+
node: AnyNode,
|
|
204
|
+
nextWallId: WallNode['id'],
|
|
205
|
+
nextLocalX: number,
|
|
206
|
+
nextWallLength: number,
|
|
207
|
+
): Partial<AnyNode> | null {
|
|
208
|
+
const clampedX = Math.max(0, Math.min(nextWallLength, nextLocalX))
|
|
209
|
+
|
|
210
|
+
if (node.type === 'door' || node.type === 'window' || node.type === 'item') {
|
|
211
|
+
const currentPosition = 'position' in node ? node.position : null
|
|
212
|
+
if (!currentPosition) return null
|
|
213
|
+
|
|
214
|
+
const nextPosition: typeof currentPosition = [
|
|
215
|
+
clampedX,
|
|
216
|
+
currentPosition[1],
|
|
217
|
+
currentPosition[2],
|
|
218
|
+
] as typeof currentPosition
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
parentId: nextWallId,
|
|
222
|
+
position: nextPosition,
|
|
223
|
+
...(node.type === 'item'
|
|
224
|
+
? {
|
|
225
|
+
wallId: nextWallId,
|
|
226
|
+
wallT: nextWallLength > 1e-6 ? clampedX / nextWallLength : 0,
|
|
227
|
+
}
|
|
228
|
+
: {
|
|
229
|
+
wallId: nextWallId,
|
|
230
|
+
}),
|
|
231
|
+
} as Partial<AnyNode>
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return null
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function buildAttachmentMigrationPlan(
|
|
238
|
+
wall: WallNode,
|
|
239
|
+
splitPoint: WallPlanPoint,
|
|
240
|
+
firstWall: WallNode,
|
|
241
|
+
secondWall: WallNode,
|
|
242
|
+
nodes: ReturnType<typeof useScene.getState>['nodes'],
|
|
243
|
+
): { id: AnyNodeId; data: Partial<AnyNode> }[] | null {
|
|
244
|
+
const splitDistance = Math.hypot(splitPoint[0] - wall.start[0], splitPoint[1] - wall.start[1])
|
|
245
|
+
const firstLength = wallLength(firstWall)
|
|
246
|
+
const secondLength = wallLength(secondWall)
|
|
247
|
+
const tolerance = 1e-4
|
|
248
|
+
const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = []
|
|
249
|
+
|
|
250
|
+
for (const childId of wall.children ?? []) {
|
|
251
|
+
const childNode = nodes[childId as AnyNodeId]
|
|
252
|
+
if (!childNode) continue
|
|
253
|
+
|
|
254
|
+
const span = getWallAttachmentSpan(childNode)
|
|
255
|
+
if (!span) {
|
|
256
|
+
return null
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (span.max <= splitDistance + tolerance) {
|
|
260
|
+
const nextUpdate = remapAttachmentToWall(childNode, firstWall.id, span.center, firstLength)
|
|
261
|
+
if (!nextUpdate) return null
|
|
262
|
+
updates.push({ id: childNode.id as AnyNodeId, data: nextUpdate })
|
|
263
|
+
continue
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (span.min >= splitDistance - tolerance) {
|
|
267
|
+
const nextUpdate = remapAttachmentToWall(
|
|
268
|
+
childNode,
|
|
269
|
+
secondWall.id,
|
|
270
|
+
span.center - splitDistance,
|
|
271
|
+
secondLength,
|
|
272
|
+
)
|
|
273
|
+
if (!nextUpdate) return null
|
|
274
|
+
updates.push({ id: childNode.id as AnyNodeId, data: nextUpdate })
|
|
275
|
+
continue
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return null
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return updates
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function splitWallIfNeeded(
|
|
285
|
+
intersection: WallSplitIntersection | null,
|
|
286
|
+
walls: WallNode[],
|
|
287
|
+
nodes: ReturnType<typeof useScene.getState>['nodes'],
|
|
288
|
+
createNodes: ReturnType<typeof useScene.getState>['createNodes'],
|
|
289
|
+
updateNodes: ReturnType<typeof useScene.getState>['updateNodes'],
|
|
290
|
+
deleteNode: ReturnType<typeof useScene.getState>['deleteNode'],
|
|
291
|
+
): { walls: WallNode[]; point: WallPlanPoint } | null {
|
|
292
|
+
if (!intersection) return null
|
|
293
|
+
|
|
294
|
+
const wallToSplit = walls.find((wall) => wall.id === intersection.wallId)
|
|
295
|
+
if (!wallToSplit) {
|
|
296
|
+
return { walls, point: intersection.point }
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const [first, second] = splitWallAtPoint(wallToSplit, intersection.point)
|
|
300
|
+
const attachmentUpdates = buildAttachmentMigrationPlan(
|
|
301
|
+
wallToSplit,
|
|
302
|
+
intersection.point,
|
|
303
|
+
first,
|
|
304
|
+
second,
|
|
305
|
+
nodes,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
if (wallHasAttachments(wallToSplit, nodes) && !attachmentUpdates) {
|
|
309
|
+
return { walls, point: intersection.point }
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
createNodes([
|
|
313
|
+
{ node: first, parentId: wallToSplit.parentId as AnyNodeId | undefined },
|
|
314
|
+
{ node: second, parentId: wallToSplit.parentId as AnyNodeId | undefined },
|
|
315
|
+
])
|
|
316
|
+
if (attachmentUpdates && attachmentUpdates.length > 0) {
|
|
317
|
+
updateNodes(attachmentUpdates)
|
|
318
|
+
}
|
|
319
|
+
deleteNode(wallToSplit.id as AnyNodeId)
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
walls: [...walls.filter((wall) => wall.id !== wallToSplit.id), first, second],
|
|
323
|
+
point: intersection.point,
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
56
327
|
export function findWallSnapTarget(
|
|
57
328
|
point: WallPlanPoint,
|
|
58
329
|
walls: WallNode[],
|
|
@@ -68,11 +339,17 @@ export function findWallSnapTarget(
|
|
|
68
339
|
continue
|
|
69
340
|
}
|
|
70
341
|
|
|
71
|
-
const candidates: Array<WallPlanPoint | null> = [
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
+
}
|
|
76
353
|
for (const candidate of candidates) {
|
|
77
354
|
if (!candidate) {
|
|
78
355
|
continue
|
|
@@ -102,7 +379,12 @@ export function snapWallDraftPoint(args: {
|
|
|
102
379
|
ignoreWallIds?: string[]
|
|
103
380
|
}): WallPlanPoint {
|
|
104
381
|
const { point, walls, start, angleSnap = false, ignoreWallIds } = args
|
|
105
|
-
const
|
|
382
|
+
const step = getWallGridStep()
|
|
383
|
+
const angleStep = getWallAngleSnapStep(step)
|
|
384
|
+
const basePoint =
|
|
385
|
+
start && angleSnap
|
|
386
|
+
? snapPointTo45Degrees(start, point, step, angleStep)
|
|
387
|
+
: snapPointToGrid(point, step)
|
|
106
388
|
|
|
107
389
|
return (
|
|
108
390
|
findWallSnapTarget(basePoint, walls, {
|
|
@@ -120,17 +402,66 @@ export function createWallOnCurrentLevel(
|
|
|
120
402
|
end: WallPlanPoint,
|
|
121
403
|
): WallNode | null {
|
|
122
404
|
const currentLevelId = useViewer.getState().selection.levelId
|
|
123
|
-
const { createNode, nodes } = useScene.getState()
|
|
405
|
+
const { createNode, createNodes, deleteNode, nodes } = useScene.getState()
|
|
406
|
+
const { updateNodes } = useScene.getState()
|
|
124
407
|
|
|
125
408
|
if (!(currentLevelId && isWallLongEnough(start, end))) {
|
|
126
409
|
return null
|
|
127
410
|
}
|
|
128
411
|
|
|
412
|
+
let workingWalls = Object.values(nodes).filter(
|
|
413
|
+
(node): node is WallNode => node?.type === 'wall' && node.parentId === currentLevelId,
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
let resolvedStart = start
|
|
417
|
+
let resolvedEnd = end
|
|
418
|
+
|
|
419
|
+
const endIntersection = findWallIntersection(resolvedEnd, workingWalls)
|
|
420
|
+
const splitEnd = splitWallIfNeeded(
|
|
421
|
+
endIntersection,
|
|
422
|
+
workingWalls,
|
|
423
|
+
nodes,
|
|
424
|
+
createNodes,
|
|
425
|
+
updateNodes,
|
|
426
|
+
deleteNode,
|
|
427
|
+
)
|
|
428
|
+
if (splitEnd) {
|
|
429
|
+
workingWalls = splitEnd.walls
|
|
430
|
+
resolvedEnd = splitEnd.point
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const startIntersection = findWallIntersection(resolvedStart, workingWalls)
|
|
434
|
+
const splitStart = splitWallIfNeeded(
|
|
435
|
+
startIntersection,
|
|
436
|
+
workingWalls,
|
|
437
|
+
nodes,
|
|
438
|
+
createNodes,
|
|
439
|
+
updateNodes,
|
|
440
|
+
deleteNode,
|
|
441
|
+
)
|
|
442
|
+
if (splitStart) {
|
|
443
|
+
workingWalls = splitStart.walls
|
|
444
|
+
resolvedStart = splitStart.point
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (!isWallLongEnough(resolvedStart, resolvedEnd) || pointsEqual(resolvedStart, resolvedEnd)) {
|
|
448
|
+
return null
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const duplicateWall = workingWalls.some(
|
|
452
|
+
(wall) =>
|
|
453
|
+
(pointsEqual(wall.start, resolvedStart) && pointsEqual(wall.end, resolvedEnd)) ||
|
|
454
|
+
(pointsEqual(wall.start, resolvedEnd) && pointsEqual(wall.end, resolvedStart)),
|
|
455
|
+
)
|
|
456
|
+
if (duplicateWall) {
|
|
457
|
+
return null
|
|
458
|
+
}
|
|
459
|
+
|
|
129
460
|
const wallCount = Object.values(nodes).filter((node) => node.type === 'wall').length
|
|
130
461
|
const wall = WallSchema.parse({
|
|
131
462
|
name: `Wall ${wallCount + 1}`,
|
|
132
|
-
start,
|
|
133
|
-
end,
|
|
463
|
+
start: resolvedStart,
|
|
464
|
+
end: resolvedEnd,
|
|
134
465
|
})
|
|
135
466
|
|
|
136
467
|
createNode(wall, currentLevelId)
|
|
@@ -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
|
+
}
|