@pascal-app/editor 1.0.0-beta.3 → 1.0.0-beta.5
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 +7 -6
- package/src/components/editor/alignment-3d-guide-layer.tsx +12 -3
- package/src/components/editor/editor-layout-v2.tsx +8 -3
- package/src/components/editor/elevation-3d-guide-layer.tsx +3 -2
- package/src/components/editor/first-person-controls.tsx +2 -45
- package/src/components/editor/floating-action-menu.tsx +13 -4
- package/src/components/editor/floorplan-background-selection.test.ts +31 -4
- package/src/components/editor/floorplan-background-selection.ts +27 -7
- package/src/components/editor/floorplan-panel.tsx +5 -45
- package/src/components/editor/group-floating-action-menu.tsx +30 -15
- package/src/components/editor/index.tsx +112 -25
- package/src/components/editor/measurement-pill.tsx +12 -12
- package/src/components/editor/node-action-menu.tsx +29 -1
- package/src/components/editor/opening-guides-3d-layer.tsx +13 -4
- package/src/components/editor/selection-manager.tsx +28 -5
- package/src/components/editor/site-edge-labels.tsx +2 -1
- package/src/components/editor/snapshot-capture-overlay.tsx +11 -2
- package/src/components/editor/thumbnail-generator.tsx +27 -11
- package/src/components/editor/wall-measurement-label.tsx +3 -2
- package/src/components/editor-2d/floorplan-group-action-menu.tsx +23 -16
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +26 -84
- package/src/components/tools/item/placement-strategies.test.ts +167 -0
- package/src/components/tools/item/placement-strategies.ts +57 -22
- package/src/components/tools/item/use-placement-coordinator.tsx +19 -27
- package/src/components/tools/shared/placement-box.tsx +9 -4
- package/src/components/tools/site/terrain-brush-cursor.tsx +2 -1
- package/src/components/tools/wall/wall-drafting.test.ts +207 -2
- package/src/components/tools/wall/wall-drafting.ts +84 -400
- package/src/components/ui/command-palette/editor-commands.tsx +3 -1
- package/src/components/ui/floating-level-selector.tsx +1 -1
- package/src/components/ui/panels/multi-selection-panel.tsx +54 -13
- package/src/components/ui/panels/panel-wrapper.tsx +228 -12
- package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +7 -2
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +11 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +12 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +8 -1
- package/src/components/ui/sidebar/tab-bar.tsx +7 -1
- package/src/components/viewer/floorplan-compass-button.tsx +50 -0
- package/src/components/viewer/floorplan-preview-geometry.test.ts +107 -0
- package/src/components/viewer/floorplan-preview-geometry.ts +281 -0
- package/src/components/viewer/floorplan-preview-navigation.test.ts +43 -0
- package/src/components/viewer/floorplan-preview-navigation.ts +62 -0
- package/src/components/viewer/floorplan-preview.tsx +1069 -0
- package/src/components/viewer/use-viewer-camera-navigation-sync.ts +140 -0
- package/src/components/viewer/viewer-stage-modes.test.ts +38 -0
- package/src/components/viewer/viewer-stage-modes.ts +49 -0
- package/src/components/viewer/viewer-stage-switcher.tsx +95 -0
- package/src/components/viewer/viewer-stage.test.tsx +47 -0
- package/src/components/viewer/viewer-stage.tsx +218 -0
- package/src/components/viewer-overlay.tsx +12 -8
- package/src/hooks/use-auto-save.test.ts +50 -1
- package/src/hooks/use-auto-save.ts +44 -9
- package/src/hooks/use-keyboard.ts +35 -1
- package/src/index.tsx +27 -0
- package/src/lib/contextual-help.test.ts +8 -0
- package/src/lib/contextual-help.ts +8 -0
- package/src/lib/floorplan/floorplan-readonly.test.ts +47 -0
- package/src/lib/floorplan/floorplan-readonly.ts +84 -0
- package/src/lib/history.test.ts +75 -10
- package/src/lib/history.ts +58 -13
- package/src/lib/inspector-card-mode.test.ts +92 -0
- package/src/lib/inspector-card-mode.ts +60 -0
- package/src/lib/measurement-parser.ts +3 -0
- package/src/lib/measurements.test.ts +4 -0
- package/src/lib/paint-scope.test.ts +58 -2
- package/src/lib/paint-scope.ts +8 -2
- package/src/lib/selection-routing.test.ts +20 -5
- package/src/lib/selection-routing.ts +15 -1
- package/src/lib/session-groups.test.ts +88 -0
- package/src/lib/session-groups.ts +201 -0
- package/src/lib/structured-clone-fallback.test.ts +45 -0
- package/src/lib/structured-clone-fallback.ts +24 -0
- package/src/store/use-session-groups.ts +103 -0
|
@@ -3,10 +3,9 @@ import {
|
|
|
3
3
|
type AnyNodeId,
|
|
4
4
|
DEFAULT_ANGLE_STEP,
|
|
5
5
|
DEFAULT_LEVEL_HEIGHT,
|
|
6
|
-
type DoorNode,
|
|
7
6
|
GROUND_SUPPORT_ID,
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
planWallInsertion,
|
|
8
|
+
planWallSplitAtPoint,
|
|
10
9
|
resolveWallSupportSlabPatch,
|
|
11
10
|
runAsSingleSceneHistoryStep,
|
|
12
11
|
snapPointAlongAngleRay,
|
|
@@ -14,8 +13,6 @@ import {
|
|
|
14
13
|
terrainSupportLift,
|
|
15
14
|
useScene,
|
|
16
15
|
type WallNode,
|
|
17
|
-
WallNode as WallSchema,
|
|
18
|
-
type WindowNode,
|
|
19
16
|
} from '@pascal-app/core'
|
|
20
17
|
import { useViewer } from '@pascal-app/viewer'
|
|
21
18
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
@@ -25,7 +22,6 @@ import {
|
|
|
25
22
|
distanceSquared,
|
|
26
23
|
findWallSnapTarget,
|
|
27
24
|
findWallSpecialPointSnap,
|
|
28
|
-
projectPointOntoWall,
|
|
29
25
|
WALL_CONNECT_SNAP_RADIUS,
|
|
30
26
|
WALL_JOIN_SNAP_RADIUS,
|
|
31
27
|
type WallDraftSnapResult,
|
|
@@ -49,17 +45,6 @@ export {
|
|
|
49
45
|
|
|
50
46
|
export const WALL_GRID_STEP = 0.5
|
|
51
47
|
export const WALL_MIN_LENGTH = 0.01
|
|
52
|
-
// An endpoint projecting within this distance of an existing wall's corner
|
|
53
|
-
// resolves to the corner without splitting — splitting there would mint a
|
|
54
|
-
// sliver segment a hair longer than `WALL_MIN_LENGTH` that no snap radius
|
|
55
|
-
// can ever target again.
|
|
56
|
-
const WALL_SPLIT_ENDPOINT_EPSILON = 0.02
|
|
57
|
-
|
|
58
|
-
type WallSplitIntersection = {
|
|
59
|
-
/** `null` = snap-only outcome: resolve to `point` but split no wall. */
|
|
60
|
-
wallId: WallNode['id'] | null
|
|
61
|
-
point: WallPlanPoint
|
|
62
|
-
}
|
|
63
48
|
|
|
64
49
|
export function getSegmentGridStep(): number {
|
|
65
50
|
// A 0 step means "no grid lattice" — every grid-snap consumer guards on
|
|
@@ -78,283 +63,6 @@ export function snapPointToGrid(point: WallPlanPoint, step = WALL_GRID_STEP): Wa
|
|
|
78
63
|
return [snapScalarToGrid(point[0], step), snapScalarToGrid(point[1], step)]
|
|
79
64
|
}
|
|
80
65
|
|
|
81
|
-
function splitWallAtPoint(
|
|
82
|
-
wall: WallNode,
|
|
83
|
-
splitPoint: WallPlanPoint,
|
|
84
|
-
nodes: ReturnType<typeof useScene.getState>['nodes'],
|
|
85
|
-
): [WallNode, WallNode] {
|
|
86
|
-
const { id: _id, parentId: _parentId, children, ...rest } = wall
|
|
87
|
-
|
|
88
|
-
const first = WallSchema.parse({
|
|
89
|
-
...rest,
|
|
90
|
-
start: wall.start,
|
|
91
|
-
end: splitPoint,
|
|
92
|
-
children: [],
|
|
93
|
-
})
|
|
94
|
-
const second = WallSchema.parse({
|
|
95
|
-
...rest,
|
|
96
|
-
start: splitPoint,
|
|
97
|
-
end: wall.end,
|
|
98
|
-
children: [],
|
|
99
|
-
})
|
|
100
|
-
|
|
101
|
-
if (wall.supportSlabId !== GROUND_SUPPORT_ID || !wall.parentId) {
|
|
102
|
-
return [first, second]
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
const levelId = wall.parentId
|
|
106
|
-
const originalElevation =
|
|
107
|
-
(terrainSupportLift(nodes, levelId, wall.start[0], wall.start[1]) ?? 0) +
|
|
108
|
-
(wall.supportOffset ?? 0)
|
|
109
|
-
const rebase = (segment: WallNode): WallNode => {
|
|
110
|
-
const terrainElevation =
|
|
111
|
-
terrainSupportLift(nodes, levelId, segment.start[0], segment.start[1]) ?? 0
|
|
112
|
-
const supportOffset = originalElevation - terrainElevation
|
|
113
|
-
return {
|
|
114
|
-
...segment,
|
|
115
|
-
supportOffset: Math.abs(supportOffset) > 1e-6 ? supportOffset : undefined,
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
return [rebase(first), rebase(second)]
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function pointsEqual(a: WallPlanPoint, b: WallPlanPoint, tolerance = 1e-6): boolean {
|
|
122
|
-
return distanceSquared(a, b) <= tolerance * tolerance
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function findWallIntersection(
|
|
126
|
-
point: WallPlanPoint,
|
|
127
|
-
walls: WallNode[],
|
|
128
|
-
radius: number,
|
|
129
|
-
ignoreWallIds?: string[],
|
|
130
|
-
): WallSplitIntersection | null {
|
|
131
|
-
const ignore = new Set(ignoreWallIds ?? [])
|
|
132
|
-
let best: WallSplitIntersection | null = null
|
|
133
|
-
let bestDistanceSquared = Number.POSITIVE_INFINITY
|
|
134
|
-
|
|
135
|
-
for (const wall of walls) {
|
|
136
|
-
if (ignore.has(wall.id)) continue
|
|
137
|
-
|
|
138
|
-
const projected = projectPointOntoWall(point, wall)
|
|
139
|
-
if (!projected) continue
|
|
140
|
-
|
|
141
|
-
const candidateDistanceSquared = distanceSquared(point, projected)
|
|
142
|
-
if (
|
|
143
|
-
candidateDistanceSquared > radius * radius ||
|
|
144
|
-
candidateDistanceSquared >= bestDistanceSquared
|
|
145
|
-
) {
|
|
146
|
-
continue
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
const nearCorner = ([wall.start, wall.end] as WallPlanPoint[]).find(
|
|
150
|
-
(corner) =>
|
|
151
|
-
distanceSquared(projected, corner) <=
|
|
152
|
-
WALL_SPLIT_ENDPOINT_EPSILON * WALL_SPLIT_ENDPOINT_EPSILON,
|
|
153
|
-
)
|
|
154
|
-
best = nearCorner
|
|
155
|
-
? { wallId: null, point: [nearCorner[0], nearCorner[1]] }
|
|
156
|
-
: { wallId: wall.id, point: projected }
|
|
157
|
-
bestDistanceSquared = candidateDistanceSquared
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
return best
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function wallHasAttachments(wall: WallNode, nodes: ReturnType<typeof useScene.getState>['nodes']) {
|
|
164
|
-
if ((wall.children?.length ?? 0) > 0) {
|
|
165
|
-
return true
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
return Object.values(nodes).some((node) => {
|
|
169
|
-
if (!node) return false
|
|
170
|
-
if ('parentId' in node && node.parentId === wall.id) return true
|
|
171
|
-
if ('wallId' in node && typeof node.wallId === 'string' && node.wallId === wall.id) return true
|
|
172
|
-
return false
|
|
173
|
-
})
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
function wallLength(wall: Pick<WallNode, 'start' | 'end'>) {
|
|
177
|
-
return Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1])
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
function getWallAttachmentSpan(node: AnyNode): { min: number; max: number; center: number } | null {
|
|
181
|
-
if (node.type === 'door') {
|
|
182
|
-
const door = node as DoorNode
|
|
183
|
-
return {
|
|
184
|
-
min: door.position[0] - door.width / 2,
|
|
185
|
-
max: door.position[0] + door.width / 2,
|
|
186
|
-
center: door.position[0],
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
if (node.type === 'window') {
|
|
191
|
-
const win = node as WindowNode
|
|
192
|
-
return {
|
|
193
|
-
min: win.position[0] - win.width / 2,
|
|
194
|
-
max: win.position[0] + win.width / 2,
|
|
195
|
-
center: win.position[0],
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
if (node.type === 'item') {
|
|
200
|
-
const item = node as ItemNode
|
|
201
|
-
if (item.asset.attachTo !== 'wall' && item.asset.attachTo !== 'wall-side') {
|
|
202
|
-
return null
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
const [width] = getScaledDimensions(item)
|
|
206
|
-
return {
|
|
207
|
-
min: item.position[0] - width / 2,
|
|
208
|
-
max: item.position[0] + width / 2,
|
|
209
|
-
center: item.position[0],
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
return null
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
function remapAttachmentToWall(
|
|
217
|
-
node: AnyNode,
|
|
218
|
-
nextWallId: WallNode['id'],
|
|
219
|
-
nextLocalX: number,
|
|
220
|
-
nextWallLength: number,
|
|
221
|
-
): Partial<AnyNode> | null {
|
|
222
|
-
const clampedX = Math.max(0, Math.min(nextWallLength, nextLocalX))
|
|
223
|
-
|
|
224
|
-
if (node.type === 'door' || node.type === 'window' || node.type === 'item') {
|
|
225
|
-
const currentPosition = 'position' in node ? node.position : null
|
|
226
|
-
if (!currentPosition) return null
|
|
227
|
-
|
|
228
|
-
const nextPosition: typeof currentPosition = [
|
|
229
|
-
clampedX,
|
|
230
|
-
currentPosition[1],
|
|
231
|
-
currentPosition[2],
|
|
232
|
-
] as typeof currentPosition
|
|
233
|
-
|
|
234
|
-
return {
|
|
235
|
-
parentId: nextWallId,
|
|
236
|
-
position: nextPosition,
|
|
237
|
-
...(node.type === 'item'
|
|
238
|
-
? {
|
|
239
|
-
wallId: nextWallId,
|
|
240
|
-
wallT: nextWallLength > 1e-6 ? clampedX / nextWallLength : 0,
|
|
241
|
-
}
|
|
242
|
-
: {
|
|
243
|
-
wallId: nextWallId,
|
|
244
|
-
}),
|
|
245
|
-
} as Partial<AnyNode>
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
return null
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
function buildAttachmentMigrationPlan(
|
|
252
|
-
wall: WallNode,
|
|
253
|
-
splitPoint: WallPlanPoint,
|
|
254
|
-
firstWall: WallNode,
|
|
255
|
-
secondWall: WallNode,
|
|
256
|
-
nodes: ReturnType<typeof useScene.getState>['nodes'],
|
|
257
|
-
): { id: AnyNodeId; data: Partial<AnyNode> }[] | null {
|
|
258
|
-
const splitDistance = Math.hypot(splitPoint[0] - wall.start[0], splitPoint[1] - wall.start[1])
|
|
259
|
-
const firstLength = wallLength(firstWall)
|
|
260
|
-
const secondLength = wallLength(secondWall)
|
|
261
|
-
const tolerance = 1e-4
|
|
262
|
-
const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = []
|
|
263
|
-
|
|
264
|
-
for (const childId of wall.children ?? []) {
|
|
265
|
-
const childNode = nodes[childId as AnyNodeId]
|
|
266
|
-
if (!childNode) continue
|
|
267
|
-
|
|
268
|
-
const span = getWallAttachmentSpan(childNode)
|
|
269
|
-
if (!span) {
|
|
270
|
-
return null
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
if (span.max <= splitDistance + tolerance) {
|
|
274
|
-
const nextUpdate = remapAttachmentToWall(childNode, firstWall.id, span.center, firstLength)
|
|
275
|
-
if (!nextUpdate) return null
|
|
276
|
-
updates.push({ id: childNode.id as AnyNodeId, data: nextUpdate })
|
|
277
|
-
continue
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
if (span.min >= splitDistance - tolerance) {
|
|
281
|
-
const nextUpdate = remapAttachmentToWall(
|
|
282
|
-
childNode,
|
|
283
|
-
secondWall.id,
|
|
284
|
-
span.center - splitDistance,
|
|
285
|
-
secondLength,
|
|
286
|
-
)
|
|
287
|
-
if (!nextUpdate) return null
|
|
288
|
-
updates.push({ id: childNode.id as AnyNodeId, data: nextUpdate })
|
|
289
|
-
continue
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
return null
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
return updates
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
function splitWallIfNeeded(
|
|
299
|
-
intersection: WallSplitIntersection | null,
|
|
300
|
-
walls: WallNode[],
|
|
301
|
-
nodes: ReturnType<typeof useScene.getState>['nodes'],
|
|
302
|
-
createNodes: ReturnType<typeof useScene.getState>['createNodes'],
|
|
303
|
-
updateNodes: ReturnType<typeof useScene.getState>['updateNodes'],
|
|
304
|
-
deleteNode: ReturnType<typeof useScene.getState>['deleteNode'],
|
|
305
|
-
): { walls: WallNode[]; point: WallPlanPoint } | null {
|
|
306
|
-
if (!intersection) return null
|
|
307
|
-
|
|
308
|
-
if (!intersection.wallId) {
|
|
309
|
-
return { walls, point: intersection.point }
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
const wallToSplit = walls.find((wall) => wall.id === intersection.wallId)
|
|
313
|
-
if (!wallToSplit) {
|
|
314
|
-
return { walls, point: intersection.point }
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
const [first, second] = splitWallAtPoint(wallToSplit, intersection.point, nodes)
|
|
318
|
-
const attachmentUpdates = buildAttachmentMigrationPlan(
|
|
319
|
-
wallToSplit,
|
|
320
|
-
intersection.point,
|
|
321
|
-
first,
|
|
322
|
-
second,
|
|
323
|
-
nodes,
|
|
324
|
-
)
|
|
325
|
-
|
|
326
|
-
if (wallHasAttachments(wallToSplit, nodes) && !attachmentUpdates) {
|
|
327
|
-
return { walls, point: intersection.point }
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
createNodes([
|
|
331
|
-
{ node: first, parentId: wallToSplit.parentId as AnyNodeId | undefined },
|
|
332
|
-
{ node: second, parentId: wallToSplit.parentId as AnyNodeId | undefined },
|
|
333
|
-
])
|
|
334
|
-
if (attachmentUpdates && attachmentUpdates.length > 0) {
|
|
335
|
-
updateNodes(attachmentUpdates)
|
|
336
|
-
}
|
|
337
|
-
deleteNode(wallToSplit.id as AnyNodeId)
|
|
338
|
-
|
|
339
|
-
return {
|
|
340
|
-
walls: [...walls.filter((wall) => wall.id !== wallToSplit.id), first, second],
|
|
341
|
-
point: intersection.point,
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
/**
|
|
346
|
-
* Commit-time split resolution for an endpoint MOVE — the sibling of the
|
|
347
|
-
* inline resolution in `createWallOnCurrentLevel`: when a moved endpoint is
|
|
348
|
-
* dropped on another wall's interior, split that host exactly like the draw
|
|
349
|
-
* path (duplicate props, migrate attachments by span, skip the split when an
|
|
350
|
-
* opening straddles the point). Mutates the scene store (create halves /
|
|
351
|
-
* migrate attachments / delete host), so callers MUST run it inside the same
|
|
352
|
-
* `runAsSingleSceneHistoryStep` as their endpoint write.
|
|
353
|
-
*
|
|
354
|
-
* Returns the resolved endpoint (projection onto the host, or a nearby corner
|
|
355
|
-
* when the drop is within `WALL_SPLIT_ENDPOINT_EPSILON` of one — corner joins
|
|
356
|
-
* are not splits), or `null` when the point lands on no wall.
|
|
357
|
-
*/
|
|
358
66
|
export function resolveEndpointWallSplit(args: {
|
|
359
67
|
point: WallPlanPoint
|
|
360
68
|
/** Level the moved wall lives on — only its walls are split candidates. */
|
|
@@ -369,14 +77,23 @@ export function resolveEndpointWallSplit(args: {
|
|
|
369
77
|
radius?: number
|
|
370
78
|
}): WallPlanPoint | null {
|
|
371
79
|
const { point, levelId, ignoreWallIds, radius = WALL_CONNECT_SNAP_RADIUS } = args
|
|
372
|
-
const { nodes,
|
|
373
|
-
const
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
80
|
+
const { nodes, applyNodeChanges } = useScene.getState()
|
|
81
|
+
const result = planWallSplitAtPoint(nodes, {
|
|
82
|
+
point,
|
|
83
|
+
levelId: levelId as AnyNodeId | null,
|
|
84
|
+
ignoreWallIds,
|
|
85
|
+
radius,
|
|
86
|
+
})
|
|
87
|
+
if (!result.ok) return null
|
|
88
|
+
const { plan } = result
|
|
89
|
+
if (
|
|
90
|
+
plan.changes.create.length > 0 ||
|
|
91
|
+
plan.changes.update.length > 0 ||
|
|
92
|
+
plan.changes.delete.length > 0
|
|
93
|
+
) {
|
|
94
|
+
applyNodeChanges(plan.changes)
|
|
95
|
+
}
|
|
96
|
+
return plan.point
|
|
380
97
|
}
|
|
381
98
|
|
|
382
99
|
type SnapWallDraftArgs = {
|
|
@@ -533,102 +250,52 @@ export function createWallOnCurrentLevel(
|
|
|
533
250
|
options?: WallConstructionOptions,
|
|
534
251
|
): WallNode | null {
|
|
535
252
|
const currentLevelId = useViewer.getState().selection.levelId
|
|
536
|
-
const {
|
|
537
|
-
const { updateNodes } = useScene.getState()
|
|
253
|
+
const { nodes, applyNodeChanges } = useScene.getState()
|
|
538
254
|
|
|
539
255
|
if (!(currentLevelId && isSegmentLongEnough(start, end))) {
|
|
540
256
|
return null
|
|
541
257
|
}
|
|
542
258
|
|
|
543
|
-
let workingWalls = Object.values(nodes).filter(
|
|
544
|
-
(node): node is WallNode => node?.type === 'wall' && node.parentId === currentLevelId,
|
|
545
|
-
)
|
|
546
|
-
|
|
547
|
-
let resolvedStart = start
|
|
548
|
-
let resolvedEnd = end
|
|
549
|
-
|
|
550
|
-
// The corner-join / wall-split resolution follows the snapping mode like the
|
|
551
|
-
// draft preview does: magnetic ('lines') keeps the generous join radius,
|
|
552
|
-
// every other mode uses the same tight connect radius the draft path already
|
|
553
|
-
// sticks endpoints with. So an endpoint the user saw connect to a wall body
|
|
554
|
-
// actually splits that wall (and redistributes its attachments) in every
|
|
555
|
-
// mode, while `'off'` / `'angles'` gain no residual long-range snap.
|
|
556
259
|
const joinRadius = isMagneticSnapActive() ? WALL_JOIN_SNAP_RADIUS : WALL_CONNECT_SNAP_RADIUS
|
|
557
260
|
|
|
558
|
-
// One undo step for the whole commit: the split ops (create halves, migrate
|
|
559
|
-
// attachments, delete host) plus the new wall each push their own history
|
|
560
|
-
// entry, and a single Ctrl-Z must not strand a half-split wall network.
|
|
561
261
|
return runAsSingleSceneHistoryStep(useScene, () => {
|
|
562
|
-
const
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
updateNodes,
|
|
569
|
-
deleteNode,
|
|
570
|
-
)
|
|
571
|
-
if (splitEnd) {
|
|
572
|
-
workingWalls = splitEnd.walls
|
|
573
|
-
resolvedEnd = splitEnd.point
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
const startIntersection = findWallIntersection(resolvedStart, workingWalls, joinRadius)
|
|
577
|
-
const splitStart = splitWallIfNeeded(
|
|
578
|
-
startIntersection,
|
|
579
|
-
workingWalls,
|
|
580
|
-
nodes,
|
|
581
|
-
createNodes,
|
|
582
|
-
updateNodes,
|
|
583
|
-
deleteNode,
|
|
584
|
-
)
|
|
585
|
-
if (splitStart) {
|
|
586
|
-
workingWalls = splitStart.walls
|
|
587
|
-
resolvedStart = splitStart.point
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
if (
|
|
591
|
-
!isSegmentLongEnough(resolvedStart, resolvedEnd) ||
|
|
592
|
-
pointsEqual(resolvedStart, resolvedEnd)
|
|
593
|
-
) {
|
|
594
|
-
return null
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
const duplicateWall = workingWalls.some(
|
|
598
|
-
(wall) =>
|
|
599
|
-
(pointsEqual(wall.start, resolvedStart) && pointsEqual(wall.end, resolvedEnd)) ||
|
|
600
|
-
(pointsEqual(wall.start, resolvedEnd) && pointsEqual(wall.end, resolvedStart)),
|
|
601
|
-
)
|
|
602
|
-
if (duplicateWall) {
|
|
603
|
-
return null
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
const wallCount = Object.values(nodes).filter((node) => node.type === 'wall').length
|
|
607
|
-
// A placed wall preset seeds `toolDefaults.wall` (thickness, height,
|
|
608
|
-
// materials, sides) before the tool activates; merge those first so the
|
|
609
|
-
// drawn wall reproduces the preset. Identity + endpoints always win.
|
|
610
|
-
const defaults = useEditor.getState().toolDefaults.wall ?? {}
|
|
611
|
-
const wall = WallSchema.parse({
|
|
612
|
-
...defaults,
|
|
613
|
-
name: `Wall ${wallCount + 1}`,
|
|
614
|
-
start: resolvedStart,
|
|
615
|
-
end: resolvedEnd,
|
|
262
|
+
const result = planWallInsertion(nodes, {
|
|
263
|
+
levelId: currentLevelId as AnyNodeId,
|
|
264
|
+
start,
|
|
265
|
+
end,
|
|
266
|
+
joinRadius,
|
|
267
|
+
wallDefaults: useEditor.getState().toolDefaults.wall ?? {},
|
|
616
268
|
})
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
269
|
+
if (!result.ok) return null
|
|
270
|
+
const { plan } = result
|
|
271
|
+
|
|
272
|
+
applyNodeChanges(plan.changes)
|
|
273
|
+
const committedNodes = useScene.getState().nodes
|
|
274
|
+
const supportUpdates = plan.insertedWalls.flatMap((wall) => {
|
|
275
|
+
const createdWall = committedNodes[wall.id]
|
|
276
|
+
if (createdWall?.type !== 'wall') return []
|
|
621
277
|
const terrainBase = terrainSupportLift(
|
|
622
|
-
|
|
278
|
+
committedNodes,
|
|
623
279
|
currentLevelId,
|
|
624
280
|
createdWall.start[0],
|
|
625
281
|
createdWall.start[1],
|
|
626
282
|
)
|
|
283
|
+
// A ground-preferred draft is the terrain exception only while sculpted
|
|
284
|
+
// terrain actually supports this storey. On flat ground the ground plane
|
|
285
|
+
// is just the backdrop the chain started from: drop the draft options so
|
|
286
|
+
// the wall commits plane-bound — no stamped height, no persisted ground
|
|
287
|
+
// host, no election cap at the draft plane.
|
|
288
|
+
const wallOptions =
|
|
289
|
+
options?.preferredSupportSlabId === GROUND_SUPPORT_ID && terrainBase == null
|
|
290
|
+
? undefined
|
|
291
|
+
: options
|
|
627
292
|
const preferredSupportSlabId =
|
|
628
|
-
|
|
629
|
-
(
|
|
630
|
-
|
|
631
|
-
|
|
293
|
+
wallOptions?.preferredSupportSlabId ??
|
|
294
|
+
(wallOptions?.constructionElevation != null && terrainBase != null
|
|
295
|
+
? GROUND_SUPPORT_ID
|
|
296
|
+
: null)
|
|
297
|
+
const supportPatch = resolveWallSupportSlabPatch(createdWall, committedNodes, {
|
|
298
|
+
maxElevation: wallOptions?.supportCap ?? null,
|
|
632
299
|
preferredSlabId: preferredSupportSlabId,
|
|
633
300
|
})
|
|
634
301
|
const supportSlabId = supportPatch.supportSlabId
|
|
@@ -639,29 +306,46 @@ export function createWallOnCurrentLevel(
|
|
|
639
306
|
createdWall.curveOffset,
|
|
640
307
|
createdWall.thickness,
|
|
641
308
|
supportSlabId,
|
|
642
|
-
|
|
309
|
+
wallOptions?.supportCap ?? null,
|
|
643
310
|
)
|
|
311
|
+
// Freezing the draft plane into the node (explicit height + offset from
|
|
312
|
+
// the elected support) is the terrain exception only: a ground-drafted
|
|
313
|
+
// chain keeps one construction plane and a fixed body height while
|
|
314
|
+
// sculpting moves the ground. Every other wall stays plane-bound —
|
|
315
|
+
// height absent, top at the storey plane, base re-elected from slab
|
|
316
|
+
// support — so a wall merely started on a slab or deck must never have
|
|
317
|
+
// the ghost height stamped onto it.
|
|
318
|
+
const groundDraft = preferredSupportSlabId === GROUND_SUPPORT_ID
|
|
644
319
|
const supportOffset =
|
|
645
|
-
|
|
646
|
-
?
|
|
647
|
-
:
|
|
320
|
+
groundDraft && wallOptions?.constructionElevation != null
|
|
321
|
+
? wallOptions.constructionElevation - sourceSupport.elevation
|
|
322
|
+
: undefined
|
|
648
323
|
const preserveDraftHeight =
|
|
324
|
+
groundDraft &&
|
|
649
325
|
createdWall.height == null &&
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
326
|
+
wallOptions?.constructionHeight != null &&
|
|
327
|
+
wallOptions.constructionElevation != null
|
|
328
|
+
return [
|
|
329
|
+
{
|
|
330
|
+
id: createdWall.id,
|
|
331
|
+
data: {
|
|
332
|
+
...supportPatch,
|
|
333
|
+
height: preserveDraftHeight
|
|
334
|
+
? (wallOptions?.constructionHeight ?? createdWall.height)
|
|
335
|
+
: createdWall.height,
|
|
336
|
+
supportOffset:
|
|
337
|
+
supportOffset != null && Math.abs(supportOffset) > 1e-6 ? supportOffset : undefined,
|
|
338
|
+
},
|
|
339
|
+
},
|
|
340
|
+
]
|
|
341
|
+
})
|
|
342
|
+
if (supportUpdates.length > 0) {
|
|
343
|
+
useScene.getState().updateNodes(supportUpdates)
|
|
661
344
|
}
|
|
662
345
|
sfxEmitter.emit('sfx:structure-build')
|
|
663
346
|
|
|
664
|
-
const
|
|
665
|
-
|
|
347
|
+
const terminalWall = plan.insertedWalls.at(-1)!
|
|
348
|
+
const committedWall = useScene.getState().nodes[plan.terminalWallId]
|
|
349
|
+
return committedWall?.type === 'wall' ? committedWall : terminalWall
|
|
666
350
|
})
|
|
667
351
|
}
|
|
@@ -37,7 +37,7 @@ import {
|
|
|
37
37
|
Video,
|
|
38
38
|
} from 'lucide-react'
|
|
39
39
|
import { useEffect } from 'react'
|
|
40
|
-
import { runRedo, runUndo } from '../../../lib/history'
|
|
40
|
+
import { getHistoryCommandState, runRedo, runUndo } from '../../../lib/history'
|
|
41
41
|
import { deleteLevelWithFallbackSelection } from '../../../lib/level-selection'
|
|
42
42
|
import { useCommandRegistry } from '../../../store/use-command-registry'
|
|
43
43
|
import type { StructureTool } from '../../../store/use-editor'
|
|
@@ -351,6 +351,7 @@ export function EditorCommands() {
|
|
|
351
351
|
group: 'History',
|
|
352
352
|
icon: <Undo2 className="h-4 w-4" />,
|
|
353
353
|
keywords: ['undo', 'revert', 'back'],
|
|
354
|
+
when: () => getHistoryCommandState().canUndo,
|
|
354
355
|
execute: () => run(() => runUndo()),
|
|
355
356
|
},
|
|
356
357
|
{
|
|
@@ -359,6 +360,7 @@ export function EditorCommands() {
|
|
|
359
360
|
group: 'History',
|
|
360
361
|
icon: <Redo2 className="h-4 w-4" />,
|
|
361
362
|
keywords: ['redo', 'forward', 'repeat'],
|
|
363
|
+
when: () => getHistoryCommandState().canRedo,
|
|
362
364
|
execute: () => run(() => runRedo()),
|
|
363
365
|
},
|
|
364
366
|
|
|
@@ -2,43 +2,84 @@
|
|
|
2
2
|
|
|
3
3
|
import { type AnyNodeId, useScene } from '@pascal-app/core'
|
|
4
4
|
import { useViewer } from '@pascal-app/viewer'
|
|
5
|
-
import { Copy, Trash2 } from 'lucide-react'
|
|
5
|
+
import { Copy, Group, Trash2, Ungroup } from 'lucide-react'
|
|
6
|
+
import { useMemo } from 'react'
|
|
6
7
|
import { deleteSelection, duplicateSelectionAndPickUp } from '../../editor/group-actions'
|
|
8
|
+
import {
|
|
9
|
+
canCreateSessionGroup,
|
|
10
|
+
selectionIntersectsSessionGroup,
|
|
11
|
+
selectionMatchesSessionGroup,
|
|
12
|
+
} from '../../../lib/session-groups'
|
|
13
|
+
import useSessionGroups, {
|
|
14
|
+
groupCurrentSelection,
|
|
15
|
+
ungroupCurrentSelection,
|
|
16
|
+
} from '../../../store/use-session-groups'
|
|
7
17
|
import { ActionButton, ActionGroup } from '../controls/action-button'
|
|
8
18
|
import { PanelWrapper } from './panel-wrapper'
|
|
9
19
|
import { formatSelectionBreakdown } from './selection-breakdown'
|
|
10
20
|
|
|
11
21
|
/**
|
|
12
|
-
* Docked
|
|
13
|
-
* single-node inspector, rendered by `PanelManager` when more than one node
|
|
14
|
-
* is selected. Same collapsed-by-default `PanelWrapper` shell (header always
|
|
15
|
-
* visible; the shared desktop collapse state carries across single ↔ multi
|
|
16
|
-
* swaps). Actions mirror the floating group pill: Duplicate clones the
|
|
17
|
-
* selection and picks the clones up, Delete removes the whole selection
|
|
18
|
-
* (including its bulk-delete confirm). Unlike the pill, the docked panel
|
|
19
|
-
* stays visible during interactions. `footer` is the host-injected slot
|
|
20
|
-
* (e.g. community's "Save to my catalog").
|
|
22
|
+
* Docked multi-selection panel. Includes Group / Ungroup for session selection sets.
|
|
21
23
|
*/
|
|
22
24
|
export function MultiSelectionPanel({ footer }: { footer?: React.ReactNode }) {
|
|
23
25
|
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
|
24
26
|
const setSelection = useViewer((s) => s.setSelection)
|
|
25
|
-
|
|
26
|
-
// unrelated mutations from re-rendering the panel.
|
|
27
|
+
const sessionGroups = useSessionGroups((s) => s.groups)
|
|
27
28
|
const breakdown = useScene((s) =>
|
|
28
29
|
formatSelectionBreakdown(selectedIds.map((id) => s.nodes[id as AnyNodeId]?.type)),
|
|
29
30
|
)
|
|
31
|
+
const sceneNodes = useScene((s) => s.nodes)
|
|
32
|
+
const liveIds = useMemo(() => new Set(Object.keys(sceneNodes)), [sceneNodes])
|
|
33
|
+
const matchedGroup = useMemo(
|
|
34
|
+
() => selectionMatchesSessionGroup(sessionGroups, selectedIds, liveIds),
|
|
35
|
+
[sessionGroups, selectedIds, liveIds],
|
|
36
|
+
)
|
|
37
|
+
const showGroup = useMemo(
|
|
38
|
+
() => canCreateSessionGroup(sessionGroups, selectedIds, liveIds),
|
|
39
|
+
[sessionGroups, selectedIds, liveIds],
|
|
40
|
+
)
|
|
41
|
+
const showUngroup = useMemo(
|
|
42
|
+
() => selectionIntersectsSessionGroup(sessionGroups, selectedIds, liveIds),
|
|
43
|
+
[sessionGroups, selectedIds, liveIds],
|
|
44
|
+
)
|
|
30
45
|
|
|
31
46
|
return (
|
|
32
47
|
<PanelWrapper
|
|
33
48
|
footer={footer}
|
|
34
49
|
icon="/icons/select.webp"
|
|
35
50
|
onClose={() => setSelection({ selectedIds: [] })}
|
|
36
|
-
title={
|
|
51
|
+
title={
|
|
52
|
+
matchedGroup
|
|
53
|
+
? `${matchedGroup.label} · ${selectedIds.length}`
|
|
54
|
+
: `${selectedIds.length} selected`
|
|
55
|
+
}
|
|
37
56
|
width={320}
|
|
38
57
|
>
|
|
39
58
|
{breakdown && <div className="px-3 py-3 text-muted-foreground text-xs">{breakdown}</div>}
|
|
59
|
+
{matchedGroup && (
|
|
60
|
+
<div className="border-border/50 border-t px-3 py-2 text-muted-foreground text-xs">
|
|
61
|
+
{matchedGroup.label} (session only). Plain click reselects all members. Not saved with the
|
|
62
|
+
project.
|
|
63
|
+
</div>
|
|
64
|
+
)}
|
|
40
65
|
<div className="border-border/50 border-t p-3">
|
|
41
66
|
<ActionGroup>
|
|
67
|
+
{showGroup && (
|
|
68
|
+
<ActionButton
|
|
69
|
+
icon={<Group className="h-4 w-4" />}
|
|
70
|
+
label="Group"
|
|
71
|
+
onClick={() => groupCurrentSelection()}
|
|
72
|
+
title="Group (Ctrl/Cmd+G)"
|
|
73
|
+
/>
|
|
74
|
+
)}
|
|
75
|
+
{showUngroup && (
|
|
76
|
+
<ActionButton
|
|
77
|
+
icon={<Ungroup className="h-4 w-4" />}
|
|
78
|
+
label="Ungroup"
|
|
79
|
+
onClick={() => ungroupCurrentSelection()}
|
|
80
|
+
title="Ungroup (Ctrl/Cmd+Shift+G)"
|
|
81
|
+
/>
|
|
82
|
+
)}
|
|
42
83
|
<ActionButton
|
|
43
84
|
icon={<Copy className="h-4 w-4" />}
|
|
44
85
|
label="Duplicate"
|