@pascal-app/editor 0.5.1 → 0.6.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 +8 -7
- package/src/components/editor/editor-layout-v2.tsx +9 -0
- package/src/components/editor/floating-action-menu.tsx +255 -34
- package/src/components/editor/floating-building-action-menu.tsx +4 -3
- package/src/components/editor/floorplan-panel.tsx +1323 -713
- package/src/components/editor/index.tsx +2 -0
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/selection-manager.tsx +200 -8
- package/src/components/editor/site-edge-labels.tsx +9 -3
- package/src/components/editor/thumbnail-generator.tsx +319 -157
- package/src/components/editor/wall-measurement-label.tsx +120 -32
- 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-hole-editor.tsx +1 -0
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
- package/src/components/tools/door/door-tool.tsx +12 -0
- package/src/components/tools/door/move-door-tool.tsx +10 -0
- package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
- package/src/components/tools/fence/fence-drafting.ts +19 -7
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +327 -0
- package/src/components/tools/fence/move-fence-tool.tsx +8 -0
- package/src/components/tools/item/move-tool.tsx +9 -0
- package/src/components/tools/item/placement-math.ts +14 -6
- package/src/components/tools/item/placement-strategies.ts +2 -2
- package/src/components/tools/item/use-placement-coordinator.tsx +42 -10
- package/src/components/tools/roof/move-roof-tool.tsx +89 -28
- package/src/components/tools/shared/polygon-editor.tsx +98 -8
- package/src/components/tools/slab/move-slab-tool.tsx +182 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
- package/src/components/tools/stair/stair-tool.tsx +11 -3
- package/src/components/tools/tool-manager.tsx +12 -0
- package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +322 -0
- package/src/components/tools/wall/move-wall-tool.tsx +356 -0
- package/src/components/tools/wall/wall-drafting.ts +331 -9
- package/src/components/tools/window/move-window-tool.tsx +10 -0
- package/src/components/tools/window/window-tool.tsx +12 -0
- package/src/components/ui/action-menu/control-modes.tsx +9 -4
- package/src/components/ui/command-palette/editor-commands.tsx +9 -4
- package/src/components/ui/command-palette/index.tsx +0 -1
- package/src/components/ui/controls/material-picker.tsx +127 -94
- package/src/components/ui/controls/slider-control.tsx +28 -14
- package/src/components/ui/item-catalog/catalog-items.tsx +5 -0
- package/src/components/ui/panels/ceiling-panel.tsx +61 -17
- package/src/components/ui/panels/door-panel.tsx +5 -5
- package/src/components/ui/panels/fence-panel.tsx +97 -12
- package/src/components/ui/panels/item-panel.tsx +5 -5
- package/src/components/ui/panels/panel-manager.tsx +31 -29
- package/src/components/ui/panels/reference-panel.tsx +5 -4
- package/src/components/ui/panels/roof-panel.tsx +91 -22
- package/src/components/ui/panels/roof-segment-panel.tsx +23 -13
- package/src/components/ui/panels/slab-panel.tsx +63 -15
- package/src/components/ui/panels/stair-panel.tsx +173 -19
- package/src/components/ui/panels/stair-segment-panel.tsx +28 -17
- package/src/components/ui/panels/wall-panel.tsx +159 -11
- package/src/components/ui/panels/window-panel.tsx +5 -7
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +7 -3
- 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 +29 -32
- 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 +7 -3
- 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/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 +3 -3
- 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 +7 -3
- package/src/components/ui/viewer-toolbar.tsx +55 -2
- package/src/components/viewer-overlay.tsx +25 -19
- package/src/hooks/use-contextual-tools.ts +14 -13
- package/src/hooks/use-keyboard.ts +3 -2
- package/src/index.tsx +2 -1
- package/src/lib/history.ts +20 -0
- package/src/lib/sfx-player.ts +96 -13
- package/src/store/use-editor.tsx +118 -10
|
@@ -1,12 +1,36 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type AnyNode,
|
|
3
|
+
type AnyNodeId,
|
|
4
|
+
type DoorNode,
|
|
5
|
+
getScaledDimensions,
|
|
6
|
+
type ItemNode,
|
|
7
|
+
useScene,
|
|
8
|
+
type WallNode,
|
|
9
|
+
WallNode as WallSchema,
|
|
10
|
+
type WindowNode,
|
|
11
|
+
} from '@pascal-app/core'
|
|
2
12
|
import { useViewer } from '@pascal-app/viewer'
|
|
3
13
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
14
|
+
import useEditor from '../../../store/use-editor'
|
|
4
15
|
|
|
5
16
|
export type WallPlanPoint = [number, number]
|
|
6
17
|
|
|
7
18
|
export const WALL_GRID_STEP = 0.5
|
|
8
19
|
export const WALL_JOIN_SNAP_RADIUS = 0.35
|
|
9
20
|
export const WALL_MIN_LENGTH = 0.01
|
|
21
|
+
const DEFAULT_WALL_ANGLE_SNAP_STEP = Math.PI / 4
|
|
22
|
+
|
|
23
|
+
const WALL_ANGLE_SNAP_BY_GRID_STEP: Record<number, number> = {
|
|
24
|
+
0.5: Math.PI / 4,
|
|
25
|
+
0.25: Math.PI / 8,
|
|
26
|
+
0.1: Math.PI / 12,
|
|
27
|
+
0.05: Math.PI / 36,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type WallSplitIntersection = {
|
|
31
|
+
wallId: WallNode['id']
|
|
32
|
+
point: WallPlanPoint
|
|
33
|
+
}
|
|
10
34
|
|
|
11
35
|
function distanceSquared(a: WallPlanPoint, b: WallPlanPoint): number {
|
|
12
36
|
const dx = a[0] - b[0]
|
|
@@ -14,7 +38,11 @@ function distanceSquared(a: WallPlanPoint, b: WallPlanPoint): number {
|
|
|
14
38
|
return dx * dx + dz * dz
|
|
15
39
|
}
|
|
16
40
|
|
|
17
|
-
function
|
|
41
|
+
export function getWallGridStep(): number {
|
|
42
|
+
return useEditor.getState().gridSnapStep
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function snapScalarToGrid(value: number, step = WALL_GRID_STEP): number {
|
|
18
46
|
return Math.round(value / step) * step
|
|
19
47
|
}
|
|
20
48
|
|
|
@@ -22,17 +50,26 @@ export function snapPointToGrid(point: WallPlanPoint, step = WALL_GRID_STEP): Wa
|
|
|
22
50
|
return [snapScalarToGrid(point[0], step), snapScalarToGrid(point[1], step)]
|
|
23
51
|
}
|
|
24
52
|
|
|
25
|
-
export function snapPointTo45Degrees(
|
|
53
|
+
export function snapPointTo45Degrees(
|
|
54
|
+
start: WallPlanPoint,
|
|
55
|
+
cursor: WallPlanPoint,
|
|
56
|
+
step = WALL_GRID_STEP,
|
|
57
|
+
angleStep = DEFAULT_WALL_ANGLE_SNAP_STEP,
|
|
58
|
+
): WallPlanPoint {
|
|
26
59
|
const dx = cursor[0] - start[0]
|
|
27
60
|
const dz = cursor[1] - start[1]
|
|
28
61
|
const angle = Math.atan2(dz, dx)
|
|
29
|
-
const snappedAngle = Math.round(angle /
|
|
62
|
+
const snappedAngle = Math.round(angle / angleStep) * angleStep
|
|
30
63
|
const distance = Math.sqrt(dx * dx + dz * dz)
|
|
31
64
|
|
|
32
65
|
return snapPointToGrid([
|
|
33
66
|
start[0] + Math.cos(snappedAngle) * distance,
|
|
34
67
|
start[1] + Math.sin(snappedAngle) * distance,
|
|
35
|
-
])
|
|
68
|
+
], step)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function getWallAngleSnapStep(step = getWallGridStep()): number {
|
|
72
|
+
return WALL_ANGLE_SNAP_BY_GRID_STEP[step] ?? DEFAULT_WALL_ANGLE_SNAP_STEP
|
|
36
73
|
}
|
|
37
74
|
|
|
38
75
|
function projectPointOntoWall(point: WallPlanPoint, wall: WallNode): WallPlanPoint | null {
|
|
@@ -53,6 +90,237 @@ function projectPointOntoWall(point: WallPlanPoint, wall: WallNode): WallPlanPoi
|
|
|
53
90
|
return [x1 + dx * t, z1 + dz * t]
|
|
54
91
|
}
|
|
55
92
|
|
|
93
|
+
function splitWallAtPoint(wall: WallNode, splitPoint: WallPlanPoint): [WallNode, WallNode] {
|
|
94
|
+
const { id: _id, parentId: _parentId, children, ...rest } = wall
|
|
95
|
+
|
|
96
|
+
const first = WallSchema.parse({
|
|
97
|
+
...rest,
|
|
98
|
+
start: wall.start,
|
|
99
|
+
end: splitPoint,
|
|
100
|
+
children: [],
|
|
101
|
+
})
|
|
102
|
+
const second = WallSchema.parse({
|
|
103
|
+
...rest,
|
|
104
|
+
start: splitPoint,
|
|
105
|
+
end: wall.end,
|
|
106
|
+
children: [],
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
return [first, second]
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function pointsEqual(a: WallPlanPoint, b: WallPlanPoint, tolerance = 1e-6): boolean {
|
|
113
|
+
return distanceSquared(a, b) <= tolerance * tolerance
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function findWallIntersection(
|
|
117
|
+
point: WallPlanPoint,
|
|
118
|
+
walls: WallNode[],
|
|
119
|
+
ignoreWallIds?: string[],
|
|
120
|
+
): WallSplitIntersection | null {
|
|
121
|
+
const ignore = new Set(ignoreWallIds ?? [])
|
|
122
|
+
let best: WallSplitIntersection | null = null
|
|
123
|
+
let bestDistanceSquared = Number.POSITIVE_INFINITY
|
|
124
|
+
|
|
125
|
+
for (const wall of walls) {
|
|
126
|
+
if (ignore.has(wall.id)) continue
|
|
127
|
+
|
|
128
|
+
const projected = projectPointOntoWall(point, wall)
|
|
129
|
+
if (!projected) continue
|
|
130
|
+
|
|
131
|
+
const candidateDistanceSquared = distanceSquared(point, projected)
|
|
132
|
+
if (
|
|
133
|
+
candidateDistanceSquared > WALL_JOIN_SNAP_RADIUS * WALL_JOIN_SNAP_RADIUS ||
|
|
134
|
+
candidateDistanceSquared >= bestDistanceSquared
|
|
135
|
+
) {
|
|
136
|
+
continue
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
best = { wallId: wall.id, point: projected }
|
|
140
|
+
bestDistanceSquared = candidateDistanceSquared
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return best
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function wallHasAttachments(wall: WallNode, nodes: ReturnType<typeof useScene.getState>['nodes']) {
|
|
147
|
+
if ((wall.children?.length ?? 0) > 0) {
|
|
148
|
+
return true
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return Object.values(nodes).some((node) => {
|
|
152
|
+
if (!node) return false
|
|
153
|
+
if ('parentId' in node && node.parentId === wall.id) return true
|
|
154
|
+
if ('wallId' in node && typeof node.wallId === 'string' && node.wallId === wall.id) return true
|
|
155
|
+
return false
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function wallLength(wall: Pick<WallNode, 'start' | 'end'>) {
|
|
160
|
+
return Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1])
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function getWallAttachmentSpan(node: AnyNode): { min: number; max: number; center: number } | null {
|
|
164
|
+
if (node.type === 'door') {
|
|
165
|
+
const door = node as DoorNode
|
|
166
|
+
return {
|
|
167
|
+
min: door.position[0] - door.width / 2,
|
|
168
|
+
max: door.position[0] + door.width / 2,
|
|
169
|
+
center: door.position[0],
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (node.type === 'window') {
|
|
174
|
+
const win = node as WindowNode
|
|
175
|
+
return {
|
|
176
|
+
min: win.position[0] - win.width / 2,
|
|
177
|
+
max: win.position[0] + win.width / 2,
|
|
178
|
+
center: win.position[0],
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (node.type === 'item') {
|
|
183
|
+
const item = node as ItemNode
|
|
184
|
+
if (item.asset.attachTo !== 'wall' && item.asset.attachTo !== 'wall-side') {
|
|
185
|
+
return null
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const [width] = getScaledDimensions(item)
|
|
189
|
+
return {
|
|
190
|
+
min: item.position[0] - width / 2,
|
|
191
|
+
max: item.position[0] + width / 2,
|
|
192
|
+
center: item.position[0],
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return null
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function remapAttachmentToWall(
|
|
200
|
+
node: AnyNode,
|
|
201
|
+
nextWallId: WallNode['id'],
|
|
202
|
+
nextLocalX: number,
|
|
203
|
+
nextWallLength: number,
|
|
204
|
+
): Partial<AnyNode> | null {
|
|
205
|
+
const clampedX = Math.max(0, Math.min(nextWallLength, nextLocalX))
|
|
206
|
+
|
|
207
|
+
if (node.type === 'door' || node.type === 'window' || node.type === 'item') {
|
|
208
|
+
const currentPosition = 'position' in node ? node.position : null
|
|
209
|
+
if (!currentPosition) return null
|
|
210
|
+
|
|
211
|
+
const nextPosition: typeof currentPosition = [
|
|
212
|
+
clampedX,
|
|
213
|
+
currentPosition[1],
|
|
214
|
+
currentPosition[2],
|
|
215
|
+
] as typeof currentPosition
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
parentId: nextWallId,
|
|
219
|
+
position: nextPosition,
|
|
220
|
+
...(node.type === 'item'
|
|
221
|
+
? {
|
|
222
|
+
wallId: nextWallId,
|
|
223
|
+
wallT: nextWallLength > 1e-6 ? clampedX / nextWallLength : 0,
|
|
224
|
+
}
|
|
225
|
+
: {
|
|
226
|
+
wallId: nextWallId,
|
|
227
|
+
}),
|
|
228
|
+
} as Partial<AnyNode>
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return null
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function buildAttachmentMigrationPlan(
|
|
235
|
+
wall: WallNode,
|
|
236
|
+
splitPoint: WallPlanPoint,
|
|
237
|
+
firstWall: WallNode,
|
|
238
|
+
secondWall: WallNode,
|
|
239
|
+
nodes: ReturnType<typeof useScene.getState>['nodes'],
|
|
240
|
+
): { id: AnyNodeId; data: Partial<AnyNode> }[] | null {
|
|
241
|
+
const splitDistance = Math.hypot(splitPoint[0] - wall.start[0], splitPoint[1] - wall.start[1])
|
|
242
|
+
const firstLength = wallLength(firstWall)
|
|
243
|
+
const secondLength = wallLength(secondWall)
|
|
244
|
+
const tolerance = 1e-4
|
|
245
|
+
const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = []
|
|
246
|
+
|
|
247
|
+
for (const childId of wall.children ?? []) {
|
|
248
|
+
const childNode = nodes[childId as AnyNodeId]
|
|
249
|
+
if (!childNode) continue
|
|
250
|
+
|
|
251
|
+
const span = getWallAttachmentSpan(childNode)
|
|
252
|
+
if (!span) {
|
|
253
|
+
return null
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (span.max <= splitDistance + tolerance) {
|
|
257
|
+
const nextUpdate = remapAttachmentToWall(childNode, firstWall.id, span.center, firstLength)
|
|
258
|
+
if (!nextUpdate) return null
|
|
259
|
+
updates.push({ id: childNode.id as AnyNodeId, data: nextUpdate })
|
|
260
|
+
continue
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (span.min >= splitDistance - tolerance) {
|
|
264
|
+
const nextUpdate = remapAttachmentToWall(
|
|
265
|
+
childNode,
|
|
266
|
+
secondWall.id,
|
|
267
|
+
span.center - splitDistance,
|
|
268
|
+
secondLength,
|
|
269
|
+
)
|
|
270
|
+
if (!nextUpdate) return null
|
|
271
|
+
updates.push({ id: childNode.id as AnyNodeId, data: nextUpdate })
|
|
272
|
+
continue
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return null
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return updates
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function splitWallIfNeeded(
|
|
282
|
+
intersection: WallSplitIntersection | null,
|
|
283
|
+
walls: WallNode[],
|
|
284
|
+
nodes: ReturnType<typeof useScene.getState>['nodes'],
|
|
285
|
+
createNodes: ReturnType<typeof useScene.getState>['createNodes'],
|
|
286
|
+
updateNodes: ReturnType<typeof useScene.getState>['updateNodes'],
|
|
287
|
+
deleteNode: ReturnType<typeof useScene.getState>['deleteNode'],
|
|
288
|
+
): { walls: WallNode[]; point: WallPlanPoint } | null {
|
|
289
|
+
if (!intersection) return null
|
|
290
|
+
|
|
291
|
+
const wallToSplit = walls.find((wall) => wall.id === intersection.wallId)
|
|
292
|
+
if (!wallToSplit) {
|
|
293
|
+
return { walls, point: intersection.point }
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const [first, second] = splitWallAtPoint(wallToSplit, intersection.point)
|
|
297
|
+
const attachmentUpdates = buildAttachmentMigrationPlan(
|
|
298
|
+
wallToSplit,
|
|
299
|
+
intersection.point,
|
|
300
|
+
first,
|
|
301
|
+
second,
|
|
302
|
+
nodes,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
if (wallHasAttachments(wallToSplit, nodes) && !attachmentUpdates) {
|
|
306
|
+
return { walls, point: intersection.point }
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
createNodes([
|
|
310
|
+
{ node: first, parentId: wallToSplit.parentId as AnyNodeId | undefined },
|
|
311
|
+
{ node: second, parentId: wallToSplit.parentId as AnyNodeId | undefined },
|
|
312
|
+
])
|
|
313
|
+
if (attachmentUpdates && attachmentUpdates.length > 0) {
|
|
314
|
+
updateNodes(attachmentUpdates)
|
|
315
|
+
}
|
|
316
|
+
deleteNode(wallToSplit.id as AnyNodeId)
|
|
317
|
+
|
|
318
|
+
return {
|
|
319
|
+
walls: [...walls.filter((wall) => wall.id !== wallToSplit.id), first, second],
|
|
320
|
+
point: intersection.point,
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
56
324
|
export function findWallSnapTarget(
|
|
57
325
|
point: WallPlanPoint,
|
|
58
326
|
walls: WallNode[],
|
|
@@ -102,7 +370,12 @@ export function snapWallDraftPoint(args: {
|
|
|
102
370
|
ignoreWallIds?: string[]
|
|
103
371
|
}): WallPlanPoint {
|
|
104
372
|
const { point, walls, start, angleSnap = false, ignoreWallIds } = args
|
|
105
|
-
const
|
|
373
|
+
const step = getWallGridStep()
|
|
374
|
+
const angleStep = getWallAngleSnapStep(step)
|
|
375
|
+
const basePoint =
|
|
376
|
+
start && angleSnap
|
|
377
|
+
? snapPointTo45Degrees(start, point, step, angleStep)
|
|
378
|
+
: snapPointToGrid(point, step)
|
|
106
379
|
|
|
107
380
|
return (
|
|
108
381
|
findWallSnapTarget(basePoint, walls, {
|
|
@@ -120,17 +393,66 @@ export function createWallOnCurrentLevel(
|
|
|
120
393
|
end: WallPlanPoint,
|
|
121
394
|
): WallNode | null {
|
|
122
395
|
const currentLevelId = useViewer.getState().selection.levelId
|
|
123
|
-
const { createNode, nodes } = useScene.getState()
|
|
396
|
+
const { createNode, createNodes, deleteNode, nodes } = useScene.getState()
|
|
397
|
+
const { updateNodes } = useScene.getState()
|
|
124
398
|
|
|
125
399
|
if (!(currentLevelId && isWallLongEnough(start, end))) {
|
|
126
400
|
return null
|
|
127
401
|
}
|
|
128
402
|
|
|
403
|
+
let workingWalls = Object.values(nodes).filter(
|
|
404
|
+
(node): node is WallNode => node?.type === 'wall' && node.parentId === currentLevelId,
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
let resolvedStart = start
|
|
408
|
+
let resolvedEnd = end
|
|
409
|
+
|
|
410
|
+
const endIntersection = findWallIntersection(resolvedEnd, workingWalls)
|
|
411
|
+
const splitEnd = splitWallIfNeeded(
|
|
412
|
+
endIntersection,
|
|
413
|
+
workingWalls,
|
|
414
|
+
nodes,
|
|
415
|
+
createNodes,
|
|
416
|
+
updateNodes,
|
|
417
|
+
deleteNode,
|
|
418
|
+
)
|
|
419
|
+
if (splitEnd) {
|
|
420
|
+
workingWalls = splitEnd.walls
|
|
421
|
+
resolvedEnd = splitEnd.point
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const startIntersection = findWallIntersection(resolvedStart, workingWalls)
|
|
425
|
+
const splitStart = splitWallIfNeeded(
|
|
426
|
+
startIntersection,
|
|
427
|
+
workingWalls,
|
|
428
|
+
nodes,
|
|
429
|
+
createNodes,
|
|
430
|
+
updateNodes,
|
|
431
|
+
deleteNode,
|
|
432
|
+
)
|
|
433
|
+
if (splitStart) {
|
|
434
|
+
workingWalls = splitStart.walls
|
|
435
|
+
resolvedStart = splitStart.point
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (!isWallLongEnough(resolvedStart, resolvedEnd) || pointsEqual(resolvedStart, resolvedEnd)) {
|
|
439
|
+
return null
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const duplicateWall = workingWalls.some(
|
|
443
|
+
(wall) =>
|
|
444
|
+
(pointsEqual(wall.start, resolvedStart) && pointsEqual(wall.end, resolvedEnd)) ||
|
|
445
|
+
(pointsEqual(wall.start, resolvedEnd) && pointsEqual(wall.end, resolvedStart)),
|
|
446
|
+
)
|
|
447
|
+
if (duplicateWall) {
|
|
448
|
+
return null
|
|
449
|
+
}
|
|
450
|
+
|
|
129
451
|
const wallCount = Object.values(nodes).filter((node) => node.type === 'wall').length
|
|
130
452
|
const wall = WallSchema.parse({
|
|
131
453
|
name: `Wall ${wallCount + 1}`,
|
|
132
|
-
start,
|
|
133
|
-
end,
|
|
454
|
+
start: resolvedStart,
|
|
455
|
+
end: resolvedEnd,
|
|
134
456
|
})
|
|
135
457
|
|
|
136
458
|
createNode(wall, currentLevelId)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type AnyNodeId,
|
|
3
3
|
emitter,
|
|
4
|
+
isCurvedWall,
|
|
4
5
|
sceneRegistry,
|
|
5
6
|
spatialGridManager,
|
|
6
7
|
useScene,
|
|
@@ -112,6 +113,10 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
|
|
|
112
113
|
|
|
113
114
|
const onWallEnter = (event: WallEvent) => {
|
|
114
115
|
if (!isValidWallSideFace(event.normal)) return
|
|
116
|
+
if (isCurvedWall(event.node)) {
|
|
117
|
+
hideCursor()
|
|
118
|
+
return
|
|
119
|
+
}
|
|
115
120
|
// Only interact with walls on the current level
|
|
116
121
|
if (event.node.parentId !== getLevelId()) return
|
|
117
122
|
|
|
@@ -168,6 +173,10 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
|
|
|
168
173
|
|
|
169
174
|
const onWallMove = (event: WallEvent) => {
|
|
170
175
|
if (!isValidWallSideFace(event.normal)) return
|
|
176
|
+
if (isCurvedWall(event.node)) {
|
|
177
|
+
hideCursor()
|
|
178
|
+
return
|
|
179
|
+
}
|
|
171
180
|
// Only interact with walls on the current level
|
|
172
181
|
if (event.node.parentId !== getLevelId()) return
|
|
173
182
|
|
|
@@ -233,6 +242,7 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
|
|
|
233
242
|
|
|
234
243
|
const onWallClick = (event: WallEvent) => {
|
|
235
244
|
if (!isValidWallSideFace(event.normal)) return
|
|
245
|
+
if (isCurvedWall(event.node)) return
|
|
236
246
|
// Only interact with walls on the current level
|
|
237
247
|
if (event.node.parentId !== getLevelId()) return
|
|
238
248
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type AnyNodeId,
|
|
3
3
|
emitter,
|
|
4
|
+
isCurvedWall,
|
|
4
5
|
sceneRegistry,
|
|
5
6
|
spatialGridManager,
|
|
6
7
|
useScene,
|
|
@@ -86,6 +87,11 @@ export const WindowTool: React.FC = () => {
|
|
|
86
87
|
|
|
87
88
|
const onWallEnter = (event: WallEvent) => {
|
|
88
89
|
if (!isValidWallSideFace(event.normal)) return
|
|
90
|
+
if (isCurvedWall(event.node)) {
|
|
91
|
+
destroyDraft()
|
|
92
|
+
hideCursor()
|
|
93
|
+
return
|
|
94
|
+
}
|
|
89
95
|
const levelId = getLevelId()
|
|
90
96
|
if (!levelId) return
|
|
91
97
|
// Only interact with walls on the current level
|
|
@@ -135,6 +141,11 @@ export const WindowTool: React.FC = () => {
|
|
|
135
141
|
|
|
136
142
|
const onWallMove = (event: WallEvent) => {
|
|
137
143
|
if (!isValidWallSideFace(event.normal)) return
|
|
144
|
+
if (isCurvedWall(event.node)) {
|
|
145
|
+
destroyDraft()
|
|
146
|
+
hideCursor()
|
|
147
|
+
return
|
|
148
|
+
}
|
|
138
149
|
// Only interact with walls on the current level
|
|
139
150
|
if (event.node.parentId !== getLevelId()) return
|
|
140
151
|
|
|
@@ -198,6 +209,7 @@ export const WindowTool: React.FC = () => {
|
|
|
198
209
|
const onWallClick = (event: WallEvent) => {
|
|
199
210
|
if (!draftRef.current) return
|
|
200
211
|
if (!isValidWallSideFace(event.normal)) return
|
|
212
|
+
if (isCurvedWall(event.node)) return
|
|
201
213
|
// Only interact with walls on the current level
|
|
202
214
|
if (event.node.parentId !== getLevelId()) return
|
|
203
215
|
|
|
@@ -90,12 +90,17 @@ export function ControlModes() {
|
|
|
90
90
|
const setSelectionTool = useEditor((state) => state.setFloorplanSelectionTool)
|
|
91
91
|
const levelId = useViewer((s) => s.selection.levelId)
|
|
92
92
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
93
|
+
// Only subscribe to the primitive `level` number — when walls are added to
|
|
94
|
+
// this level the object ref changes but this number doesn't, so Object.is
|
|
95
|
+
// dedupes and we avoid a re-render.
|
|
96
|
+
const levelIndex = useScene((state) => {
|
|
97
|
+
if (!levelId) return null
|
|
98
|
+
const node = state.nodes[levelId]
|
|
99
|
+
return node?.type === 'level' ? (node as LevelNode).level : null
|
|
100
|
+
})
|
|
96
101
|
|
|
97
102
|
const isSiteEditing = phase === 'site'
|
|
98
|
-
const isGroundFloor =
|
|
103
|
+
const isGroundFloor = levelIndex === 0
|
|
99
104
|
const canEnterSiteEdit = isGroundFloor || isSiteEditing
|
|
100
105
|
|
|
101
106
|
const structureLayer = useEditor((state) => state.structureLayer)
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
} from 'lucide-react'
|
|
36
36
|
import { useEffect } from 'react'
|
|
37
37
|
import { deleteLevelWithFallbackSelection } from '../../../lib/level-selection'
|
|
38
|
+
import { runRedo, runUndo } from '../../../lib/history'
|
|
38
39
|
import { useCommandRegistry } from '../../../store/use-command-registry'
|
|
39
40
|
import type { StructureTool } from '../../../store/use-editor'
|
|
40
41
|
import useEditor from '../../../store/use-editor'
|
|
@@ -44,8 +45,12 @@ export function EditorCommands() {
|
|
|
44
45
|
const register = useCommandRegistry((s) => s.register)
|
|
45
46
|
const { navigateTo, setInputValue, setOpen } = useCommandPalette()
|
|
46
47
|
|
|
47
|
-
const
|
|
48
|
-
|
|
48
|
+
const setPhase = useEditor((s) => s.setPhase)
|
|
49
|
+
const setMode = useEditor((s) => s.setMode)
|
|
50
|
+
const setTool = useEditor((s) => s.setTool)
|
|
51
|
+
const setStructureLayer = useEditor((s) => s.setStructureLayer)
|
|
52
|
+
const isPreviewMode = useEditor((s) => s.isPreviewMode)
|
|
53
|
+
const setPreviewMode = useEditor((s) => s.setPreviewMode)
|
|
49
54
|
|
|
50
55
|
const exportScene = useViewer((s) => s.exportScene)
|
|
51
56
|
|
|
@@ -309,7 +314,7 @@ export function EditorCommands() {
|
|
|
309
314
|
group: 'History',
|
|
310
315
|
icon: <Undo2 className="h-4 w-4" />,
|
|
311
316
|
keywords: ['undo', 'revert', 'back'],
|
|
312
|
-
execute: () => run(() =>
|
|
317
|
+
execute: () => run(() => runUndo()),
|
|
313
318
|
},
|
|
314
319
|
{
|
|
315
320
|
id: 'editor.history.redo',
|
|
@@ -317,7 +322,7 @@ export function EditorCommands() {
|
|
|
317
322
|
group: 'History',
|
|
318
323
|
icon: <Redo2 className="h-4 w-4" />,
|
|
319
324
|
keywords: ['redo', 'forward', 'repeat'],
|
|
320
|
-
execute: () => run(() =>
|
|
325
|
+
execute: () => run(() => runRedo()),
|
|
321
326
|
},
|
|
322
327
|
|
|
323
328
|
// ── Export & Share ───────────────────────────────────────────────────
|
|
@@ -219,7 +219,6 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm
|
|
|
219
219
|
const views = usePaletteViewRegistry((s) => s.views)
|
|
220
220
|
|
|
221
221
|
const activeLevelId = useViewer((s) => s.selection.levelId)
|
|
222
|
-
const activeLevelNode = useScene((s) => (activeLevelId ? s.nodes[activeLevelId] : null))
|
|
223
222
|
|
|
224
223
|
const wallMode = useViewer((s) => s.wallMode)
|
|
225
224
|
const setWallMode = useViewer((s) => s.setWallMode)
|