@pascal-app/editor 1.0.1 → 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 (111) hide show
  1. package/package.json +10 -5
  2. package/src/components/editor/custom-camera-controls.tsx +22 -74
  3. package/src/components/editor/editor-layout-mobile.tsx +1 -0
  4. package/src/components/editor/floorplan-panel.tsx +210 -163
  5. package/src/components/editor/grid.tsx +31 -7
  6. package/src/components/editor/group-actions.ts +5 -9
  7. package/src/components/editor/group-floating-action-menu.tsx +19 -9
  8. package/src/components/editor/group-move-3d.ts +4 -6
  9. package/src/components/editor/group-rotate-handle.tsx +81 -35
  10. package/src/components/editor/group-selection-box-3d.tsx +25 -17
  11. package/src/components/editor/group-transform-shared.test.ts +126 -1
  12. package/src/components/editor/group-transform-shared.ts +79 -73
  13. package/src/components/editor/handles/handle-arrow.tsx +72 -2
  14. package/src/components/editor/handles/use-handle-drag.ts +75 -6
  15. package/src/components/editor/index.tsx +24 -9
  16. package/src/components/editor/node-action-menu.tsx +2 -0
  17. package/src/components/editor/node-arrow-handles.tsx +141 -7
  18. package/src/components/editor/registry-action-contributions.tsx +53 -0
  19. package/src/components/editor/wall-move-side-handles.tsx +71 -21
  20. package/src/components/editor/wall-opening-highlights.tsx +2 -3
  21. package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
  22. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
  23. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +154 -49
  24. package/src/components/editor-2d/renderers/floorplan-draft-wall-measurement.tsx +166 -0
  25. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +49 -0
  26. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +67 -21
  27. package/src/components/systems/zone/zone-system.tsx +8 -4
  28. package/src/components/tools/__fixtures__/hosting.ts +10 -0
  29. package/src/components/tools/item/item-surface-placement.test.ts +3 -9
  30. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +3 -9
  31. package/src/components/tools/item/placement-strategies.ts +55 -43
  32. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +3 -3
  33. package/src/components/tools/item/use-draft-node.ts +41 -2
  34. package/src/components/tools/item/use-placement-coordinator.tsx +166 -73
  35. package/src/components/tools/registry/item-surface-move.test.ts +10 -2
  36. package/src/components/tools/registry/item-surface-move.ts +46 -24
  37. package/src/components/tools/registry/item-surface-resolver.test.ts +2 -2
  38. package/src/components/tools/registry/move-registry-node-tool.tsx +23 -9
  39. package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
  40. package/src/components/tools/shared/affordance-dispatch.ts +13 -0
  41. package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
  42. package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
  43. package/src/components/tools/shared/pointer-support-cap.ts +17 -5
  44. package/src/components/tools/shared/surface-rejection.test.tsx +5 -4
  45. package/src/components/tools/shared/surface-rejection.tsx +14 -0
  46. package/src/components/tools/tool-manager.tsx +36 -3
  47. package/src/components/ui/controls/material-paint-panel.tsx +6 -67
  48. package/src/components/ui/controls/material-picker.tsx +5 -67
  49. package/src/components/ui/controls/slider-control.tsx +76 -31
  50. package/src/components/ui/helpers/helper-manager.tsx +24 -6
  51. package/src/components/ui/helpers/registered-tool-helper.tsx +2 -32
  52. package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
  53. package/src/components/ui/panels/multi-height-mode.tsx +27 -21
  54. package/src/components/ui/panels/parametric-inspector.tsx +3 -15
  55. package/src/components/ui/primitives/shortcut-token.tsx +4 -0
  56. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
  57. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +585 -369
  58. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  59. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +1 -1
  60. package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
  61. package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
  62. package/src/components/viewer/floorplan-preview.tsx +1 -12
  63. package/src/components/viewer-zone-system.tsx +7 -2
  64. package/src/hooks/use-auto-save.integration.test.tsx +197 -0
  65. package/src/hooks/use-auto-save.ts +21 -6
  66. package/src/hooks/use-ceiling-events.test.ts +1 -0
  67. package/src/hooks/use-ceiling-events.ts +2 -0
  68. package/src/hooks/use-grid-events.test.ts +2 -0
  69. package/src/hooks/use-grid-events.ts +16 -11
  70. package/src/hooks/use-keyboard.test.ts +7 -0
  71. package/src/hooks/use-keyboard.ts +42 -29
  72. package/src/index.tsx +63 -3
  73. package/src/lib/catalog-panel-model.ts +39 -0
  74. package/src/lib/catalog-selection.test.ts +27 -0
  75. package/src/lib/catalog-selection.ts +8 -0
  76. package/src/lib/contextual-help.test.ts +27 -1
  77. package/src/lib/contextual-help.ts +16 -9
  78. package/src/lib/floorplan/floorplan-export.test.ts +3 -1
  79. package/src/lib/floorplan/floorplan-extension.ts +16 -0
  80. package/src/lib/fresh-planar-placement.ts +168 -36
  81. package/src/lib/glb-export.test.ts +59 -16
  82. package/src/lib/glb-export.ts +26 -13
  83. package/src/lib/grid-frame.test.ts +19 -0
  84. package/src/lib/grid-frame.ts +18 -0
  85. package/src/lib/interaction/scope.ts +9 -1
  86. package/src/lib/keyboard-pan.test.ts +62 -0
  87. package/src/lib/keyboard-pan.ts +89 -0
  88. package/src/lib/material-catalog-model.ts +72 -0
  89. package/src/lib/material-paint-panel-model.ts +76 -0
  90. package/src/lib/overlay-transform.test.ts +28 -0
  91. package/src/lib/overlay-transform.ts +12 -0
  92. package/src/lib/panel-rows.ts +75 -0
  93. package/src/lib/panel-tool-options.test.ts +34 -0
  94. package/src/lib/panel-tool-options.ts +46 -0
  95. package/src/lib/parametric-node-update.ts +37 -0
  96. package/src/lib/scene.test.ts +99 -0
  97. package/src/lib/scene.ts +21 -3
  98. package/src/lib/snapping-mode.test.ts +13 -2
  99. package/src/lib/snapping-mode.ts +11 -6
  100. package/src/lib/spatial-drag-plane.test.ts +53 -0
  101. package/src/lib/spatial-drag-plane.ts +19 -0
  102. package/src/lib/spatial-pointer-input.test.ts +72 -0
  103. package/src/lib/spatial-pointer-input.ts +62 -0
  104. package/src/lib/spatial-snap.test.ts +47 -0
  105. package/src/lib/terrain-panel-model.ts +117 -0
  106. package/src/lib/world-grid-snap.test.ts +48 -0
  107. package/src/lib/world-grid-snap.ts +9 -5
  108. package/src/store/rotation-snapping.test.ts +53 -0
  109. package/src/store/use-editor.tsx +10 -2
  110. package/src/store/use-floorplan-draft-preview.ts +10 -1
  111. package/src/store/use-interaction-scope.ts +155 -6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pascal-app/editor",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "Pascal building editor component",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -14,8 +14,8 @@
