@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.
Files changed (73) hide show
  1. package/package.json +7 -6
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +12 -3
  3. package/src/components/editor/editor-layout-v2.tsx +8 -3
  4. package/src/components/editor/elevation-3d-guide-layer.tsx +3 -2
  5. package/src/components/editor/first-person-controls.tsx +2 -45
  6. package/src/components/editor/floating-action-menu.tsx +13 -4
  7. package/src/components/editor/floorplan-background-selection.test.ts +31 -4
  8. package/src/components/editor/floorplan-background-selection.ts +27 -7
  9. package/src/components/editor/floorplan-panel.tsx +5 -45
  10. package/src/components/editor/group-floating-action-menu.tsx +30 -15
  11. package/src/components/editor/index.tsx +112 -25
  12. package/src/components/editor/measurement-pill.tsx +12 -12
  13. package/src/components/editor/node-action-menu.tsx +29 -1
  14. package/src/components/editor/opening-guides-3d-layer.tsx +13 -4
  15. package/src/components/editor/selection-manager.tsx +28 -5
  16. package/src/components/editor/site-edge-labels.tsx +2 -1
  17. package/src/components/editor/snapshot-capture-overlay.tsx +11 -2
  18. package/src/components/editor/thumbnail-generator.tsx +27 -11
  19. package/src/components/editor/wall-measurement-label.tsx +3 -2
  20. package/src/components/editor-2d/floorplan-group-action-menu.tsx +23 -16
  21. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +26 -84
  22. package/src/components/tools/item/placement-strategies.test.ts +167 -0
  23. package/src/components/tools/item/placement-strategies.ts +57 -22
  24. package/src/components/tools/item/use-placement-coordinator.tsx +19 -27
  25. package/src/components/tools/shared/placement-box.tsx +9 -4
  26. package/src/components/tools/site/terrain-brush-cursor.tsx +2 -1
  27. package/src/components/tools/wall/wall-drafting.test.ts +207 -2
  28. package/src/components/tools/wall/wall-drafting.ts +84 -400
  29. package/src/components/ui/command-palette/editor-commands.tsx +3 -1
  30. package/src/components/ui/floating-level-selector.tsx +1 -1
  31. package/src/components/ui/panels/multi-selection-panel.tsx +54 -13
  32. package/src/components/ui/panels/panel-wrapper.tsx +228 -12
  33. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +7 -2
  34. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +11 -0
  35. package/src/components/ui/sidebar/panels/site-panel/index.tsx +12 -0
  36. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +8 -1
  37. package/src/components/ui/sidebar/tab-bar.tsx +7 -1
  38. package/src/components/viewer/floorplan-compass-button.tsx +50 -0
  39. package/src/components/viewer/floorplan-preview-geometry.test.ts +107 -0
  40. package/src/components/viewer/floorplan-preview-geometry.ts +281 -0
  41. package/src/components/viewer/floorplan-preview-navigation.test.ts +43 -0
  42. package/src/components/viewer/floorplan-preview-navigation.ts +62 -0
  43. package/src/components/viewer/floorplan-preview.tsx +1069 -0
  44. package/src/components/viewer/use-viewer-camera-navigation-sync.ts +140 -0
  45. package/src/components/viewer/viewer-stage-modes.test.ts +38 -0
  46. package/src/components/viewer/viewer-stage-modes.ts +49 -0
  47. package/src/components/viewer/viewer-stage-switcher.tsx +95 -0
  48. package/src/components/viewer/viewer-stage.test.tsx +47 -0
  49. package/src/components/viewer/viewer-stage.tsx +218 -0
  50. package/src/components/viewer-overlay.tsx +12 -8
  51. package/src/hooks/use-auto-save.test.ts +50 -1
  52. package/src/hooks/use-auto-save.ts +44 -9
  53. package/src/hooks/use-keyboard.ts +35 -1
  54. package/src/index.tsx +27 -0
  55. package/src/lib/contextual-help.test.ts +8 -0
  56. package/src/lib/contextual-help.ts +8 -0
  57. package/src/lib/floorplan/floorplan-readonly.test.ts +47 -0
  58. package/src/lib/floorplan/floorplan-readonly.ts +84 -0
  59. package/src/lib/history.test.ts +75 -10
  60. package/src/lib/history.ts +58 -13
  61. package/src/lib/inspector-card-mode.test.ts +92 -0
  62. package/src/lib/inspector-card-mode.ts +60 -0
  63. package/src/lib/measurement-parser.ts +3 -0
  64. package/src/lib/measurements.test.ts +4 -0
  65. package/src/lib/paint-scope.test.ts +58 -2
  66. package/src/lib/paint-scope.ts +8 -2
  67. package/src/lib/selection-routing.test.ts +20 -5
  68. package/src/lib/selection-routing.ts +15 -1
  69. package/src/lib/session-groups.test.ts +88 -0
  70. package/src/lib/session-groups.ts +201 -0
  71. package/src/lib/structured-clone-fallback.test.ts +45 -0
  72. package/src/lib/structured-clone-fallback.ts +24 -0
  73. 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
