@pascal-app/editor 1.0.0 → 1.0.3

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 (177) hide show
  1. package/package.json +12 -6
  2. package/src/components/editor/bake-exporter.tsx +4 -0
  3. package/src/components/editor/bake-thumbnail.tsx +4 -1
  4. package/src/components/editor/custom-camera-controls.tsx +22 -74
  5. package/src/components/editor/editor-layout-mobile.tsx +1 -0
  6. package/src/components/editor/floating-action-menu.tsx +14 -1
  7. package/src/components/editor/floorplan-panel.tsx +233 -166
  8. package/src/components/editor/grid.tsx +31 -7
  9. package/src/components/editor/group-actions.ts +5 -9
  10. package/src/components/editor/group-floating-action-menu.tsx +19 -9
  11. package/src/components/editor/group-move-3d.ts +4 -6
  12. package/src/components/editor/group-rotate-handle.tsx +81 -35
  13. package/src/components/editor/group-selection-box-3d.tsx +25 -17
  14. package/src/components/editor/group-transform-shared.test.ts +126 -1
  15. package/src/components/editor/group-transform-shared.ts +79 -73
  16. package/src/components/editor/handles/handle-arrow.tsx +72 -2
  17. package/src/components/editor/handles/handle-placement.test.ts +111 -0
  18. package/src/components/editor/handles/handle-placement.ts +69 -0
  19. package/src/components/editor/handles/linear-resize-drag.ts +13 -2
  20. package/src/components/editor/handles/use-handle-drag.ts +80 -8
  21. package/src/components/editor/index.tsx +36 -13
  22. package/src/components/editor/node-action-menu.tsx +2 -0
  23. package/src/components/editor/node-arrow-handles.tsx +162 -69
  24. package/src/components/editor/registry-action-contributions.tsx +53 -0
  25. package/src/components/editor/selection-manager.tsx +49 -0
  26. package/src/components/editor/thumbnail-generator.tsx +11 -2
  27. package/src/components/editor/wall-move-side-handles.tsx +71 -21
  28. package/src/components/editor/wall-opening-highlights.tsx +2 -3
  29. package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
  30. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
  31. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
  32. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +154 -49
  33. package/src/components/editor-2d/renderers/floorplan-draft-wall-measurement.tsx +166 -0
  34. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +49 -0
  35. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +113 -24
  36. package/src/components/procedural-items/inspector.tsx +223 -0
  37. package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
  38. package/src/components/systems/zone/zone-system.tsx +8 -4
  39. package/src/components/tools/__fixtures__/hosting.ts +10 -0
  40. package/src/components/tools/item/item-surface-placement.test.ts +169 -0
  41. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +204 -0
  42. package/src/components/tools/item/placement-strategies.ts +212 -200
  43. package/src/components/tools/item/placement-types.ts +4 -0
  44. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
  45. package/src/components/tools/item/use-draft-node.ts +136 -47
  46. package/src/components/tools/item/use-placement-coordinator.tsx +391 -157
  47. package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
  48. package/src/components/tools/registry/item-surface-move.test.ts +509 -0
  49. package/src/components/tools/registry/item-surface-move.ts +463 -0
  50. package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
  51. package/src/components/tools/registry/move-registry-node-tool.tsx +237 -78
  52. package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
  53. package/src/components/tools/shared/affordance-dispatch.ts +13 -0
  54. package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
  55. package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
  56. package/src/components/tools/shared/pointer-support-cap.ts +27 -13
  57. package/src/components/tools/shared/shelf-stickiness.ts +54 -0
  58. package/src/components/tools/shared/surface-hit.test.ts +56 -0
  59. package/src/components/tools/shared/surface-hit.ts +23 -0
  60. package/src/components/tools/shared/surface-rejection.test.tsx +107 -0
  61. package/src/components/tools/shared/surface-rejection.tsx +43 -0
  62. package/src/components/tools/tool-manager.tsx +36 -3
  63. package/src/components/tools/zone/zone-tool.tsx +13 -4
  64. package/src/components/ui/command-palette/editor-commands.tsx +54 -1
  65. package/src/components/ui/controls/material-paint-panel.tsx +6 -67
  66. package/src/components/ui/controls/material-picker.tsx +5 -67
  67. package/src/components/ui/controls/metric-control.tsx +29 -13
  68. package/src/components/ui/controls/slider-control.tsx +87 -35
  69. package/src/components/ui/floating-level-selector.tsx +81 -5
  70. package/src/components/ui/helpers/helper-manager.tsx +24 -6
  71. package/src/components/ui/helpers/registered-tool-helper.tsx +2 -32
  72. package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
  73. package/src/components/ui/panels/multi-height-mode.tsx +27 -21
  74. package/src/components/ui/panels/multi-parametric-inspector.tsx +6 -3
  75. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  76. package/src/components/ui/panels/parametric-inspector.tsx +9 -17
  77. package/src/components/ui/panels/reference-panel.tsx +3 -3
  78. package/src/components/ui/primitives/checkbox.tsx +34 -0
  79. package/src/components/ui/primitives/shortcut-token.tsx +4 -0
  80. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
  81. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +669 -336
  82. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  83. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +31 -4
  84. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +30 -11
  85. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
  86. package/src/components/ui/sidebar/panels/site-panel/index.tsx +106 -4
  87. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
  88. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
  89. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +6 -1
  90. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
  91. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
  92. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
  93. package/src/components/ui/snap-target-badge.tsx +2 -0
  94. package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
  95. package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
  96. package/src/components/viewer/floorplan-preview.tsx +1 -12
  97. package/src/components/viewer/viewer-scene-header.tsx +3 -0
  98. package/src/components/viewer/viewer-units-panel.tsx +116 -0
  99. package/src/components/viewer-zone-system.tsx +7 -2
  100. package/src/hooks/use-auto-save.integration.test.tsx +197 -0
  101. package/src/hooks/use-auto-save.ts +21 -6
  102. package/src/hooks/use-ceiling-events.test.ts +25 -2
  103. package/src/hooks/use-ceiling-events.ts +7 -1
  104. package/src/hooks/use-grid-events.test.ts +2 -0
  105. package/src/hooks/use-grid-events.ts +16 -11
  106. package/src/hooks/use-keyboard.test.ts +7 -0
  107. package/src/hooks/use-keyboard.ts +43 -28
  108. package/src/index.tsx +68 -3
  109. package/src/lib/catalog-panel-model.ts +39 -0
  110. package/src/lib/catalog-selection.test.ts +27 -0
  111. package/src/lib/catalog-selection.ts +8 -0
  112. package/src/lib/contextual-help.test.ts +27 -1
  113. package/src/lib/contextual-help.ts +16 -9
  114. package/src/lib/export-texture-utils.test.ts +167 -0
  115. package/src/lib/export-texture-utils.ts +121 -0
  116. package/src/lib/export-user-data.test.ts +48 -0
  117. package/src/lib/export-user-data.ts +69 -0
  118. package/src/lib/floorplan/floorplan-export.test.ts +142 -2
  119. package/src/lib/floorplan/floorplan-export.tsx +9 -3
  120. package/src/lib/floorplan/floorplan-extension.ts +16 -0
  121. package/src/lib/floorplan/floorplan-readonly.ts +4 -0
  122. package/src/lib/fresh-planar-placement.ts +173 -33
  123. package/src/lib/glb-export-serialize.test.ts +280 -0
  124. package/src/lib/glb-export.test.ts +265 -16
  125. package/src/lib/glb-export.ts +197 -37
  126. package/src/lib/grid-frame.test.ts +19 -0
  127. package/src/lib/grid-frame.ts +18 -0
  128. package/src/lib/interaction/scope.ts +9 -1
  129. package/src/lib/keyboard-pan.test.ts +62 -0
  130. package/src/lib/keyboard-pan.ts +89 -0
  131. package/src/lib/level-duplication.test.ts +73 -0
  132. package/src/lib/level-duplication.ts +32 -2
  133. package/src/lib/linear-display.test.ts +59 -0
  134. package/src/lib/linear-display.ts +39 -0
  135. package/src/lib/local-guide-image.test.ts +24 -0
  136. package/src/lib/local-guide-image.ts +34 -1
  137. package/src/lib/material-catalog-model.ts +72 -0
  138. package/src/lib/material-paint-panel-model.ts +76 -0
  139. package/src/lib/node-action-movement.ts +21 -0
  140. package/src/lib/overlay-transform.test.ts +28 -0
  141. package/src/lib/overlay-transform.ts +12 -0
  142. package/src/lib/panel-rows.ts +75 -0
  143. package/src/lib/panel-tool-options.test.ts +34 -0
  144. package/src/lib/panel-tool-options.ts +46 -0
  145. package/src/lib/parametric-node-update.ts +37 -0
  146. package/src/lib/portable-export.test.ts +89 -0
  147. package/src/lib/portable-export.ts +84 -11
  148. package/src/lib/print-shell-compiler-manifold-core.ts +59 -10
  149. package/src/lib/print-shell-compiler-manifold-worker.ts +14 -1
  150. package/src/lib/print-shell-compiler-manifold.worker.ts +1 -1
  151. package/src/lib/print-shell-compiler-protocol.ts +8 -0
  152. package/src/lib/scene-clipboard.ts +1 -1
  153. package/src/lib/scene.test.ts +99 -0
  154. package/src/lib/scene.ts +21 -3
  155. package/src/lib/send-to-app.test.ts +108 -0
  156. package/src/lib/send-to-app.ts +121 -0
  157. package/src/lib/sfx/movement-tick.ts +37 -0
  158. package/src/lib/snapping-mode.test.ts +13 -2
  159. package/src/lib/snapping-mode.ts +11 -6
  160. package/src/lib/spatial-drag-plane.test.ts +53 -0
  161. package/src/lib/spatial-drag-plane.ts +19 -0
  162. package/src/lib/spatial-pointer-input.test.ts +72 -0
  163. package/src/lib/spatial-pointer-input.ts +62 -0
  164. package/src/lib/spatial-snap.test.ts +47 -0
  165. package/src/lib/surface-attachment.ts +77 -0
  166. package/src/lib/terrain-panel-model.ts +117 -0
  167. package/src/lib/units.ts +254 -0
  168. package/src/lib/usdz-export.ts +43 -16
  169. package/src/lib/use-linear-display.ts +8 -32
  170. package/src/lib/world-grid-snap.test.ts +48 -0
  171. package/src/lib/world-grid-snap.ts +9 -5
  172. package/src/store/rotation-snapping.test.ts +53 -0
  173. package/src/store/use-editor.tsx +10 -2
  174. package/src/store/use-floorplan-draft-preview.ts +10 -1
  175. package/src/store/use-handle-group.ts +4 -0
  176. package/src/store/use-interaction-scope.ts +155 -6
  177. package/src/store/use-send-to-app.ts +21 -0