14
14
  "test": "bun test src"
15
15
  },
16
16
  "peerDependencies": {
17
- "@pascal-app/core": "^1.0.1",
18
- "@pascal-app/viewer": "^1.0.1",
17
+ "@pascal-app/core": "^1.0.3",
18
+ "@pascal-app/viewer": "^1.0.3",
19
19
  "@react-three/drei": "^10",
20
20
  "@react-three/fiber": "^9",
21
21
  "next": ">=15",
@@ -62,8 +62,8 @@
62
62
  "zustand": "^5.0.11"
63
63
  },
64
64
  "devDependencies": {
65
- "@pascal-app/core": "^1.0.1",
66
- "@pascal-app/viewer": "^1.0.1",
65
+ "@pascal-app/core": "^1.0.3",
66
+ "@pascal-app/viewer": "^1.0.3",
67
67
  "@pascal/typescript-config": "*",
68
68
  "@react-three/test-renderer": "^9.1.0",
69
69
  "@types/blob-stream": "^0.1.33",
@@ -75,5 +75,10 @@
75
75
  "@types/three": "^0.184.0",
76
76
  "fast-xml-parser": "^5.4.2",
77
77
  "typescript": "6.0.3"
78
+ },
79
+ "repository": {
80
+ "type": "git",
81
+ "url": "git+https://github.com/pascalorg/editor.git",
82
+ "directory": "packages/editor"
78
83
  }
