@pascal-app/editor 0.4.0 → 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 +341 -48
- package/src/components/editor/floating-building-action-menu.tsx +70 -0
- package/src/components/editor/floorplan-panel.tsx +1350 -722
- package/src/components/editor/index.tsx +221 -167
- package/src/components/editor/node-action-menu.tsx +40 -11
- package/src/components/editor/selection-manager.tsx +238 -10
- package/src/components/editor/site-edge-labels.tsx +9 -3
- package/src/components/editor/thumbnail-generator.tsx +422 -79
- 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/systems/stair/stair-edit-system.tsx +27 -5
- package/src/components/tools/building/move-building-tool.tsx +157 -0
- 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-math.ts +1 -1
- package/src/components/tools/door/door-tool.tsx +31 -7
- package/src/components/tools/door/move-door-tool.tsx +27 -8
- package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
- package/src/components/tools/fence/fence-drafting.ts +137 -0
- package/src/components/tools/fence/fence-tool.tsx +190 -0
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +327 -0
- package/src/components/tools/fence/move-fence-tool.tsx +231 -0
- package/src/components/tools/item/item-tool.tsx +3 -3
- package/src/components/tools/item/move-tool.tsx +16 -0
- package/src/components/tools/item/placement-math.ts +14 -6
- package/src/components/tools/item/placement-strategies.ts +17 -9
- package/src/components/tools/item/use-placement-coordinator.tsx +123 -16
- package/src/components/tools/roof/move-roof-tool.tsx +90 -26
- package/src/components/tools/roof/roof-tool.tsx +6 -6
- package/src/components/tools/select/box-select-tool.tsx +2 -2
- 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/slab/slab-tool.tsx +4 -4
- package/src/components/tools/stair/stair-defaults.ts +10 -0
- package/src/components/tools/stair/stair-tool.tsx +39 -8
- package/src/components/tools/tool-manager.tsx +54 -14
- 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/wall/wall-tool.tsx +19 -29
- package/src/components/tools/window/move-window-tool.tsx +27 -8
- package/src/components/tools/window/window-math.ts +1 -1
- package/src/components/tools/window/window-tool.tsx +31 -7
- package/src/components/tools/zone/zone-tool.tsx +7 -7
- package/src/components/ui/action-menu/control-modes.tsx +9 -4
- package/src/components/ui/action-menu/structure-tools.tsx +1 -0
- 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/helpers/building-helper.tsx +32 -0
- package/src/components/ui/helpers/helper-manager.tsx +2 -0
- 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 +269 -0
- package/src/components/ui/panels/item-panel.tsx +5 -5
- package/src/components/ui/panels/panel-manager.tsx +32 -27
- 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 +377 -50
- 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 +28 -17
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +65 -53
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +40 -25
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +69 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +88 -72
- package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +14 -13
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +64 -53
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +32 -23
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +72 -51
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +40 -37
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +72 -51
- package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +13 -13
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +20 -17
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +62 -54
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +40 -25
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +27 -28
- package/src/components/ui/viewer-toolbar.tsx +55 -2
- package/src/components/viewer-overlay.tsx +26 -19
- package/src/hooks/use-auto-save.ts +3 -6
- package/src/hooks/use-contextual-tools.ts +25 -16
- package/src/hooks/use-grid-events.ts +13 -1
- package/src/hooks/use-keyboard.ts +7 -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 +125 -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)
|
|
@@ -78,31 +78,28 @@ export const WallTool: React.FC = () => {
|
|
|
78
78
|
let gridPosition: WallPlanPoint = [0, 0]
|
|
79
79
|
let previousWallEnd: [number, number] | null = null
|
|
80
80
|
|
|
81
|
+
// All positions are building-local: this tool is inside the ToolManager building group,
|
|
82
|
+
// so local coords are used for both data and visual positioning.
|
|
81
83
|
const onGridMove = (event: GridEvent) => {
|
|
82
84
|
if (!(cursorRef.current && wallPreviewRef.current)) return
|
|
83
85
|
|
|
84
86
|
const walls = getCurrentLevelWalls()
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
walls,
|
|
89
|
-
})
|
|
87
|
+
// event.localPosition is building-local — consistent with stored wall start/end
|
|
88
|
+
const localPoint: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
|
|
89
|
+
gridPosition = snapWallDraftPoint({ point: localPoint, walls })
|
|
90
90
|
|
|
91
91
|
if (buildingState.current === 1) {
|
|
92
|
-
const
|
|
93
|
-
point:
|
|
92
|
+
const snappedLocal = snapWallDraftPoint({
|
|
93
|
+
point: localPoint,
|
|
94
94
|
walls,
|
|
95
95
|
start: [startingPoint.current.x, startingPoint.current.z],
|
|
96
96
|
angleSnap: !shiftPressed.current,
|
|
97
97
|
})
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
// Position the cursor at the end of the wall being drawn
|
|
102
|
-
cursorRef.current.position.set(snapped.x, snapped.y, snapped.z)
|
|
98
|
+
endingPoint.current.set(snappedLocal[0], event.localPosition[1], snappedLocal[1])
|
|
99
|
+
cursorRef.current.position.copy(endingPoint.current)
|
|
103
100
|
|
|
104
101
|
// Play snap sound only when the actual wall end position changes
|
|
105
|
-
const currentWallEnd: [number, number] = [
|
|
102
|
+
const currentWallEnd: [number, number] = [snappedLocal[0], snappedLocal[1]]
|
|
106
103
|
if (
|
|
107
104
|
previousWallEnd &&
|
|
108
105
|
(currentWallEnd[0] !== previousWallEnd[0] || currentWallEnd[1] !== previousWallEnd[1])
|
|
@@ -111,43 +108,36 @@ export const WallTool: React.FC = () => {
|
|
|
111
108
|
}
|
|
112
109
|
previousWallEnd = currentWallEnd
|
|
113
110
|
|
|
114
|
-
// Update wall preview geometry
|
|
115
111
|
updateWallPreview(wallPreviewRef.current, startingPoint.current, endingPoint.current)
|
|
116
112
|
} else {
|
|
117
113
|
// Not drawing a wall yet, show the snapped anchor point.
|
|
118
|
-
cursorRef.current.position.set(gridPosition[0], event.
|
|
114
|
+
cursorRef.current.position.set(gridPosition[0], event.localPosition[1], gridPosition[1])
|
|
119
115
|
}
|
|
120
116
|
}
|
|
121
117
|
|
|
122
118
|
const onGridClick = (event: GridEvent) => {
|
|
123
119
|
const walls = getCurrentLevelWalls()
|
|
124
|
-
const
|
|
120
|
+
const localClick: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
|
|
125
121
|
|
|
126
122
|
if (buildingState.current === 0) {
|
|
127
|
-
const snappedStart = snapWallDraftPoint({
|
|
128
|
-
point: clickPoint,
|
|
129
|
-
walls,
|
|
130
|
-
})
|
|
123
|
+
const snappedStart = snapWallDraftPoint({ point: localClick, walls })
|
|
131
124
|
gridPosition = snappedStart
|
|
132
|
-
startingPoint.current.set(snappedStart[0], event.
|
|
125
|
+
startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1])
|
|
133
126
|
endingPoint.current.copy(startingPoint.current)
|
|
134
127
|
buildingState.current = 1
|
|
135
128
|
wallPreviewRef.current.visible = true
|
|
136
129
|
} else if (buildingState.current === 1) {
|
|
137
130
|
const snappedEnd = snapWallDraftPoint({
|
|
138
|
-
point:
|
|
131
|
+
point: localClick,
|
|
139
132
|
walls,
|
|
140
133
|
start: [startingPoint.current.x, startingPoint.current.z],
|
|
141
134
|
angleSnap: !shiftPressed.current,
|
|
142
135
|
})
|
|
143
|
-
|
|
144
|
-
const
|
|
145
|
-
const dz = endingPoint.current.z - startingPoint.current.z
|
|
136
|
+
const dx = snappedEnd[0] - startingPoint.current.x
|
|
137
|
+
const dz = snappedEnd[1] - startingPoint.current.z
|
|
146
138
|
if (dx * dx + dz * dz < 0.01 * 0.01) return
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
[endingPoint.current.x, endingPoint.current.z],
|
|
150
|
-
)
|
|
139
|
+
// Both start and end are building-local ✓
|
|
140
|
+
createWallOnCurrentLevel([startingPoint.current.x, startingPoint.current.z], snappedEnd)
|
|
151
141
|
wallPreviewRef.current.visible = false
|
|
152
142
|
buildingState.current = 0
|
|
153
143
|
}
|
|
@@ -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
|
|
|
@@ -185,17 +194,26 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
|
|
|
185
194
|
movingWindowNode.height,
|
|
186
195
|
)
|
|
187
196
|
|
|
188
|
-
useScene.getState().updateNode(movingWindowNode.id, {
|
|
189
|
-
position: [clampedX, clampedY, 0],
|
|
190
|
-
rotation: [0, itemRotation, 0],
|
|
191
|
-
side,
|
|
192
|
-
parentId: event.node.id,
|
|
193
|
-
wallId: event.node.id,
|
|
194
|
-
})
|
|
195
|
-
|
|
196
197
|
if (currentWallId !== event.node.id) {
|
|
198
|
+
// Wall changed mid-move: must updateNode to reparent
|
|
199
|
+
useScene.getState().updateNode(movingWindowNode.id, {
|
|
200
|
+
position: [clampedX, clampedY, 0],
|
|
201
|
+
rotation: [0, itemRotation, 0],
|
|
202
|
+
side,
|
|
203
|
+
parentId: event.node.id,
|
|
204
|
+
wallId: event.node.id,
|
|
205
|
+
})
|
|
197
206
|
markWallDirty(currentWallId)
|
|
198
207
|
currentWallId = event.node.id
|
|
208
|
+
} else {
|
|
209
|
+
// Same wall: update Three.js mesh directly to avoid store churn
|
|
210
|
+
// collectCutoutBrushes reads cutoutMesh.matrixWorld, not scene store positions
|
|
211
|
+
const windowMesh = sceneRegistry.nodes.get(movingWindowNode.id as AnyNodeId)
|
|
212
|
+
if (windowMesh) {
|
|
213
|
+
windowMesh.position.set(clampedX, clampedY, 0)
|
|
214
|
+
windowMesh.rotation.set(0, itemRotation, 0)
|
|
215
|
+
windowMesh.updateMatrixWorld(true)
|
|
216
|
+
}
|
|
199
217
|
}
|
|
200
218
|
markWallDirty(event.node.id)
|
|
201
219
|
|
|
@@ -224,6 +242,7 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
|
|
|
224
242
|
|
|
225
243
|
const onWallClick = (event: WallEvent) => {
|
|
226
244
|
if (!isValidWallSideFace(event.normal)) return
|
|
245
|
+
if (isCurvedWall(event.node)) return
|
|
227
246
|
// Only interact with walls on the current level
|
|
228
247
|
if (event.node.parentId !== getLevelId()) return
|
|
229
248
|
|
|
@@ -77,7 +77,7 @@ export function hasWallChildOverlap(
|
|
|
77
77
|
const newLeft = clampedX - halfW
|
|
78
78
|
const newRight = clampedX + halfW
|
|
79
79
|
|
|
80
|
-
for (const childId of wallNode.children) {
|
|
80
|
+
for (const childId of Array.isArray(wallNode.children) ? wallNode.children : []) {
|
|
81
81
|
if (childId === ignoreId) continue
|
|
82
82
|
const child = nodes[childId as AnyNodeId]
|
|
83
83
|
if (!child) continue
|
|
@@ -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
|
|
|
@@ -151,13 +162,25 @@ export const WindowTool: React.FC = () => {
|
|
|
151
162
|
const { clampedX, clampedY } = clampToWall(event.node, localX, localY, width, height)
|
|
152
163
|
|
|
153
164
|
if (draftRef.current) {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
165
|
+
if (event.node.id !== draftRef.current.parentId) {
|
|
166
|
+
// Wall changed without enter/leave: must updateNode to reparent
|
|
167
|
+
useScene.getState().updateNode(draftRef.current.id, {
|
|
168
|
+
position: [clampedX, clampedY, 0],
|
|
169
|
+
rotation: [0, itemRotation, 0],
|
|
170
|
+
side,
|
|
171
|
+
parentId: event.node.id,
|
|
172
|
+
wallId: event.node.id,
|
|
173
|
+
})
|
|
174
|
+
} else {
|
|
175
|
+
// Same wall: update Three.js mesh directly to avoid store churn
|
|
176
|
+
const draftMesh = sceneRegistry.nodes.get(draftRef.current.id as AnyNodeId)
|
|
177
|
+
if (draftMesh) {
|
|
178
|
+
draftMesh.position.set(clampedX, clampedY, 0)
|
|
179
|
+
draftMesh.rotation.set(0, itemRotation, 0)
|
|
180
|
+
draftMesh.updateMatrixWorld(true)
|
|
181
|
+
}
|
|
182
|
+
markWallDirty(event.node.id)
|
|
183
|
+
}
|
|
161
184
|
}
|
|
162
185
|
|
|
163
186
|
const valid = !hasWallChildOverlap(
|
|
@@ -186,6 +209,7 @@ export const WindowTool: React.FC = () => {
|
|
|
186
209
|
const onWallClick = (event: WallEvent) => {
|
|
187
210
|
if (!draftRef.current) return
|
|
188
211
|
if (!isValidWallSideFace(event.normal)) return
|
|
212
|
+
if (isCurvedWall(event.node)) return
|
|
189
213
|
// Only interact with walls on the current level
|
|
190
214
|
if (event.node.parentId !== getLevelId()) return
|
|
191
215
|
|
|
@@ -174,18 +174,18 @@ export const ZoneTool: React.FC = () => {
|
|
|
174
174
|
if (!cursorRef.current) return
|
|
175
175
|
|
|
176
176
|
// Snap to 0.5 grid
|
|
177
|
-
const gridX = Math.round(event.
|
|
178
|
-
const gridZ = Math.round(event.
|
|
177
|
+
const gridX = Math.round(event.localPosition[0] * 2) / 2
|
|
178
|
+
const gridZ = Math.round(event.localPosition[2] * 2) / 2
|
|
179
179
|
cursorPosition = [gridX, gridZ]
|
|
180
|
-
levelYRef.current = event.
|
|
180
|
+
levelYRef.current = event.localPosition[1]
|
|
181
181
|
|
|
182
182
|
// If we have points, snap to axis from last point
|
|
183
183
|
const lastPoint = pointsRef.current[pointsRef.current.length - 1]
|
|
184
184
|
if (lastPoint) {
|
|
185
185
|
const snapped = calculateSnapPoint(lastPoint, cursorPosition)
|
|
186
|
-
cursorRef.current.position.set(snapped[0], event.
|
|
186
|
+
cursorRef.current.position.set(snapped[0], event.localPosition[1], snapped[1])
|
|
187
187
|
} else {
|
|
188
|
-
cursorRef.current.position.set(gridX, event.
|
|
188
|
+
cursorRef.current.position.set(gridX, event.localPosition[1], gridZ)
|
|
189
189
|
}
|
|
190
190
|
|
|
191
191
|
updatePreview()
|
|
@@ -194,8 +194,8 @@ export const ZoneTool: React.FC = () => {
|
|
|
194
194
|
const onGridClick = (event: GridEvent) => {
|
|
195
195
|
if (!currentLevelId) return
|
|
196
196
|
|
|
197
|
-
const gridX = Math.round(event.
|
|
198
|
-
const gridZ = Math.round(event.
|
|
197
|
+
const gridX = Math.round(event.localPosition[0] * 2) / 2
|
|
198
|
+
const gridZ = Math.round(event.localPosition[2] * 2) / 2
|
|
199
199
|
let clickPoint: [number, number] = [gridX, gridZ]
|
|
200
200
|
|
|
201
201
|
// Snap to axis from last point
|