@@ -34,6 +34,7 @@ import {
34
34
  type RoofSegmentNode,
35
35
  resolveSlabPlacementElevation,
36
36
  resolveTerrainWallConstructionOptions,
37
+ runAsSingleSceneHistoryStep,
37
38
  type SiteNode,
38
39
  type SlabNode,
39
40
  SlabNode as SlabNodeSchema,
@@ -54,6 +55,7 @@ import {
54
55
  type WindowNode,
55
56
  WindowNode as WindowNodeSchema,
56
57
  wallClosesRoom,
58
+ wallRectangleCorners,
57
59
  ZoneNode as ZoneNodeSchema,
58
60
  type ZoneNode as ZoneNodeType,
59
61
  } from '@pascal-app/core'
@@ -93,11 +95,21 @@ import { resolveGenericFloorplanGridEventPoint } from '../../lib/floorplan-grid-
93
95
  import type { EditorGridEvent } from '../../lib/grid-event-presentation'
94
96
  import { groundHeightAt } from '../../lib/ground-surface'
95
97
  import { guideEmitter } from '../../lib/guide-events'
98
+ import {
99
+ acceptsKeyboardPan,
100
+ clearKeyboardPanKeys,
101
+ isKeyboardPanKey,
102
+ type KeyboardPanState,
103
+ keyboardPanDirection,
104
+ keyboardPanSpeed,
105
+ setKeyboardPanKey,
106
+ } from '../../lib/keyboard-pan'
96
107
  import { measurementHint, parseMeasurement } from '../../lib/measurement-parser'
97
108
  import { formatLinearMeasurement, linearUnitToMeters } from '../../lib/measurements'
98
109
  import { sfxEmitter } from '../../lib/sfx-bus'
99
110
  import { SITE_BOUNDARY_DRAG_LABEL, siteBoundaryHandlesEnabled } from '../../lib/site-boundary'
100
111
  import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap'
112
+ import { cancelPendingZonePaint, focusedUnitNode, paintZoneMembership } from '../../lib/units'
101
113
  import { cn } from '../../lib/utils'
102
114
  import { snapBuildingLocalToWorldGrid } from '../../lib/world-grid-snap'
103
115
  import { subscribeNavigationSyncPose } from '../../store/navigation-sync-pose-store'
@@ -135,6 +147,7 @@ import {
135
147
  import { FloorplanSnapBeaconLayer } from '../editor-2d/floorplan-snap-beacon-layer'
136
148
  import { FloorplanWallMoveGhostLayer } from '../editor-2d/floorplan-wall-move-ghost-layer'
137
149
  import { FloorplanDraftLayer } from '../editor-2d/renderers/floorplan-draft-layer'
150
+ import { FloorplanDraftWallMeasurement } from '../editor-2d/renderers/floorplan-draft-wall-measurement'
138
151
  import { FloorplanGeometryRenderer } from '../editor-2d/renderers/floorplan-geometry-renderer'
139
152
  import { FloorplanMarqueeLayer } from '../editor-2d/renderers/floorplan-marquee-layer'
140
153
  import { FloorplanPlacementPreviewLayer } from '../editor-2d/renderers/floorplan-placement-preview-layer'
@@ -2318,168 +2331,6 @@ function buildDraftWall(levelId: string, start: WallPlanPoint, end: WallPlanPoin
2318
2331
  }
2319
2332
  }
2320
2333
 
2321
- type DraftWallMeasurement = {
2322
- lengthLabel: string
2323
- midpoint: WallPlanPoint
2324
- direction: WallPlanPoint
2325
- angleLabels: {
2326
- id: string
2327
- label: string
2328
- center: WallPlanPoint
2329
- radius: number
2330
- startAngle: number
2331
- endAngle: number
2332
- midAngle: number
2333
- }[]
2334
- }
2335
-
2336
- function FloorplanDraftWallMeasurement({
2337
- measurement,
2338
- measurementStroke,
2339
- labelBackground,
2340
- labelText,
2341
- sceneRotationDeg,
2342
- unitsPerPixel,
2343
- }: {
2344
- measurement: DraftWallMeasurement
2345
- measurementStroke: string
2346
- labelBackground: string
2347
- labelText: string
2348
- sceneRotationDeg: number
2349
- unitsPerPixel: number
2350
- }) {
2351
- const stroke = measurementStroke
2352
- const labelBg = labelBackground
2353
-
2354
- const upx = unitsPerPixel
2355
- const fontSize = Math.max(upx * 10, 0.08)
2356
- const padX = upx * 6
2357
- const padY = upx * 3
2358
-
2359
- // Length plate: rotates to follow the wall direction, but flips 180°
2360
- // when its on-screen orientation would read upside-down (same trick as
2361
- // `floorplan-registry-layer.tsx` for dimension labels).
2362
- const wallAngleDeg =
2363
- (Math.atan2(measurement.direction[1], measurement.direction[0]) * 180) / Math.PI
2364
- let labelAngleDeg = wallAngleDeg
2365
- let screenDeg = wallAngleDeg + sceneRotationDeg
2366
- screenDeg = ((((screenDeg + 180) % 360) + 360) % 360) - 180
2367
- if (screenDeg > 90) labelAngleDeg -= 180
2368
- else if (screenDeg <= -90) labelAngleDeg += 180
2369
-
2370
- // Push the plate perpendicular to the wall so the dashed footprint
2371
- // stays visible underneath.
2372
- const perpX = -measurement.direction[1]
2373
- const perpY = measurement.direction[0]
2374
- const offset = upx * 18
2375
- const cx = measurement.midpoint[0] + perpX * offset
2376
- const cy = measurement.midpoint[1] + perpY * offset
2377
-
2378
- const lengthTextWidth = measurement.lengthLabel.length * upx * 6.2
2379
- const lengthPlateW = lengthTextWidth + padX * 2
2380
- const lengthPlateH = fontSize + padY * 2
2381
-
2382
- const arcSampleCount = 32
2383
-
2384
- return (
2385
- <g pointerEvents="none">
2386
- <g transform={`translate(${cx} ${cy}) rotate(${labelAngleDeg})`}>
2387
- <rect
2388
- fill={labelBg}
2389
- height={lengthPlateH}
2390
- opacity={0.92}
2391
- rx={upx * 3}
2392
- ry={upx * 3}
2393
- stroke={stroke}
2394
- strokeWidth={upx * 0.5}
2395
- vectorEffect="non-scaling-stroke"
2396
- width={lengthPlateW}
2397
- x={-lengthPlateW / 2}
2398
- y={-lengthPlateH / 2}
2399
- />
2400
- <text
2401
- dominantBaseline="middle"
2402
- fill={labelText}
2403
- fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
2404
- fontSize={fontSize}
2405
- fontWeight={600}
2406
- textAnchor="middle"
2407
- x={0}
2408
- y={0}
2409
- >
2410
- {measurement.lengthLabel}
2411
- </text>
2412
- </g>
2413
-
2414
- {measurement.angleLabels.map((arc) => {
2415
- // Sample the arc as a polyline — avoids the SVG arc command's
2416
- // sweep-flag direction quirks across negative/positive sweeps.
2417
- const points: string[] = []
2418
- for (let i = 0; i <= arcSampleCount; i += 1) {
2419
- const t = i / arcSampleCount
2420
- const a = arc.startAngle + (arc.endAngle - arc.startAngle) * t
2421
- const px = arc.center[0] + Math.cos(a) * arc.radius
2422
- const py = arc.center[1] + Math.sin(a) * arc.radius
2423
- points.push(`${px},${py}`)
2424
- }
2425
-
2426
- const aFontSize = Math.max(upx * 9, 0.075)
2427
- const aPadX = upx * 5
2428
- const aPadY = upx * 2.5
2429
- const aTextWidth = arc.label.length * upx * 6.2
2430
- const aPlateW = aTextWidth + aPadX * 2
2431
- const aPlateH = aFontSize + aPadY * 2
2432
-
2433
- const labelDist = arc.radius + upx * 16
2434
- const lx = arc.center[0] + Math.cos(arc.midAngle) * labelDist
2435
- const ly = arc.center[1] + Math.sin(arc.midAngle) * labelDist
2436
-
2437
- return (
2438
- <g key={`draft-angle-${arc.id}`}>
2439
- <polyline
2440
- fill="none"
2441
- points={points.join(' ')}
2442
- stroke={stroke}
2443
- strokeLinecap="round"
2444
- strokeLinejoin="round"
2445
- strokeOpacity={0.95}
2446
- strokeWidth={upx * 1.2}
2447
- vectorEffect="non-scaling-stroke"
2448
- />
2449
- <g transform={`translate(${lx} ${ly})`}>
2450
- <rect
2451
- fill={labelBg}
2452
- height={aPlateH}
2453
- opacity={0.92}
2454
- rx={upx * 3}
2455
- ry={upx * 3}
2456
- stroke={stroke}
2457
- strokeWidth={upx * 0.5}
2458
- vectorEffect="non-scaling-stroke"
2459
- width={aPlateW}
2460
- x={-aPlateW / 2}
2461
- y={-aPlateH / 2}
2462
- />
2463
- <text
2464
- dominantBaseline="middle"
2465
- fill={labelText}
2466
- fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
2467
- fontSize={aFontSize}
2468
- fontWeight={600}
2469
- textAnchor="middle"
2470
- x={0}
2471
- y={0}
2472
- >
2473
- {arc.label}
2474
- </text>
2475
- </g>
2476
- </g>
2477
- )
2478
- })}
2479
- </g>
2480
- )
2481
- }
2482
-
2483
2334
  function pointsEqual(a: WallPlanPoint, b: WallPlanPoint): boolean {
2484
2335
  return a[0] === b[0] && a[1] === b[1]
2485
2336
  }
@@ -4568,7 +4419,7 @@ function FloorplanDraftCursorLayer({
4568
4419
  )}
4569
4420
 
4570
4421
  {cursorPoint && (
4571
- <g>
4422
+ <g pointerEvents="none">
4572
4423
  <circle
4573
4424
  cx={toSvgX(cursorPoint[0])}
4574
4425
  cy={toSvgY(cursorPoint[1])}
@@ -4678,6 +4529,12 @@ function FloorplanLinearDraftLayer({
4678
4529
  const fenceDraftEnd = useFloorplanDraftPreview((s) => s.fenceDraftEnd)
4679
4530
  const roofDraftEnd = useFloorplanDraftPreview((s) => s.roofDraftEnd)
4680
4531
  const roofDraftQuarterTurn = useFloorplanDraftPreview((s) => s.roofDraftQuarterTurn)
4532
+ const wallRectangleDraftStart = useFloorplanDraftPreview((s) => s.wallRectangleDraftStart)
4533
+ // The live cursor is the rectangle's opposite corner; select it only while a
4534
+ // rectangle draft is open so idle moves don't re-render this layer.
4535
+ const wallRectangleDraftEnd = useFloorplanDraftPreview((s) =>
4536
+ s.wallRectangleDraftStart ? s.cursorPoint : null,
4537
+ )
4681
4538
 
4682
4539
  const draftPolygon = useMemo(() => {
4683
4540
  if (
@@ -4695,6 +4552,53 @@ function FloorplanLinearDraftLayer({
4695
4552
  return getWallPlanFootprint(draftWall, EMPTY_WALL_MITER_DATA)
4696
4553
  }, [levelId, wallDraftStart, wallDraftEnd])
4697
4554
 
4555
+ // Rectangle mode drafts four walls at once; they read exactly like a line
4556
+ // draft — same mitered footprints, measurement plates and axis guides.
4557
+ const rectangleDraft = useMemo(() => {
4558
+ if (!(levelId && isWallBuildActive && wallRectangleDraftStart && wallRectangleDraftEnd)) {
4559
+ return null
4560
+ }
4561
+ const corners = wallRectangleCorners(wallRectangleDraftStart, wallRectangleDraftEnd)
4562
+ if (corners.length !== 4) return null
4563
+ const draftWalls = corners.map((start, index) =>
4564
+ getSharedFloorplanWall({
4565
+ ...buildDraftWall(levelId, start, corners[(index + 1) % 4]!),
4566
+ id: `wall_draft_${index}` as WallNode['id'],
4567
+ }),
4568
+ )
4569
+ const miterData = calculateLevelMiters(draftWalls)
4570
+ const polygons = draftWalls.map((wall) =>
4571
+ formatPolygonPoints(getWallPlanFootprint(wall, miterData)),
4572
+ )
4573
+ // Measure the two sides meeting at the cursor corner. Corners wind
4574
+ // counter-clockwise, so each side is measured end → start to push its
4575
+ // plate outside the rectangle.
4576
+ const cursorIndex = corners.findIndex(
4577
+ ([x, z]) => x === wallRectangleDraftEnd[0] && z === wallRectangleDraftEnd[1],
4578
+ )
4579
+ const measurements = [cursorIndex - 1, cursorIndex].map((side) => {
4580
+ const from = corners[(side + 5) % 4]!
4581
+ const to = corners[(side + 4) % 4]!
4582
+ const dx = to[0] - from[0]
4583
+ const dy = to[1] - from[1]
4584
+ const length = Math.hypot(dx, dy)
4585
+ return {
4586
+ lengthLabel: formatMeasurement(length, unit, null, metricNotation),
4587
+ midpoint: [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2] as WallPlanPoint,
4588
+ direction: [dx / length, dy / length] as WallPlanPoint,
4589
+ angleLabels: [],
4590
+ }
4591
+ })
4592
+ return { polygons, measurements }
4593
+ }, [
4594
+ isWallBuildActive,
4595
+ levelId,
4596
+ metricNotation,
4597
+ unit,
4598
+ wallRectangleDraftEnd,
4599
+ wallRectangleDraftStart,
4600
+ ])
4601
+
4698
4602
  const draftPolygonPoints = useMemo(() => {
4699
4603
  if (isRoofBuildActive && roofDraftStart && roofDraftEnd) {
4700
4604
  const minX = Math.min(roofDraftStart[0], roofDraftEnd[0])
@@ -4862,6 +4766,10 @@ function FloorplanLinearDraftLayer({
4862
4766
  if (isWallBuildActive && wallDraftStart && wallDraftEnd) {
4863
4767
  pushDraft(wallDraftStart, wallDraftEnd)
4864
4768
  }
4769
+ if (isWallBuildActive && wallRectangleDraftStart && wallRectangleDraftEnd) {
4770
+ pushCross(wallRectangleDraftStart)
4771
+ pushCross(wallRectangleDraftEnd)
4772
+ }
4865
4773
  if (isFenceBuildActive && fenceDraftStart && fenceDraftEnd) {
4866
4774
  pushDraft(fenceDraftStart, fenceDraftEnd)
4867
4775
  }
@@ -4873,6 +4781,8 @@ function FloorplanLinearDraftLayer({
4873
4781
  fenceDraftStart,
4874
4782
  wallDraftEnd,
4875
4783
  wallDraftStart,
4784
+ wallRectangleDraftEnd,
4785
+ wallRectangleDraftStart,
4876
4786
  ])
4877
4787
 
4878
4788
  return (
@@ -4930,6 +4840,34 @@ function FloorplanLinearDraftLayer({
4930
4840
  unitsPerPixel={unitsPerPixel}
4931
4841
  />
4932
4842
  )}
4843
+
4844
+ {rectangleDraft?.polygons.map((points, index) => (
4845
+ <FloorplanDraftLayer
4846
+ anchorFill={draftStroke}
4847
+ draftAnchorPoints={EMPTY_DRAFT_ANCHOR_POINTS}
4848
+ draftFill={draftFill}
4849
+ draftPolygonPoints={points}
4850
+ draftStroke={draftStroke}
4851
+ key={index}
4852
+ linearDraftSegment={null}
4853
+ polygonDraftClosingSegment={null}
4854
+ polygonDraftPolygonPoints={null}
4855
+ polygonDraftPolylinePoints={null}
4856
+ unitsPerPixel={unitsPerPixel}
4857
+ />
4858
+ ))}
4859
+
4860
+ {rectangleDraft?.measurements.map((measurement, index) => (
4861
+ <FloorplanDraftWallMeasurement
4862
+ key={index}
4863
+ labelBackground={isDark ? '#0f172a' : '#ffffff'}
4864
+ labelText={isDark ? '#e2e8f0' : '#171717'}
4865
+ measurement={measurement}
4866
+ measurementStroke={measurementStroke}
4867
+ sceneRotationDeg={sceneRotationDeg}
4868
+ unitsPerPixel={unitsPerPixel}
4869
+ />
4870
+ ))}
4933
4871
  </>
4934
4872
  )
4935
4873
  }
@@ -5034,6 +4972,7 @@ export function FloorplanPanel({
5034
4972
  // the user closes and re-opens the 2D editor instead of restoring the
5035
4973
  // stale viewport from before they closed it.
5036
4974
  const isFloorplanOpen = useEditor((state) => state.isFloorplanOpen)
4975
+ const viewMode = useEditor((state) => state.viewMode)
5037
4976
  // Mirror for callbacks that fire outside React's render (the per-frame
5038
4977
  // navigation-pose subscriber): when the 2D panel is hidden (`display:none` in
5039
4978
  // 3D mode) it must NOT re-render on every camera-zoom frame.
@@ -7782,6 +7721,116 @@ export function FloorplanPanel({
7782
7721
  [publishFloorplanNavigationPose],
7783
7722
  )
7784
7723
 
7724
+ // WASD and the orbit buttons in 2D-only view. The 3D canvas is paused there
7725
+ // (`renderPaused`), so the plan drives them itself — same keys and speed as
7726
+ // the camera — and hands the pose to 3D when the move ends.
7727
+ const keyboardPanKeysRef = useRef<KeyboardPanState>({
7728
+ forward: false,
7729
+ backward: false,
7730
+ left: false,
7731
+ right: false,
7732
+ })
7733
+ useEffect(() => {
7734
+ if (viewMode !== '2d') return
7735
+ const keys = keyboardPanKeysRef.current
7736
+ let frame: number | null = null
7737
+ let lastTime: number | null = null
7738
+ const sceneRotationDeg = (userRotationDeg: number) =>
7739
+ FLOORPLAN_VIEW_ROTATION_DEG + userRotationDeg - buildingRotationDeg
7740
+ const step = (time: number) => {
7741
+ const { horizontal, vertical } = keyboardPanDirection(keys)
7742
+ const viewport = latestViewportRef.current ?? latestFittedViewportRef.current
7743
+ if ((horizontal === 0 && vertical === 0) || !viewport) {
7744
+ frame = null
7745
+ lastTime = null
7746
+ commitFloorplanPan()
7747
+ return
7748
+ }
7749
+ const elapsed = lastTime === null ? 0 : Math.min((time - lastTime) / 1000, 0.05)
7750
+ lastTime = time
7751
+ const distance =
7752
+ (keyboardPanSpeed(viewport.width) * elapsed) / Math.hypot(horizontal, vertical)
7753
+ const next = {
7754
+ centerX: viewport.centerX + horizontal * distance,
7755
+ centerY: viewport.centerY - vertical * distance,
7756
+ width: viewport.width,
7757
+ }
7758
+ const userRotationDeg = latestFloorplanUserRotationDegRef.current
7759
+ floorplanViewportInteractionInProgressRef.current = true
7760
+ applyFloorplanViewportImperatively(next)
7761
+ floorplanPanPoseRef.current = {
7762
+ localCenter: rotateSvgPoint(
7763
+ { x: next.centerX, y: next.centerY },
7764
+ -sceneRotationDeg(userRotationDeg),
7765
+ ),
7766
+ userRotationDeg,
7767
+ viewWidth: next.width,
7768
+ }
7769
+ frame = requestAnimationFrame(step)
7770
+ }
7771
+ const start = () => {
7772
+ if (frame === null) frame = requestAnimationFrame(step)
7773
+ }
7774
+ const onKeyDown = (event: KeyboardEvent) => {
7775
+ if (!(isKeyboardPanKey(event.code) && acceptsKeyboardPan(event))) return
7776
+ if (setKeyboardPanKey(keys, event.code, true)) start()
7777
+ event.preventDefault()
7778
+ event.stopPropagation()
7779
+ }
7780
+ const onKeyUp = (event: KeyboardEvent) => {
7781
+ if (isKeyboardPanKey(event.code) && setKeyboardPanKey(keys, event.code, false)) {
7782
+ event.preventDefault()
7783
+ event.stopPropagation()
7784
+ }
7785
+ }
7786
+ const onBlur = () => clearKeyboardPanKeys(keys)
7787
+ // Same rule as the 3D buttons: snap to the nearest quarter turn, then turn one more.
7788
+ const orbit = (clockwise: boolean) => {
7789
+ const viewport = latestViewportRef.current ?? latestFittedViewportRef.current
7790
+ if (!viewport) return
7791
+ const userRotationDeg = latestFloorplanUserRotationDegRef.current
7792
+ const quarter = Math.PI / 2
7793
+ const azimuth =
7794
+ Math.round(cameraAzimuthFromFloorplanRotation(userRotationDeg) / quarter) * quarter +
7795
+ (clockwise ? -quarter : quarter)
7796
+ const nextRotationDeg = floorplanRotationFromCameraAzimuth(azimuth, userRotationDeg)
7797
+ const localCenter = rotateSvgPoint(
7798
+ { x: viewport.centerX, y: viewport.centerY },
7799
+ -sceneRotationDeg(userRotationDeg),
7800
+ )
7801
+ smoothFloorplanNavigationView(localCenter, nextRotationDeg, viewport.width)
7802
+ publishFloorplanNavigationPose(localCenter, nextRotationDeg, viewport.width)
7803
+ }
7804
+ const orbitClockwise = () => orbit(true)
7805
+ const orbitCounterClockwise = () => orbit(false)
7806
+ document.addEventListener('keydown', onKeyDown)
7807
+ document.addEventListener('keyup', onKeyUp)
7808
+ window.addEventListener('blur', onBlur)
7809
+ emitter.on('camera-controls:orbit-cw', orbitClockwise)
7810
+ emitter.on('camera-controls:orbit-ccw', orbitCounterClockwise)
7811
+ return () => {
7812
+ document.removeEventListener('keydown', onKeyDown)
7813
+ document.removeEventListener('keyup', onKeyUp)
7814
+ window.removeEventListener('blur', onBlur)
7815
+ emitter.off('camera-controls:orbit-cw', orbitClockwise)
7816
+ emitter.off('camera-controls:orbit-ccw', orbitCounterClockwise)
7817
+ // A key released while 3D owns the keys would otherwise still read as
7818
+ // held when the plan takes them back; the camera clears its own the same way.
7819
+ clearKeyboardPanKeys(keys)
7820
+ if (frame !== null) {
7821
+ cancelAnimationFrame(frame)
7822
+ commitFloorplanPan()
7823
+ }
7824
+ }
7825
+ }, [
7826
+ applyFloorplanViewportImperatively,
7827
+ buildingRotationDeg,
7828
+ commitFloorplanPan,
7829
+ publishFloorplanNavigationPose,
7830
+ smoothFloorplanNavigationView,
7831
+ viewMode,
7832
+ ])
7833
+
7785
7834
  useLayoutEffect(() => {
7786
7835
  flushFloorplanRotationPresentationRestore(pendingFloorplanRotationRestoreRef)
7787
7836
  })
@@ -7952,7 +8001,7 @@ export function FloorplanPanel({
7952
8001
  return null
7953
8002
  }
7954
8003
 
7955
- const { createNode, nodes } = useScene.getState()
8004
+ const { createNode, updateNode, nodes } = useScene.getState()
7956
8005
  const zoneCount = Object.values(nodes).filter((node) => node.type === 'zone').length
7957
8006
  const zone = ZoneNodeSchema.parse({
7958
8007
  color: PALETTE_COLORS[zoneCount % PALETTE_COLORS.length],
@@ -7960,9 +8009,15 @@ export function FloorplanPanel({
7960
8009
  polygon: points.map(([x, z]) => [x, z] as [number, number]),
7961
8010
  })
7962
8011
 
7963
- createNode(zone, levelId)
8012
+ // Joining the focused unit rides in the zone's own undo step; selecting
8013
+ // the zone would end focus, so a painted unit keeps it instead.
8014
+ const focusedUnit = focusedUnitNode()
8015
+ runAsSingleSceneHistoryStep(useScene, () => {
8016
+ createNode(zone, levelId)
8017
+ if (focusedUnit) updateNode(focusedUnit.id, { members: [...focusedUnit.members, zone.id] })
8018
+ })
7964
8019
  sfxEmitter.emit('sfx:structure-build')
7965
- setSelection({ zoneId: zone.id })
8020
+ if (!focusedUnit) setSelection({ zoneId: zone.id })
7966
8021
  return zone.id
7967
8022
  },
7968
8023
  [levelId, setSelection],
@@ -10008,6 +10063,18 @@ export function FloorplanPanel({
10008
10063
  setSelectedReferenceId(null)
10009
10064
 
10010
10065
  if (backgroundSelection.kind === 'select-zone') {
10066
+ // Unit focus: a click paints membership, a double-click selects the
10067
+ // zone and keeps focus (the SVG gets the second click as detail 2).
10068
+ const focusedUnitId = useViewer.getState().focusedUnitId
10069
+ if (focusedUnitId) {
10070
+ if (event.detail >= 2) {
10071
+ cancelPendingZonePaint(backgroundSelection.zoneId)
10072
+ setSelection({ zoneId: backgroundSelection.zoneId })
10073
+ } else {
10074
+ paintZoneMembership(focusedUnitId, backgroundSelection.zoneId)
10075
+ }
10076
+ return
10077
+ }
10011
10078
  setSelection({ zoneId: backgroundSelection.zoneId })
10012
10079
  return
10013
10080
  }
@@ -1,7 +1,12 @@
1
1
  'use client'
2
2
 
3
3
  import { type AnyNodeId, emitter, type GridEvent, sceneRegistry } from '@pascal-app/core'
4
- import { GRID_LAYER, getSceneTheme, useViewer } from '@pascal-app/viewer'
4
+ import {
5
+ GRID_LAYER,
6
+ getSceneTheme,
7
+ useImmersiveXRPresentation,
8
+ useViewer,
9
+ } from '@pascal-app/viewer'
5
10
  import { useFrame } from '@react-three/fiber'
6
11
  import { useEffect, useMemo, useRef, useState } from 'react'
7
12
  import { DoubleSide, type Mesh, PlaneGeometry, Quaternion, Vector2, Vector3 } from 'three'
@@ -10,9 +15,12 @@ import { MeshBasicNodeMaterial } from 'three/webgpu'
10
15
  import { useCeilingEvents } from '../../hooks/use-ceiling-events'
11
16
  import { useGridEvents } from '../../hooks/use-grid-events'
12
17
  import { getPlacementSurface, usesOrientedPlacementPlane } from '../../lib/active-placement-surface'
13
- import useEditor, { isGridSnapActive } from '../../store/use-editor'
18
+ import { gridLocalNormal, gridLocalPoint } from '../../lib/grid-frame'
19
+ import useEditor, { getActiveSnapContext, isGridSnapActive } from '../../store/use-editor'
14
20
  import { getMovingNode } from '../../store/use-interaction-scope'
15
21
 
22
+ export const EDITOR_GRID_INPUT_NAME = 'pascal-editor-grid-input'
23
+
16
24
  // Reveal radius (m) of the cursor-local grid patch shown while placing/moving in
17
25
  // grid-snap mode — much tighter than the idle reveal so only the area you're
18
26
  // about to snap into lights up.
@@ -45,6 +53,7 @@ export const Grid = ({
45
53
  fadeStrength?: number
46
54
  revealRadius?: number
47
55
  }) => {
56
+ const immersive = useImmersiveXRPresentation()
48
57
  const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark')
49
58
 
50
59
  // Use slightly lighter colors for dark themes' grid to make it apparent
@@ -60,6 +69,9 @@ export const Grid = ({
60
69
  const wallCursorRef = useRef(new Vector3())
61
70
  // Scratch for deriving a moving wall-item's host normal from its mesh.
62
71
  const wallNormalRef = useRef(new Vector3())
72
+ const localSurfaceRef = useRef(new Vector3())
73
+ const localNormalRef = useRef(new Vector3())
74
+ const localCursorRef = useRef(new Vector3())
63
75
  // Last Y pushed to `gridY` state, so the per-frame surface follow only triggers
64
76
  // a React re-render when the height actually changes (not every frame).
65
77
  const lastGridYRef = useRef<number | null>(null)
@@ -176,10 +188,11 @@ export const Grid = ({
176
188
  // laid-flat orientation maps `positionLocal.y` to world `-Z` relative to the
177
189
  // mesh origin. The cursor is recomputed every frame from the stored world hit
178
190
  // so the reveal stays put regardless of where the mesh origin sits.
179
- const lastWorldCursorRef = useRef<{ x: number; z: number } | null>(null)
191
+ const lastWorldCursorRef = useRef<Vector3 | null>(null)
180
192
  useEffect(() => {
181
193
  const onGridMove = (event: GridEvent) => {
182
- lastWorldCursorRef.current = { x: event.position[0], z: event.position[2] }
194
+ lastWorldCursorRef.current ??= new Vector3()
195
+ lastWorldCursorRef.current.set(...event.position)
183
196
  }
184
197
 
185
198
  emitter.on('grid:move', onGridMove)
@@ -194,7 +207,12 @@ export const Grid = ({
194
207
  if (levelId) {
195
208
  const levelMesh = sceneRegistry.nodes.get(levelId)
196
209
  if (levelMesh) {
197
- levelY = levelMesh.position.y
210
+ levelMesh.getWorldPosition(worldPosRef.current)
211
+ levelY = gridLocalPoint(
212
+ gridRef.current.parent,
213
+ worldPosRef.current,
214
+ localSurfaceRef.current,
215
+ ).y
198
216
  }
199
217
  }
200
218
 
@@ -229,6 +247,10 @@ export const Grid = ({
229
247
  }
230
248
 
231
249
  const gridMesh = gridRef.current
250
+ if (surfacePoint) {
251
+ surfacePoint = gridLocalPoint(gridMesh.parent, surfacePoint, localSurfaceRef.current)
252
+ surfaceNormal = gridLocalNormal(gridMesh.parent, surfaceNormal, localNormalRef.current)
253
+ }
232
254
  const onOrientedPlane = surfacePoint != null && usesOrientedPlacementPlane(surfaceNormal)
233
255
  if (onOrientedPlane && surfacePoint) {
234
256
  // Surface-anchored lattice: orient the plane into the host and pin the mesh to
@@ -269,7 +291,8 @@ export const Grid = ({
269
291
  gridMesh.quaternion.copy(HORIZONTAL_QUATERNION)
270
292
  const world = lastWorldCursorRef.current
271
293
  if (world) {
272
- cursorPositionRef.current.set(world.x, -world.z)
294
+ const local = gridLocalPoint(gridMesh.parent, world, localCursorRef.current)
295
+ cursorPositionRef.current.set(local.x, -local.z)
273
296
  }
274
297
  if (lastGridYRef.current !== targetY) {
275
298
  lastGridYRef.current = targetY
@@ -293,7 +316,7 @@ export const Grid = ({
293
316
  // from the interaction scope OR the armed build tool and is true only when
294
317
  // that context resolves to grid, so it IS the gate. (Previously this also
295
318
  // required a ghost in flight, so a merely-armed draft tool showed nothing.)
296
- const snapPatchVisible = isGridSnapActive()
319
+ const snapPatchVisible = isGridSnapActive() || (immersive && getActiveSnapContext() !== null)
297
320
  revealRadiusUniform.value = PLACEMENT_REVEAL_RADIUS
298
321
  baseAlphaUniform.value = 0
299
322
  cellSizeUniform.value = useEditor.getState().gridSnapStep
@@ -320,6 +343,7 @@ export const Grid = ({
320
343
  geometry={geometry}
321
344
  layers={GRID_LAYER}
322
345
  material={material}
346
+ name={EDITOR_GRID_INPUT_NAME}
323
347
  ref={gridRef}
324
348
  renderOrder={1}
325
349
  />
@@ -39,7 +39,6 @@ import {
39
39
  classifyParticipant,
40
40
  collectParticipants,
41
41
  computeGroupBox,
42
- expandToComponent,
43
42
  groupPlanBounds,
44
43
  levelFrame,
45
44
  planBoundsCenter,
@@ -83,8 +82,8 @@ export function canGroupPickUp(): boolean {
83
82
  * until a click commits, mirroring the single-node `movingNode` flow. Returns
84
83
  * false when the selection holds no transformable participants.
85
84
  *
86
- * `scopeToSelection` limits the moving set to the selected participants —
87
- * no connected-component expansion and no welded-neighbor endpoints. The
85
+ * `scopeToSelection` drops the welded-neighbor endpoints, so connected walls
86
+ * don't stretch along with the move. The
88
87
  * Duplicate flow needs this: its clones sit EXACTLY on the originals, so
89
88
  * junction coincidence would otherwise weld the originals into the pick-up
90
89
  * and drag them along with the copies.
@@ -96,10 +95,7 @@ export function startGroupPickUp(
96
95
  const participantIds = groupParticipantIds()
97
96
  if (participantIds.length === 0) return false
98
97
  const nodes = useScene.getState().nodes
99
- const fullIds = opts.scopeToSelection
100
- ? participantIds
101
- : expandToComponent(participantIds, nodes, levelId)
102
- const collected = collectParticipants(fullIds, nodes, levelId)
98
+ const collected = collectParticipants(participantIds, nodes, levelId)
103
99
  // Mutable: mid-carry R/T rotates these snapshots in place.
104
100
  let starts = collected.starts
105
101
  let links = opts.scopeToSelection ? [] : collected.links
@@ -107,8 +103,8 @@ export function startGroupPickUp(
107
103
  const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)]
108
104
 
109
105
  const { inverse: frameInv } = levelFrame(levelId)
110
- const restBox = computeGroupBox(fullIds)
111
- const startBounds = groupPlanBounds(restBox, starts, frameInv)
106
+ const restBox = computeGroupBox(participantIds)
107
+ const startBounds = groupPlanBounds(starts, frameInv)
112
108
  if (!startBounds) return false
113
109
  // Mutable: mid-carry R/T re-seeds the footprint around the same pivot.
114
110
  let restBounds = startBounds