79
84
  }
@@ -31,6 +31,17 @@ import {
31
31
  withCameraPoseDistance,
32
32
  } from '../../lib/camera-pose'
33
33
  import { EDITOR_LAYER } from '../../lib/constants'
34
+ import {
35
+ acceptsKeyboardPan,
36
+ clearKeyboardPanKeys,
37
+ hasKeyboardPanInput,
38
+ isEditableKeyboardTarget,
39
+ isKeyboardPanKey,
40
+ type KeyboardPanState,
41
+ keyboardPanDirection,
42
+ keyboardPanSpeed,
43
+ setKeyboardPanKey,
44
+ } from '../../lib/keyboard-pan'
34
45
  import { editorOwnsOneFingerDrag } from '../../lib/touch-gesture-priority'
35
46
  import { publishCameraPose } from '../../store/camera-pose-store'
36
47
  import useEditor from '../../store/use-editor'
@@ -51,11 +62,11 @@ const tempTarget = new Vector3()
51
62
  const transitionFreezePosition = new Vector3()
52
63
  const transitionFreezeTarget = new Vector3()
53
64
  const keyboardPanSpherical = new Spherical()
65
+ // In 2D-only view the canvas is paused, so the floor plan drives WASD, orbit and
66
+ // top view itself (`floorplan-panel.tsx`) and the camera stands down.
67
+ const planOwnsNavigation = () => useEditor.getState().viewMode === '2d'
54
68
  const DEFAULT_MAX_POLAR_ANGLE = Math.PI / 2 - 0.1
55
69
  const DEBUG_MAX_POLAR_ANGLE = Math.PI - 0.05
56
- const KEYBOARD_PAN_VIEW_WIDTH_PER_SECOND = 0.65
57
- const KEYBOARD_PAN_MIN_SPEED = 2
58
- const KEYBOARD_PAN_MAX_SPEED = 55
59
70
  type CameraMode = ReturnType<typeof useViewer.getState>['cameraMode']
60
71
  type CameraPoseSnapshot = {
61
72
  mode: CameraMode
@@ -114,54 +125,6 @@ function freezeCameraControlTransition(control: CameraControlsImpl) {
114
125
  )
115
126
  }
116
127
 
117
- function isEditableKeyboardTarget(target: EventTarget | null) {
118
- return (
119
- target instanceof HTMLInputElement ||
120
- target instanceof HTMLTextAreaElement ||
121
- target instanceof HTMLSelectElement ||
122
- (target instanceof HTMLElement && target.isContentEditable)
123
- )
124
- }
125
-
126
- type KeyboardPanState = {
127
- forward: boolean
128
- backward: boolean
129
- left: boolean
130
- right: boolean
131
- }
132
-
133
- function setKeyboardPanKey(state: KeyboardPanState, code: string, pressed: boolean): boolean {
134
- if (code === 'KeyW') {
135
- const changed = state.forward !== pressed
136
- state.forward = pressed
137
- return changed
138
- }
139
- if (code === 'KeyS') {
140
- const changed = state.backward !== pressed
141
- state.backward = pressed
142
- return changed
143
- }
144
- if (code === 'KeyA') {
145
- const changed = state.left !== pressed
146
- state.left = pressed
147
- return changed
148
- }
149
- if (code === 'KeyD') {
150
- const changed = state.right !== pressed
151
- state.right = pressed
152
- return changed
153
- }
154
- return false
155
- }
156
-
157
- function isKeyboardPanKey(code: string): boolean {
158
- return code === 'KeyW' || code === 'KeyA' || code === 'KeyS' || code === 'KeyD'
159
- }
160
-
161
- function hasKeyboardPanInput(state: KeyboardPanState): boolean {
162
- return state.forward || state.backward || state.left || state.right
163
- }
164
-
165
128
  type CameraViewportSize = {
166
129
  width: number
167
130
  height: number
@@ -667,19 +630,14 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
667
630
  }
668
631
  }
669
632
 
670
- const panKeys = keyboardPanKeys.current
671
- const horizontal = (panKeys.right ? 1 : 0) - (panKeys.left ? 1 : 0)
672
- const vertical = (panKeys.forward ? 1 : 0) - (panKeys.backward ? 1 : 0)
633
+ const { horizontal, vertical } = keyboardPanDirection(keyboardPanKeys.current)
673
634
  if (horizontal === 0 && vertical === 0) return