- getScaledDimensions,
9
- type ItemNode,
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, createNodes, updateNodes, deleteNode } = useScene.getState()
373
- const walls = Object.values(nodes).filter(
374
- (node): node is WallNode => node?.type === 'wall' && (node.parentId ?? null) === levelId,
375
- )
376
-
377
- const intersection = findWallIntersection(point, walls, radius, ignoreWallIds)
378
- const split = splitWallIfNeeded(intersection, walls, nodes, createNodes, updateNodes, deleteNode)
379
- return split ? split.point : null
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 { createNode, createNodes, deleteNode, nodes } = useScene.getState()
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 endIntersection = findWallIntersection(resolvedEnd, workingWalls, joinRadius)
563
- const splitEnd = splitWallIfNeeded(
564
- endIntersection,
565
- workingWalls,
566
- nodes,
567
- createNodes,
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
- createNode(wall, currentLevelId)
619
- const createdWall = useScene.getState().nodes[wall.id]
620
- if (createdWall?.type === 'wall') {
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
- useScene.getState().nodes,
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
- options?.preferredSupportSlabId ??
629
- (options?.constructionElevation != null && terrainBase != null ? GROUND_SUPPORT_ID : null)
630
- const supportPatch = resolveWallSupportSlabPatch(createdWall, useScene.getState().nodes, {
631
- maxElevation: options?.supportCap ?? null,
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
- options?.supportCap ?? null,
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
- options?.constructionElevation == null
646
- ? undefined
647
- : options.constructionElevation - sourceSupport.elevation
320
+ groundDraft && wallOptions?.constructionElevation != null
321
+ ? wallOptions.constructionElevation - sourceSupport.elevation
322
+ : undefined
648
323
  const preserveDraftHeight =
324
+ groundDraft &&
649
325
  createdWall.height == null &&
650
- options?.constructionHeight != null &&
651
- options.constructionElevation != null &&
652
- (terrainBase != null || Math.abs(options.constructionElevation) > 1e-6)
653
- useScene.getState().updateNode(createdWall.id, {
654
- ...supportPatch,
655
- height: preserveDraftHeight
656
- ? (options?.constructionHeight ?? createdWall.height)
657
- : createdWall.height,
658
- supportOffset:
659
- supportOffset != null && Math.abs(supportOffset) > 1e-6 ? supportOffset : undefined,
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 committedWall = useScene.getState().nodes[wall.id]
665
- return committedWall?.type === 'wall' ? committedWall : wall
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
 
@@ -235,7 +235,7 @@ function LevelRow({
235
235
  >
236
236
  <SliderControl
237
237
  label="Level height"
238
- max={6}
238
+ max={20}
239
239
  min={1}
240
240
  onChange={(v) => updateNode(level.id, { height: v })}
241
241
  precision={3}
@@ -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 right-side panel for a MULTI-selection the compact sibling of the
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
- // String selector recomputed on scene ticks, but the === compare keeps
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={`${selectedIds.length} selected`}
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"