674
635
 
675
636
  const control = controls.current
676
637
 
677
638
  control.getSpherical(keyboardPanSpherical, false)
678
639
  const viewWidth = getCameraViewWidth(camera, keyboardPanSpherical.radius, viewportSize)
679
- const speed = Math.min(
680
- Math.max(viewWidth * KEYBOARD_PAN_VIEW_WIDTH_PER_SECOND, KEYBOARD_PAN_MIN_SPEED),
681
- KEYBOARD_PAN_MAX_SPEED,
682
- )
640
+ const speed = keyboardPanSpeed(viewWidth)
683
641
  const step = (speed * Math.min(delta, 0.05)) / Math.hypot(horizontal, vertical)
684
642
 
685
643
  if (horizontal !== 0) control.truck(horizontal * step, 0, true)
@@ -766,13 +724,6 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
766
724
  let panPointerId: number | null = null
767
725
  let panPointerButton: number | null = null
768
726
 
769
- const clearKeyboardPanKeys = () => {
770
- keyboardPanKeys.current.forward = false
771
- keyboardPanKeys.current.backward = false
772
- keyboardPanKeys.current.left = false
773
- keyboardPanKeys.current.right = false
774
- }
775
-
776
727
  const setNavigationCursor = (cursor: 'grab' | 'grabbing') => {
777
728
  document.body.style.cursor = cursor
778
729
  gl.domElement.style.cursor = cursor
@@ -835,10 +786,7 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
835
786
 
836
787
  const onKeyDown = (event: KeyboardEvent) => {
837
788
  if (isKeyboardPanKey(event.code)) {
838
- if (
839
- !(event.metaKey || event.ctrlKey || event.altKey) &&
840
- !isEditableKeyboardTarget(event.target)
841
- ) {
789
+ if (acceptsKeyboardPan(event) && !planOwnsNavigation()) {
842
790
  const changed = setKeyboardPanKey(keyboardPanKeys.current, event.code, true)
843
791
  if (changed) beginLocalCameraInteraction()
844
792
  event.preventDefault()
@@ -930,7 +878,7 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
930
878
 
931
879
  const onBlur = () => {
932
880
  keyState.space = false
933
- clearKeyboardPanKeys()
881
+ clearKeyboardPanKeys(keyboardPanKeys.current)
934
882
  panPointerId = null
935
883
  panPointerButton = null
936
884
  clearNavigationCursor()
@@ -955,7 +903,7 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
955
903
  window.removeEventListener('pointercancel', onPointerUp, true)
956
904
  window.removeEventListener('blur', onBlur)
957
905
  gl.domElement.removeEventListener('wheel', onWheel, true)
958
- clearKeyboardPanKeys()
906
+ clearKeyboardPanKeys(keyboardPanKeys.current)
959
907
  clearNavigationCursor()
960
908
  cameraDraggingLifecycle.end()
961
909
  }
@@ -1196,7 +1144,7 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
1196
1144
  }
1197
1145
 
1198
1146
  const handleTopView = () => {
1199
- if (isFirstPersonMode || !controls.current) return
1147
+ if (isFirstPersonMode || !controls.current || planOwnsNavigation()) return
1200
1148
 
1201
1149
  const currentPolarAngle = controls.current.polarAngle
1202
1150
 
@@ -1208,7 +1156,7 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
1208
1156
  }
1209
1157
 
1210
1158
  const handleOrbitCW = () => {
1211
- if (isFirstPersonMode || !controls.current) return
1159
+ if (isFirstPersonMode || !controls.current || planOwnsNavigation()) return
1212
1160
 
1213
1161
  const currentAzimuth = controls.current.azimuthAngle
1214
1162
  const currentPolar = controls.current.polarAngle
@@ -1220,7 +1168,7 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
1220
1168
  }
1221
1169
 
1222
1170
  const handleOrbitCCW = () => {
1223
- if (isFirstPersonMode || !controls.current) return
1171
+ if (isFirstPersonMode || !controls.current || planOwnsNavigation()) return
1224
1172
 
1225
1173
  const currentAzimuth = controls.current.azimuthAngle
1226
1174
  const currentPolar = controls.current.polarAngle
@@ -236,6 +236,7 @@ export function EditorLayoutMobile({
236
236
  <div className="relative h-full w-full overflow-hidden">{viewerContent}</div>
237
237
  {overlays && (
238
238
  <div
239
+ data-viewer-bounds
239
240
  className="pointer-events-none absolute inset-0 z-30"
240
241
  style={{ transform: 'translateZ(0)' }}
241
242
  >
@@ -55,6 +55,7 @@ import {
55
55
  type WindowNode,
56
56
  WindowNode as WindowNodeSchema,
57
57
  wallClosesRoom,
58
+ wallRectangleCorners,
58
59
  ZoneNode as ZoneNodeSchema,
59
60
  type ZoneNode as ZoneNodeType,
60
61
  } from '@pascal-app/core'
@@ -94,6 +95,15 @@ import { resolveGenericFloorplanGridEventPoint } from '../../lib/floorplan-grid-
94
95
  import type { EditorGridEvent } from '../../lib/grid-event-presentation'
95
96
  import { groundHeightAt } from '../../lib/ground-surface'
96
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'
97
107
  import { measurementHint, parseMeasurement } from '../../lib/measurement-parser'
98
108
  import { formatLinearMeasurement, linearUnitToMeters } from '../../lib/measurements'
99
109
  import { sfxEmitter } from '../../lib/sfx-bus'
@@ -137,6 +147,7 @@ import {
137
147
  import { FloorplanSnapBeaconLayer } from '../editor-2d/floorplan-snap-beacon-layer'
138
148
  import { FloorplanWallMoveGhostLayer } from '../editor-2d/floorplan-wall-move-ghost-layer'
139
149
  import { FloorplanDraftLayer } from '../editor-2d/renderers/floorplan-draft-layer'
150
+ import { FloorplanDraftWallMeasurement } from '../editor-2d/renderers/floorplan-draft-wall-measurement'
140
151
  import { FloorplanGeometryRenderer } from '../editor-2d/renderers/floorplan-geometry-renderer'
141
152
  import { FloorplanMarqueeLayer } from '../editor-2d/renderers/floorplan-marquee-layer'
142
153
  import { FloorplanPlacementPreviewLayer } from '../editor-2d/renderers/floorplan-placement-preview-layer'
@@ -2320,168 +2331,6 @@ function buildDraftWall(levelId: string, start: WallPlanPoint, end: WallPlanPoin
2320
2331
  }
2321
2332
  }
2322
2333
 
2323
- type DraftWallMeasurement = {
2324
- lengthLabel: string
2325
- midpoint: WallPlanPoint
2326
- direction: WallPlanPoint
2327
- angleLabels: {
2328
- id: string
2329
- label: string
2330
- center: WallPlanPoint
2331
- radius: number
2332
- startAngle: number
2333
- endAngle: number
2334
- midAngle: number
2335
- }[]
2336
- }
2337
-
2338
- function FloorplanDraftWallMeasurement({
2339
- measurement,
2340
- measurementStroke,
2341
- labelBackground,
2342
- labelText,
2343
- sceneRotationDeg,
2344
- unitsPerPixel,
2345
- }: {
2346
- measurement: DraftWallMeasurement
2347
- measurementStroke: string
2348
- labelBackground: string
2349
- labelText: string
2350
- sceneRotationDeg: number
2351
- unitsPerPixel: number
2352
- }) {
2353
- const stroke = measurementStroke
2354
- const labelBg = labelBackground
2355
-
2356
- const upx = unitsPerPixel
2357
- const fontSize = Math.max(upx * 10, 0.08)
2358
- const padX = upx * 6
2359
- const padY = upx * 3
2360
-
2361
- // Length plate: rotates to follow the wall direction, but flips 180°
2362
- // when its on-screen orientation would read upside-down (same trick as
2363
- // `floorplan-registry-layer.tsx` for dimension labels).
2364
- const wallAngleDeg =
2365
- (Math.atan2(measurement.direction[1], measurement.direction[0]) * 180) / Math.PI
2366
- let labelAngleDeg = wallAngleDeg
2367
- let screenDeg = wallAngleDeg + sceneRotationDeg
2368
- screenDeg = ((((screenDeg + 180) % 360) + 360) % 360) - 180
2369
- if (screenDeg > 90) labelAngleDeg -= 180
2370
- else if (screenDeg <= -90) labelAngleDeg += 180
2371
-
2372
- // Push the plate perpendicular to the wall so the dashed footprint
2373
- // stays visible underneath.
2374
- const perpX = -measurement.direction[1]
2375
- const perpY = measurement.direction[0]
2376
- const offset = upx * 18
2377
- const cx = measurement.midpoint[0] + perpX * offset
2378
- const cy = measurement.midpoint[1] + perpY * offset
2379
-
2380
- const lengthTextWidth = measurement.lengthLabel.length * upx * 6.2
2381
- const lengthPlateW = lengthTextWidth + padX * 2
2382
- const lengthPlateH = fontSize + padY * 2
2383
-
2384
- const arcSampleCount = 32
2385
-
2386
- return (
2387
- <g pointerEvents="none">
2388
- <g transform={`translate(${cx} ${cy}) rotate(${labelAngleDeg})`}>
2389
- <rect
2390
- fill={labelBg}
2391
- height={lengthPlateH}
2392
- opacity={0.92}
2393
- rx={upx * 3}
2394
- ry={upx * 3}
2395
- stroke={stroke}
2396
- strokeWidth={upx * 0.5}
2397
- vectorEffect="non-scaling-stroke"
2398
- width={lengthPlateW}
2399
- x={-lengthPlateW / 2}
2400
- y={-lengthPlateH / 2}
2401
- />
2402
- <text
2403
- dominantBaseline="middle"
2404
- fill={labelText}
2405
- fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
2406
- fontSize={fontSize}
2407
- fontWeight={600}
2408
- textAnchor="middle"
2409
- x={0}
2410
- y={0}
2411
- >
2412
- {measurement.lengthLabel}
2413
- </text>
2414
- </g>
2415
-
2416
- {measurement.angleLabels.map((arc) => {
2417
- // Sample the arc as a polyline — avoids the SVG arc command's
2418
- // sweep-flag direction quirks across negative/positive sweeps.
2419
- const points: string[] = []
2420
- for (let i = 0; i <= arcSampleCount; i += 1) {
2421
- const t = i / arcSampleCount
2422
- const a = arc.startAngle + (arc.endAngle - arc.startAngle) * t
2423
- const px = arc.center[0] + Math.cos(a) * arc.radius
2424
- const py = arc.center[1] + Math.sin(a) * arc.radius
2425
- points.push(`${px},${py}`)
2426
- }
2427
-
2428
- const aFontSize = Math.max(upx * 9, 0.075)
2429
- const aPadX = upx * 5
2430
- const aPadY = upx * 2.5
2431
- const aTextWidth = arc.label.length * upx * 6.2
2432
- const aPlateW = aTextWidth + aPadX * 2
2433
- const aPlateH = aFontSize + aPadY * 2
2434
-
2435
- const labelDist = arc.radius + upx * 16
2436
- const lx = arc.center[0] + Math.cos(arc.midAngle) * labelDist
2437
- const ly = arc.center[1] + Math.sin(arc.midAngle) * labelDist
2438
-
2439
- return (
2440
- <g key={`draft-angle-${arc.id}`}>
2441
- <polyline
2442
- fill="none"
2443
- points={points.join(' ')}
2444
- stroke={stroke}
2445
- strokeLinecap="round"
2446
- strokeLinejoin="round"
2447
- strokeOpacity={0.95}
2448
- strokeWidth={upx * 1.2}
2449
- vectorEffect="non-scaling-stroke"
2450
- />
2451
- <g transform={`translate(${lx} ${ly})`}>
2452
- <rect
2453
- fill={labelBg}
2454
- height={aPlateH}
2455
- opacity={0.92}
2456
- rx={upx * 3}
2457
- ry={upx * 3}
2458
- stroke={stroke}
2459
- strokeWidth={upx * 0.5}
2460
- vectorEffect="non-scaling-stroke"
2461
- width={aPlateW}
2462
- x={-aPlateW / 2}
2463
- y={-aPlateH / 2}
2464
- />
2465
- <text
2466
- dominantBaseline="middle"
2467
- fill={labelText}
2468
- fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
2469
- fontSize={aFontSize}
2470
- fontWeight={600}
2471
- textAnchor="middle"
2472
- x={0}
2473
- y={0}
2474
- >
2475
- {arc.label}
2476
- </text>
2477
- </g>
2478
- </g>
2479
- )
2480
- })}
2481
- </g>
2482
- )
2483
- }
2484
-
2485
2334
  function pointsEqual(a: WallPlanPoint, b: WallPlanPoint): boolean {
2486
2335
  return a[0] === b[0] && a[1] === b[1]
2487
2336
  }
@@ -4570,7 +4419,7 @@ function FloorplanDraftCursorLayer({
4570
4419
  )}
4571
4420
 
4572
4421
  {cursorPoint && (
4573
- <g>
4422
+ <g pointerEvents="none">
4574
4423
  <circle
4575
4424
  cx={toSvgX(cursorPoint[0])}
4576
4425
  cy={toSvgY(cursorPoint[1])}
@@ -4680,6 +4529,12 @@ function FloorplanLinearDraftLayer({
4680
4529
  const fenceDraftEnd = useFloorplanDraftPreview((s) => s.fenceDraftEnd)
4681
4530
  const roofDraftEnd = useFloorplanDraftPreview((s) => s.roofDraftEnd)
4682
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
+ )
4683
4538
 
4684
4539
  const draftPolygon = useMemo(() => {
4685
4540
  if (
@@ -4697,6 +4552,53 @@ function FloorplanLinearDraftLayer({
4697
4552
  return getWallPlanFootprint(draftWall, EMPTY_WALL_MITER_DATA)
4698
4553
  }, [levelId, wallDraftStart, wallDraftEnd])
4699
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
+
4700
4602
  const draftPolygonPoints = useMemo(() => {
4701
4603
  if (isRoofBuildActive && roofDraftStart && roofDraftEnd) {
4702
4604
  const minX = Math.min(roofDraftStart[0], roofDraftEnd[0])
@@ -4864,6 +4766,10 @@ function FloorplanLinearDraftLayer({
4864
4766
  if (isWallBuildActive && wallDraftStart && wallDraftEnd) {
4865
4767
  pushDraft(wallDraftStart, wallDraftEnd)
4866
4768
  }
4769
+ if (isWallBuildActive && wallRectangleDraftStart && wallRectangleDraftEnd) {
4770
+ pushCross(wallRectangleDraftStart)
4771
+ pushCross(wallRectangleDraftEnd)
4772
+ }
4867
4773
  if (isFenceBuildActive && fenceDraftStart && fenceDraftEnd) {
4868
4774
  pushDraft(fenceDraftStart, fenceDraftEnd)
4869
4775
  }
@@ -4875,6 +4781,8 @@ function FloorplanLinearDraftLayer({
4875
4781
  fenceDraftStart,
4876
4782
  wallDraftEnd,
4877
4783
  wallDraftStart,
4784
+ wallRectangleDraftEnd,
4785
+ wallRectangleDraftStart,
4878
4786
  ])
4879
4787
 
4880
4788
  return (
@@ -4932,6 +4840,34 @@ function FloorplanLinearDraftLayer({
4932
4840
  unitsPerPixel={unitsPerPixel}
4933
4841
  />
4934
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
+ ))}
4935
4871
  </>
4936
4872
  )
4937
4873
  }
@@ -5036,6 +4972,7 @@ export function FloorplanPanel({
5036
4972
  // the user closes and re-opens the 2D editor instead of restoring the
5037
4973
  // stale viewport from before they closed it.
5038
4974
  const isFloorplanOpen = useEditor((state) => state.isFloorplanOpen)
4975
+ const viewMode = useEditor((state) => state.viewMode)
5039
4976
  // Mirror for callbacks that fire outside React's render (the per-frame
5040
4977
  // navigation-pose subscriber): when the 2D panel is hidden (`display:none` in
5041
4978
  // 3D mode) it must NOT re-render on every camera-zoom frame.
@@ -7784,6 +7721,116 @@ export function FloorplanPanel({
7784
7721
  [publishFloorplanNavigationPose],
7785
7722
  )
7786
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
+
7787
7834
  useLayoutEffect(() => {
7788
7835
  flushFloorplanRotationPresentationRestore(pendingFloorplanRotationRestoreRef)
7789
7836
  })