@pascal-app/editor 1.0.7 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/package.json +7 -7
  2. package/src/components/editor/custom-camera-controls.tsx +90 -34
  3. package/src/components/editor/duplicate-node.ts +2 -1
  4. package/src/components/editor/first-person-controls.tsx +5 -7
  5. package/src/components/editor/fit-scene-framing.test.ts +318 -0
  6. package/src/components/editor/fit-scene-framing.ts +142 -0
  7. package/src/components/editor/floating-action-menu.tsx +4 -2
  8. package/src/components/editor/floor-openings-overlay.tsx +1 -1
  9. package/src/components/editor/floor-region-controls.tsx +2 -2
  10. package/src/components/editor/floorplan-panel.tsx +173 -8
  11. package/src/components/editor/floorplan-wall-typing-keys.test.tsx +152 -0
  12. package/src/components/editor/floorplan-wall-typing-keys.ts +79 -0
  13. package/src/components/editor/footprint-height-handle.tsx +1 -1
  14. package/src/components/editor/group-transform-shared.ts +3 -1
  15. package/src/components/editor/index.tsx +20 -0
  16. package/src/components/editor/measurement-pill.tsx +1 -1
  17. package/src/components/editor/node-arrow-handles.tsx +1 -1
  18. package/src/components/editor/open-wall-ends-3d-layer.tsx +1 -2
  19. package/src/components/editor/open-wall-ends-hint.tsx +1 -1
  20. package/src/components/editor/quick-measurement-card.tsx +1 -1
  21. package/src/components/editor/room-controls.tsx +3 -2
  22. package/src/components/editor/room-handles-2d.tsx +2 -1
  23. package/src/components/editor/room-handles.tsx +3 -3
  24. package/src/components/editor/room-labels.tsx +4 -2
  25. package/src/components/editor/site-edge-labels.tsx +6 -2
  26. package/src/components/editor/wall-measurement-label.tsx +30 -14
  27. package/src/components/editor/wall-move-side-handles.tsx +10 -6
  28. package/src/components/editor/wall-region-handles.tsx +2 -2
  29. package/src/components/editor/wall-snap-beacon-layer.tsx +53 -32
  30. package/src/components/editor-2d/floorplan-group-move.tsx +1 -1
  31. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +12 -12
  32. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +1 -2
  33. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +2 -1
  34. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +22 -4
  35. package/src/components/editor-2d/renderers/floorplan-draft-measurement.tsx +7 -1
  36. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +13 -1
  37. package/src/components/editor-2d/renderers/floorplan-placement-typing-hud.test.tsx +35 -0
  38. package/src/components/editor-2d/renderers/floorplan-placement-typing-hud.tsx +65 -0
  39. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +22 -0
  40. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +14 -2
  41. package/src/components/systems/roof/roof-edit-system.test.tsx +15 -1
  42. package/src/components/systems/roof/roof-edit-system.tsx +30 -19
  43. package/src/components/tools/distribution/distribution-run-tool.test.tsx +371 -3
  44. package/src/components/tools/distribution/distribution-run-tool.tsx +150 -32
  45. package/src/components/tools/item/move-tool.tsx +1 -6
  46. package/src/components/tools/item/use-placement-coordinator.tsx +1 -1
  47. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +1 -1
  48. package/src/components/tools/registry/move-registry-node-tool.test.ts +42 -1
  49. package/src/components/tools/registry/move-registry-node-tool.tsx +38 -14
  50. package/src/components/tools/shared/placement-box.tsx +1 -5
  51. package/src/components/tools/shared/placement-coordinate-input.tsx +94 -0
  52. package/src/components/tools/shared/segment-angle.ts +0 -4
  53. package/src/components/tools/tool-manager.tsx +1 -24
  54. package/src/components/tools/wall/wall-typed-entry.test.ts +99 -0
  55. package/src/components/tools/wall/wall-typed-entry.ts +70 -0
  56. package/src/components/ui/controls/slider-control.tsx +18 -6
  57. package/src/components/ui/controls/tool-options-panel.tsx +74 -44
  58. package/src/components/ui/helpers/helper-manager.test.tsx +53 -0
  59. package/src/components/ui/helpers/helper-manager.tsx +54 -0
  60. package/src/components/ui/panels/floor-foundation-panel.tsx +1 -1
  61. package/src/components/ui/panels/floor-opening-panel.tsx +1 -1
  62. package/src/components/ui/panels/mobile-selection-bar.tsx +28 -24
  63. package/src/components/ui/panels/multi-height-mode.tsx +3 -3
  64. package/src/components/ui/panels/multi-selection-panel.tsx +1 -1
  65. package/src/components/ui/panels/panel-manager.tsx +4 -2
  66. package/src/components/ui/panels/panel-wrapper.tsx +41 -18
  67. package/src/components/ui/panels/room-construction-rows.tsx +1 -1
  68. package/src/components/ui/panels/room-openings-field.tsx +1 -1
  69. package/src/components/ui/panels/room-panel.tsx +1 -1
  70. package/src/components/ui/right-stack.test.ts +13 -6
  71. package/src/components/ui/right-stack.tsx +21 -10
  72. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +1 -5
  73. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +1 -1
  74. package/src/components/ui/sidebar/panels/site-panel/index.tsx +1 -8
  75. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +1 -1
  76. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +1 -1
  77. package/src/components/ui/wall-type/wall-type-editor-dialog.tsx +1 -1
  78. package/src/components/viewer/viewer-rooms.tsx +1 -1
  79. package/src/i18n/locales/en/messages.po +856 -124
  80. package/src/i18n/locales/en/messages.ts +1 -1
  81. package/src/i18n/locales/en-XA/messages.ts +1 -1
  82. package/src/i18n/locales/es/messages.po +2087 -257
  83. package/src/i18n/locales/fr/messages.po +2087 -257
  84. package/src/i18n/locales/ja/messages.po +2087 -257
  85. package/src/i18n/locales/zh-CN/messages.po +2087 -257
  86. package/src/index.tsx +74 -47
  87. package/src/lib/contextual-help.ts +6 -0
  88. package/src/lib/elevation-guides.ts +21 -6
  89. package/src/lib/elevator-support.ts +1 -72
  90. package/src/lib/floor-footprints.test.ts +1 -1
  91. package/src/lib/floor-footprints.ts +1 -9
  92. package/src/lib/floor-foundation-support.test.ts +8 -3
  93. package/src/lib/floor-opening-draft.ts +1 -1
  94. package/src/lib/floor-region-session.ts +1 -1
  95. package/src/lib/floorplan/open-wall-ends.ts +2 -1
  96. package/src/lib/gesture-lifecycle.ts +3 -1
  97. package/src/lib/interaction/scope.ts +1 -0
  98. package/src/lib/layer-thickness.ts +1 -1
  99. package/src/lib/level-selection.test.ts +18 -2
  100. package/src/lib/level-selection.ts +31 -8
  101. package/src/lib/linear-display.ts +1 -1
  102. package/src/lib/live-gesture-round-trip.test.ts +1 -1
  103. package/src/lib/measurement-label.test.ts +1 -1
  104. package/src/lib/measurements.test.ts +8 -7
  105. package/src/lib/measurements.ts +1 -218
  106. package/src/lib/node-action-movement.test.tsx +115 -0
  107. package/src/lib/node-action-movement.ts +2 -0
  108. package/src/lib/placement-metadata.ts +4 -0
  109. package/src/lib/point-ask/describe-target.ts +6 -8
  110. package/src/lib/point-ask/region.ts +1 -1
  111. package/src/lib/print-shell-compiler.ts +12 -1
  112. package/src/lib/print-wall-solids.test.ts +11 -0
  113. package/src/lib/print-wall-solids.ts +9 -0
  114. package/src/lib/room-assembly-overlay.ts +1 -1
  115. package/src/lib/room-built-on.test.ts +2 -5
  116. package/src/lib/room-built-on.ts +1 -15
  117. package/src/lib/room-construction-commands.ts +3 -2
  118. package/src/lib/room-controls-phase6.test.ts +1 -1
  119. package/src/lib/room-handle-drag.ts +6 -25
  120. package/src/lib/room-push-dimensions.test.ts +1 -1
  121. package/src/lib/scene-bounds.test.ts +144 -0
  122. package/src/lib/scene-bounds.ts +79 -15
  123. package/src/lib/scene-clipboard.ts +2 -1
  124. package/src/lib/snapping-mode.test.ts +23 -1
  125. package/src/lib/viewer-selection.ts +1 -1
  126. package/src/lib/wall-region-list.ts +3 -1
  127. package/src/lib/window-interaction.test.ts +4 -2
  128. package/src/store/use-editor.tsx +5 -0
  129. package/src/store/use-placement-typing.test.ts +165 -0
  130. package/src/store/use-placement-typing.ts +186 -0
  131. package/src/components/tools/elevator/elevator-defaults.ts +0 -8
  132. package/src/components/tools/elevator/elevator-tool.tsx +0 -372
  133. package/src/components/tools/elevator/move-elevator-tool.tsx +0 -262
  134. package/src/lib/door-interaction.ts +0 -102
  135. package/src/lib/measurement-label.ts +0 -68
  136. package/src/lib/window-interaction.ts +0 -142
@@ -0,0 +1,142 @@
1
+ export type FitSceneBounds = {
2
+ center: [number, number]
3
+ size: [number, number]
4
+ } | null
5
+
6
+ export type FitSceneLookAt = {
7
+ eyeX: number
8
+ eyeY: number
9
+ eyeZ: number
10
+ targetX: number
11
+ targetY: number
12
+ targetZ: number
13
+ /** The eye's height above `targetY`, before any keep-over-ground lift. */
14
+ eyeHeight: number
15
+ /**
16
+ * What an orthographic camera fits its zoom to: `setLookAt` leaves the
17
+ * previous project's zoom in place. Centred on the target, so fitting it
18
+ * does not move the pivot.
19
+ */
20
+ fitSphere: { center: [number, number, number]; radius: number }
21
+ }
22
+
23
+ // The empty-editor pose shows the default ±15 m site.
24
+ const DEFAULT_FIT_RADIUS = 15
25
+ // Margin around the footprint, as the frame-node path uses for orthographic fits.
26
+ const FIT_SPHERE_MARGIN = 1.3
27
+ // Keeps a lone wall from filling the viewport, like the perspective 15 m floor.
28
+ const MIN_FIT_RADIUS = 5
29
+
30
+ export type PendingFitScene = {
31
+ bounds: FitSceneBounds
32
+ }
33
+
34
+ export type FitSceneEventPlan =
35
+ | { action: 'ignore' }
36
+ | { action: 'apply'; lookAt: FitSceneLookAt }
37
+ | { action: 'queue'; pending: PendingFitScene }
38
+
39
+ export type FitSceneResumePlan = { action: 'noop' } | { action: 'apply'; lookAt: FitSceneLookAt }
40
+
41
+ /**
42
+ * Compute the orbit look-at used by `camera-controls:fit-scene`, resting the
43
+ * pivot at `groundY` (the selected level's orbit height).
44
+ * Null bounds restore the empty-editor default pose.
45
+ */
46
+ export function computeFitSceneLookAt(bounds: FitSceneBounds, groundY = 0): FitSceneLookAt {
47
+ if (!bounds) {
48
+ return {
49
+ eyeX: 20,
50
+ eyeY: 20 + groundY,
51
+ eyeZ: 20,
52
+ targetX: 0,
53
+ targetY: groundY,
54
+ targetZ: 0,
55
+ eyeHeight: 20,
56
+ fitSphere: { center: [0, groundY, 0], radius: DEFAULT_FIT_RADIUS },
57
+ }
58
+ }
59
+
60
+ const [cx, cz] = bounds.center
61
+ const [w, d] = bounds.size
62
+ // Use the longer horizontal extent to size the orbit radius so the whole
63
+ // footprint sits in view regardless of aspect ratio.
64
+ const maxExtent = Math.max(w, d)
65
+ const distance = Math.max(maxExtent * 1.4, 15)
66
+ const height = Math.max(maxExtent * 0.8, 10)
67
+ return {
68
+ eyeX: cx + distance * 0.7,
69
+ eyeY: height + groundY,
70
+ eyeZ: cz + distance * 0.7,
71
+ targetX: cx,
72
+ targetY: groundY,
73
+ targetZ: cz,
74
+ eyeHeight: height,
75
+ fitSphere: {
76
+ center: [cx, groundY, cz],
77
+ radius: Math.max((Math.hypot(w, d) / 2) * FIT_SPHERE_MARGIN, MIN_FIT_RADIUS),
78
+ },
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Decide what to do when a fit-scene event arrives.
84
+ * First-person (and briefly-unmounted orbit controls) must queue the frame
85
+ * so it can apply once orbit is back.
86
+ */
87
+ export function planFitSceneOnEvent(input: {
88
+ isPreviewMode: boolean
89
+ isFirstPersonMode: boolean
90
+ hasControls: boolean
91
+ bounds: FitSceneBounds
92
+ groundY?: number
93
+ }): FitSceneEventPlan {
94
+ if (input.isPreviewMode) return { action: 'ignore' }
95
+
96
+ const pending: PendingFitScene = { bounds: input.bounds }
97
+ if (input.isFirstPersonMode || !input.hasControls) {
98
+ return { action: 'queue', pending }
99
+ }
100
+
101
+ return { action: 'apply', lookAt: computeFitSceneLookAt(input.bounds, input.groundY) }
102
+ }
103
+
104
+ /**
105
+ * Flush a queued load frame once orbit controls can apply it. The ground is
106
+ * read at flush time: the selected level may have changed while it waited.
107
+ */
108
+ export function planFitSceneOnOrbitResume(input: {
109
+ isPreviewMode: boolean
110
+ isFirstPersonMode: boolean
111
+ hasControls: boolean
112
+ pending: PendingFitScene | null
113
+ groundY?: number
114
+ }): FitSceneResumePlan {
115
+ if (!input.pending || input.isPreviewMode || input.isFirstPersonMode || !input.hasControls) {
116
+ return { action: 'noop' }
117
+ }
118
+
119
+ return { action: 'apply', lookAt: computeFitSceneLookAt(input.pending.bounds, input.groundY) }
120
+ }
121
+
122
+ export type LevelFollowPlan = 'none' | 'default-pose' | 'rest-pivot'
123
+
124
+ /**
125
+ * What the level-follow effect does with the orbit pivot.
126
+ *
127
+ * Only a scene-less first run takes the default pose. A first run over a
128
+ * loaded scene is a fresh controls instance (mount, leaving XR, error-boundary
129
+ * recovery) whose pivot sits at the origin, so it is rested on the level like
130
+ * a camera change; on a real load the later scene-ready fit still wins.
131
+ * Afterwards the pivot follows level, level-mode and camera changes only.
132
+ */
133
+ export function planLevelFollow(input: {
134
+ firstLoad: boolean
135
+ sceneEmpty: boolean
136
+ levelChanged: boolean
137
+ modeChanged: boolean
138
+ cameraChanged: boolean
139
+ }): LevelFollowPlan {
140
+ if (input.firstLoad) return input.sceneEmpty ? 'default-pose' : 'rest-pivot'
141
+ return input.levelChanged || input.modeChanged || input.cameraChanged ? 'rest-pivot' : 'none'
142
+ }
@@ -14,6 +14,7 @@ import {
14
14
  getWallThickness,
15
15
  isCurvedWall,
16
16
  isDerivedNode,
17
+ isNodeDuplicable,
17
18
  isRegistryMovable,
18
19
  isRegistrySelectable,
19
20
  isSplineFence,
@@ -284,7 +285,7 @@ function getHeightPillDimensions(node: WallNode | FenceNode): {
284
285
  } {
285
286
  if (node.type === 'wall') {
286
287
  return {
287
- height: getWallEffectiveHeightForNodes(node, useScene.getState().nodes),
288
+ height: getWallEffectiveHeightForNodes(node, useScene.getState().nodes, 0),
288
289
  length: getWallCurveLength(node),
289
290
  thickness: getWallThickness(node),
290
291
  }
@@ -423,7 +424,7 @@ export function FloatingActionMenu() {
423
424
  | undefined
424
425
  const fallbackHeight =
425
426
  node.type === 'wall'
426
- ? getWallEffectiveHeightForNodes(node, useScene.getState().nodes)
427
+ ? getWallEffectiveHeightForNodes(node, useScene.getState().nodes, 0)
427
428
  : FENCE_DEFAULT_HEIGHT
428
429
  const liveHeight = override?.height ?? node.height ?? fallbackHeight
429
430
  pillHeightRef.current.textContent = `H ${formatMeasurement(liveHeight, unit, metricNotation)}`
@@ -681,6 +682,7 @@ export function FloatingActionMenu() {
681
682
  onDelete={handleDelete}
682
683
  onDuplicate={
683
684
  node &&
685
+ isNodeDuplicable(node as AnyNode) &&
684
686
  node.type !== 'spawn' &&
685
687
  !DELETE_ONLY_TYPES.includes(node.type) &&
686
688
  !HOLE_TYPES.includes(node.type)
@@ -5,6 +5,7 @@ import {
5
5
  type AnyNodeId,
6
6
  type FloorOpeningNode,
7
7
  resolveCeilingHeight,
8
+ roomFloorElevation,
8
9
  sceneRegistry,
9
10
  useScene,
10
11
  } from '@pascal-app/core'
@@ -24,7 +25,6 @@ import { useCeilingEditSession } from '../../lib/ceiling-edit-session'
24
25
  import { EDITOR_LAYER } from '../../lib/constants'
25
26
  import { openingRoom, roomSurfaceOpenings, useFloorEditSession } from '../../lib/floor-edit-session'
26
27
  import { useOpeningDraft } from '../../lib/floor-opening-draft'
27
- import { roomFloorElevation } from '../../lib/room-handle-drag'
28
28
  import { PolygonEditor } from '../tools/shared/polygon-editor'
29
29
 
30
30
  // The openings of the room being edited ("Edit floor", "Edit ceiling", or a
@@ -3,8 +3,10 @@
3
3
  import {
4
4
  containsPoint,
5
5
  emitter,
6
+ formatAreaLabel,
6
7
  type MultiPolygon,
7
8
  resolveCeilingHeight,
9
+ roomFloorElevation,
8
10
  sceneRegistry,
9
11
  useScene,
10
12
  } from '@pascal-app/core'
@@ -60,14 +62,12 @@ import {
60
62
  } from '../../lib/floor-region-session'
61
63
  import { type FloorRegionPoint, snapFloorRegionPoint } from '../../lib/floor-region-snap'
62
64
  import { clientToPlan } from '../../lib/floorplan/plan-coords'
63
- import { formatAreaLabel } from '../../lib/measurements'
64
65
  import {
65
66
  cancelMezzanineDraft,
66
67
  mezzanineDraftActive,
67
68
  useMezzanineDraft,
68
69
  } from '../../lib/mezzanine-draft'
69
70
  import { usePaintRegionMode } from '../../lib/paint-region-mode'
70
- import { roomFloorElevation } from '../../lib/room-handle-drag'
71
71
  import {
72
72
  cancelTerraceDraft,
73
73
  terraceDraftActive,
@@ -19,6 +19,8 @@ import {
19
19
  emitter,
20
20
  type FenceNode,
21
21
  type FloorplanGeometry,
22
+ formatAngleRadians,
23
+ formatLinearMeasurement,
22
24
  formatNumber as formatLocalizedNumber,
23
25
  type GeometryContext,
24
26
  type GuideNode,
@@ -29,6 +31,7 @@ import {
29
31
  type ItemNode,
30
32
  isCurvedWall,
31
33
  type LevelNode,
34
+ linearUnitToMeters,
32
35
  loadAssetUrl,
33
36
  newZone,
34
37
  nodeRegistry,
@@ -124,7 +127,6 @@ import {
124
127
  syncKeyboardPanMotion,
125
128
  } from '../../lib/keyboard-pan'
126
129
  import { measurementHint, parseMeasurement } from '../../lib/measurement-parser'
127
- import { formatLinearMeasurement, linearUnitToMeters } from '../../lib/measurements'
128
130
  import {
129
131
  createGuideScaleReference,
130
132
  formatReferenceScaleLabel,
@@ -193,6 +195,7 @@ import useInteractionScope, {
193
195
  useReshapingNode,
194
196
  } from '../../store/use-interaction-scope'
195
197
  import usePlacementPreview from '../../store/use-placement-preview'
198
+ import { placementTypingLengthOptions, usePlacementTyping } from '../../store/use-placement-typing'
196
199
  import { expandSessionSelectionForNode } from '../../store/use-session-groups'
197
200
  import { useStairBuildPreview } from '../../store/use-stair-build-preview'
198
201
  import {
@@ -223,6 +226,7 @@ import { FloorplanDraftMeasurement } from '../editor-2d/renderers/floorplan-draf
223
226
  import { FloorplanGeometryRenderer } from '../editor-2d/renderers/floorplan-geometry-renderer'
224
227
  import { FloorplanMarqueeLayer } from '../editor-2d/renderers/floorplan-marquee-layer'
225
228
  import { FloorplanPlacementPreviewLayer } from '../editor-2d/renderers/floorplan-placement-preview-layer'
229
+ import { FloorplanPlacementTypingHud } from '../editor-2d/renderers/floorplan-placement-typing-hud'
226
230
  import {
227
231
  FloorplanRegistryLayer,
228
232
  RotationAngleOverlay,
@@ -253,7 +257,6 @@ import {
253
257
  } from '../tools/select/screen-rectangle-selection'
254
258
  import { collectSelectableCandidateIds } from '../tools/select/select-candidates'
255
259
  import {
256
- formatAngleRadians,
257
260
  getAngleArcToSegmentReference,
258
261
  getAngleToSegmentReference,
259
262
  getSegmentAngleReferenceAtPoint,
@@ -262,6 +265,7 @@ import {
262
265
  chainEndJoinsExistingWall,
263
266
  createInvisibleWallOnCurrentLevel,
264
267
  createWallOnCurrentLevel,
268
+ getSegmentGridStep,
265
269
  isSegmentLongEnough,
266
270
  snapWallDraftPoint,
267
271
  snapWallDraftPointDetailed,
@@ -270,6 +274,7 @@ import {
270
274
  WALL_JOIN_SNAP_RADIUS,
271
275
  type WallPlanPoint,
272
276
  } from '../tools/wall/wall-drafting'
277
+ import { formatWallDraftHeading, resolveTypedWallEnd } from '../tools/wall/wall-typed-entry'
273
278
  import { FloorplanCompassButton } from '../viewer/floorplan-compass-button'
274
279
  import { resolveFloorplanBackgroundSelection } from './floorplan-background-selection'
275
280
  import {
@@ -289,6 +294,10 @@ import {
289
294
  resolveFloorplanPresentationViewBox,
290
295
  setFloorplanCompassRotation,
291
296
  } from './floorplan-navigation-presentation'
297
+ import {
298
+ endFloorplanWallTypingOnCancel,
299
+ installFloorplanWallTypingKeys,
300
+ } from './floorplan-wall-typing-keys'
292
301
  import { swallowNextClick } from './handles/use-handle-drag'
293
302
  import { useFloorplanBackgroundPlacement } from './use-floorplan-background-placement'
294
303
  import { useFloorplanHitTesting } from './use-floorplan-hit-testing'
@@ -4503,6 +4512,7 @@ function FloorplanLinearDraftLayer({
4503
4512
  // The thickness the wall commits at, as the 3D draft shows it.
4504
4513
  const wallDraftThickness = useWallDraftThickness(DEFAULT_WALL_THICKNESS)
4505
4514
  const wallDraftEnd = useFloorplanDraftPreview((s) => s.wallDraftEnd)
4515
+ const wallTypingActive = usePlacementTyping((s) => s.isActive)
4506
4516
  const fenceDraftEnd = useFloorplanDraftPreview((s) => s.fenceDraftEnd)
4507
4517
  const roofDraftEnd = useFloorplanDraftPreview((s) => s.roofDraftEnd)
4508
4518
  const roofDraftQuarterTurn = useFloorplanDraftPreview((s) => s.roofDraftQuarterTurn)
@@ -4828,6 +4838,7 @@ function FloorplanLinearDraftLayer({
4828
4838
  <FloorplanDraftMeasurement
4829
4839
  labelBackground={isDark ? '#0f172a' : '#ffffff'}
4830
4840
  labelText={isDark ? '#e2e8f0' : '#171717'}
4841
+ lengthHidden={wallTypingActive}
4831
4842
  measurement={draftWallMeasurement}
4832
4843
  measurementStroke={measurementStroke}
4833
4844
  sceneRotationDeg={sceneRotationDeg}
@@ -4835,6 +4846,19 @@ function FloorplanLinearDraftLayer({
4835
4846
  />
4836
4847
  )}
4837
4848
 
4849
+ {wallTypingActive && isWallBuildActive && wallDraftStart ? (
4850
+ <FloorplanPlacementTypingHud
4851
+ placeholders={[
4852
+ draftWallMeasurement?.lengthLabel ?? '',
4853
+ wallDraftEnd ? formatWallDraftHeading(wallDraftStart, wallDraftEnd) : '',
4854
+ ]}
4855
+ sceneRotationDeg={sceneRotationDeg}
4856
+ unitsPerPixel={unitsPerPixel}
4857
+ x={(draftWallMeasurement?.midpoint ?? wallDraftStart)[0]}
4858
+ y={(draftWallMeasurement?.midpoint ?? wallDraftStart)[1]}
4859
+ />
4860
+ ) : null}
4861
+
4838
4862
  {polygonRoomDraft?.map((points, index) => (
4839
4863
  <FloorplanDraftLayer
4840
4864
  anchorFill={draftStroke}
@@ -4912,6 +4936,9 @@ export function FloorplanPanel({
4912
4936
  const floorplanSpacePanPressedRef = useRef(false)
4913
4937
  const floorplanNavigationClickSuppressedRef = useRef(false)
4914
4938
  const guideInteractionRef = useRef<GuideInteractionState | null>(null)
4939
+ // Late-bound so the window keydown effect can commit the wall draft at the
4940
+ // typed length without an ordering dependency on `handleWallPlacementPoint`.
4941
+ const wallPlacementPointRef = useRef<((point: WallPlanPoint) => void) | null>(null)
4915
4942
  const guideTransformDraftRef = useRef<GuideTransformDraft | null>(null)
4916
4943
  const pendingFenceDragRef = useRef<PendingFenceDragState | null>(null)
4917
4944
  const wallEndpointDragRef = useRef<WallEndpointDragState | null>(null)
@@ -5105,6 +5132,25 @@ export function FloorplanPanel({
5105
5132
  // Walls committed by the current 2D-only chain — exclusion set for the
5106
5133
  // T-junction chain-termination test (mirrors the 3D tool's `chainWallIds`).
5107
5134
  const wallChainWallIdsRef = useRef<string[]>([])
5135
+ // Split view: the 3D wall tool owns the commit and publishes each segment's
5136
+ // resolved end as the chain start. Re-base this panel's draft onto it so a
5137
+ // typed Enter in the plan projects from the segment the 3D chain is
5138
+ // actually drafting — without this, `draftStart` stays on the last point
5139
+ // the panel itself placed and the typed commit uses a stale origin. In
5140
+ // 2D-only the panel is the committer and never publishes, so this
5141
+ // subscription stays dormant there.
5142
+ useEffect(() => {
5143
+ if (!(mode === 'build' && tool === 'wall')) return
5144
+ return useSegmentDraftChain.subscribe((state, previousState) => {
5145
+ const next = state.wall
5146
+ if (next === previousState.wall) return
5147
+ if (!next) return
5148
+ setDraftStart((current) =>
5149
+ current && current[0] === next[0] && current[1] === next[1] ? current : next,
5150
+ )
5151
+ setWallChainFirstVertex((current) => current ?? next)
5152
+ })
5153
+ }, [mode, tool])
5108
5154
  // Direction of the run's last wall (a pulled run's source first): the next
5109
5155
  // holds square to it. Null before the run has a wall.
5110
5156
  const wallRunReferenceRef = useRef<WallPlanPoint | null>(null)
@@ -5115,6 +5161,8 @@ export function FloorplanPanel({
5115
5161
  },
5116
5162
  [],
5117
5163
  )
5164
+ // The pointer's own (snapped) wall draft end; a typed session re-projects it.
5165
+ const wallDraftPointerRef = useRef<WallPlanPoint | null>(null)
5118
5166
  const [fenceDraftStart, setFenceDraftStart] = useState<WallPlanPoint | null>(null)
5119
5167
  const setFenceDraftEnd = useCallback(
5120
5168
  (next: WallPlanPoint | null | ((prev: WallPlanPoint | null) => WallPlanPoint | null)) => {
@@ -7916,6 +7964,26 @@ export function FloorplanPanel({
7916
7964
  }
7917
7965
  }, [isFloorplanOpen, stopFloorplanViewAnimation])
7918
7966
 
7967
+ // Typing reshapes the plan's own draft without a pointer move.
7968
+ useEffect(() => {
7969
+ if (!draftStart) return
7970
+ return usePlacementTyping.subscribe((state, previous) => {
7971
+ if (state.fields === previous.fields && state.isActive === previous.isActive) return
7972
+ const pointer = wallDraftPointerRef.current
7973
+ if (!pointer) return
7974
+ setDraftEnd(
7975
+ (state.isActive &&
7976
+ resolveTypedWallEnd(
7977
+ draftStart,
7978
+ pointer,
7979
+ state.fields,
7980
+ placementTypingLengthOptions(unit, metricNotation),
7981
+ getSegmentGridStep(),
7982
+ )) ||
7983
+ pointer,
7984
+ )
7985
+ })
7986
+ }, [draftStart, metricNotation, setDraftEnd, unit])
7919
7987
  // Clearing an open wall draft drops a Polygon room in progress (nothing of
7920
7988
  // it was written); paths that keep it commit first.
7921
7989
  const clearWallPlacementDraft = useCallback(() => {
@@ -7926,6 +7994,8 @@ export function FloorplanPanel({
7926
7994
  wallChainWallIdsRef.current = []
7927
7995
  wallRunReferenceRef.current = null
7928
7996
  setDraftEnd(null)
7997
+ wallDraftPointerRef.current = null
7998
+ usePlacementTyping.getState().clear()
7929
7999
  useSegmentDraftChain.getState().clear('wall')
7930
8000
  // A run pulled from a wall end ends with the plan's draft: back to Select.
7931
8001
  endWallPullRun()
@@ -8033,6 +8103,7 @@ export function FloorplanPanel({
8033
8103
 
8034
8104
  useEffect(() => {
8035
8105
  const handleCancel = () => {
8106
+ if (endFloorplanWallTypingOnCancel()) return
8036
8107
  clearDraft()
8037
8108
  }
8038
8109
 
@@ -8042,6 +8113,20 @@ export function FloorplanPanel({
8042
8113
  }
8043
8114
  }, [clearDraft])
8044
8115
 
8116
+ // Split view: when the 3D wall tool stops drafting (auto-close,
8117
+ // T-junction, single-segment, cancel) it clears the published chain start.
8118
+ // That null clear must tear this panel's draft down too, or the 2D side
8119
+ // keeps a live draft that swallows typing keys against a chain the 3D
8120
+ // view already ended. Lives here (below `clearDraft`)
8121
+ // rather than beside the re-base subscription so the teardown stays
8122
+ // reachable.
8123
+ useEffect(() => {
8124
+ if (!(mode === 'build' && tool === 'wall')) return
8125
+ return useSegmentDraftChain.subscribe((state, previousState) => {
8126
+ if (state.wall === null && previousState.wall !== null) clearDraft()
8127
+ })
8128
+ }, [mode, tool, clearDraft])
8129
+
8045
8130
  // A wall end's pull grip pressed in the plan opens the Walls run at that
8046
8131
  // end: start the plan's draft there, as if the end had been clicked. A pull
8047
8132
  // from the 3D view drafts there (the plan beside it follows its moves).
@@ -8315,6 +8400,31 @@ export function FloorplanPanel({
8315
8400
  return unsubscribe
8316
8401
  }, [movingNode, scheduleMovingFloorplanNodeRefresh])
8317
8402
 
8403
+ // Read by the typed-entry listener, registered once so it stays ahead of
8404
+ // the global shortcuts.
8405
+ const wallTypingSourceRef = useRef({
8406
+ draftStart: null as WallPlanPoint | null,
8407
+ lengthOptions: placementTypingLengthOptions(unit, metricNotation),
8408
+ })
8409
+ wallTypingSourceRef.current = {
8410
+ draftStart: isWallBuildActive ? draftStart : null,
8411
+ lengthOptions: placementTypingLengthOptions(unit, metricNotation),
8412
+ }
8413
+ useEffect(
8414
+ () =>
8415
+ installFloorplanWallTypingKeys({
8416
+ getDraft: () => {
8417
+ const { draftStart: start, lengthOptions } = wallTypingSourceRef.current
8418
+ if (!start) return null
8419
+ const pointer =
8420
+ wallDraftPointerRef.current ?? useFloorplanDraftPreview.getState().wallDraftEnd ?? start
8421
+ return { start, pointer, lengthOptions }
8422
+ },
8423
+ commit: (pointer) => wallPlacementPointRef.current?.(pointer),
8424
+ }),
8425
+ [],
8426
+ )
8427
+
8318
8428
  useEffect(() => {
8319
8429
  const handleKeyDown = (event: KeyboardEvent) => {
8320
8430
  const target = event.target as HTMLElement | null
@@ -8393,7 +8503,16 @@ export function FloorplanPanel({
8393
8503
  window.removeEventListener('keyup', handleKeyUp)
8394
8504
  window.removeEventListener('blur', handleBlur)
8395
8505
  }
8396
- }, [isFloorplanOpen, isStairBuildActive, movingNode])
8506
+ }, [
8507
+ isFloorplanOpen,
8508
+ isStairBuildActive,
8509
+ movingNode,
8510
+ isWallBuildActive,
8511
+ draftStart,
8512
+ unit,
8513
+ metricNotation,
8514
+ setDraftEnd,
8515
+ ])
8397
8516
 
8398
8517
  useEffect(() => {
8399
8518
  const handleWindowPointerMove = (event: PointerEvent) => {
@@ -9653,8 +9772,23 @@ export function FloorplanPanel({
9653
9772
  // Emit `grid:move` so the registry-driven wall tool's 3D preview
9654
9773
  // tracks the cursor. The local draftEnd update below is what
9655
9774
  // drives the 2D draft polygon — both views update in parallel.
9775
+ // An open typed session re-projects the pointer onto what was typed;
9776
+ // the 3D tool gets the pointer itself and projects it the same way.
9777
+ wallDraftPointerRef.current = draftStart ? snappedPoint : null
9778
+ const wallTyping = usePlacementTyping.getState()
9779
+ const draftEndPoint =
9780
+ (draftStart &&
9781
+ wallTyping.isActive &&
9782
+ resolveTypedWallEnd(
9783
+ draftStart,
9784
+ snappedPoint,
9785
+ wallTyping.fields,
9786
+ placementTypingLengthOptions(unit, metricNotation),
9787
+ getSegmentGridStep(),
9788
+ )) ||
9789
+ snappedPoint
9656
9790
  emitFloorplanGridEvent('move', snappedPoint, event)
9657
- setCursorPoint(snappedPoint)
9791
+ setCursorPoint(draftEndPoint)
9658
9792
 
9659
9793
  if (!draftStart) {
9660
9794
  return
@@ -9663,13 +9797,13 @@ export function FloorplanPanel({
9663
9797
  setDraftEnd((previousEnd) => {
9664
9798
  if (
9665
9799
  !previousEnd ||
9666
- previousEnd[0] !== snappedPoint[0] ||
9667
- previousEnd[1] !== snappedPoint[1]
9800
+ previousEnd[0] !== draftEndPoint[0] ||
9801
+ previousEnd[1] !== draftEndPoint[1]
9668
9802
  ) {
9669
9803
  sfxEmitter.emit('sfx:grid-snap')
9670
9804
  }
9671
9805
 
9672
- return snappedPoint
9806
+ return draftEndPoint
9673
9807
  })
9674
9808
  },
9675
9809
  [
@@ -9704,6 +9838,11 @@ export function FloorplanPanel({
9704
9838
  isRoofBuildActive,
9705
9839
  isWallBuildActive,
9706
9840
  levelId,
9841
+ // `unit` / `metricNotation` feed the typed-length projection helpers
9842
+ // shared with the commit path; listed so a unit change re-binds the
9843
+ // move preview to the new bare-unit parse.
9844
+ metricNotation,
9845
+ unit,
9707
9846
  publishFloorplanNavigationPose,
9708
9847
  referenceScaleDraft,
9709
9848
  siteVertexDragState,
@@ -9889,7 +10028,21 @@ export function FloorplanPanel({
9889
10028
  )
9890
10029
 
9891
10030
  const handleWallPlacementPoint = useCallback(
9892
- (point: WallPlanPoint) => {
10031
+ (rawPoint: WallPlanPoint) => {
10032
+ // An open typed session commits what its preview shows: the pointer
10033
+ // re-projected onto what was typed, never re-snapped.
10034
+ const typing = usePlacementTyping.getState()
10035
+ const typedEnd =
10036
+ draftStart && typing.isActive
10037
+ ? resolveTypedWallEnd(
10038
+ draftStart,
10039
+ rawPoint,
10040
+ typing.fields,
10041
+ placementTypingLengthOptions(unit, metricNotation),
10042
+ getSegmentGridStep(),
10043
+ )
10044
+ : null
10045
+ const point: WallPlanPoint = typedEnd ?? rawPoint
9893
10046
  if (!draftStart) {
9894
10047
  wallConstructionOptionsRef.current = levelId
9895
10048
  ? resolveTerrainWallConstructionOptions(
@@ -9950,6 +10103,7 @@ export function FloorplanPanel({
9950
10103
  setDraftStart(point)
9951
10104
  setDraftEnd(point)
9952
10105
  setCursorPoint(point)
10106
+ usePlacementTyping.getState().clear()
9953
10107
  return
9954
10108
  }
9955
10109
  if (viewIs2DOnly) {
@@ -10015,16 +10169,26 @@ export function FloorplanPanel({
10015
10169
  setDraftStart(nextStart)
10016
10170
  setDraftEnd(nextStart)
10017
10171
  setCursorPoint(nextStart)
10172
+ // What was typed belongs to this segment; the next starts afresh.
10173
+ usePlacementTyping.getState().clear()
10018
10174
  },
10019
10175
  [
10020
10176
  clearWallPlacementDraft,
10021
10177
  draftStart,
10022
10178
  levelId,
10179
+ metricNotation,
10180
+ unit,
10023
10181
  wallChainFirstVertex,
10024
10182
  setDraftEnd,
10025
10183
  setCursorPoint,
10026
10184
  ],
10027
10185
  )
10186
+ // Latest-ref for the Enter-commit path: assigning the ref inside the
10187
+ // callback itself froze the first-render closure (draftStart === null), so
10188
+ // a typed Enter re-started the draft instead of committing the wall.
10189
+ useEffect(() => {
10190
+ wallPlacementPointRef.current = handleWallPlacementPoint
10191
+ }, [handleWallPlacementPoint])
10028
10192
  const { getFloorplanHitIdAtPoint, getFloorplanSelectionIdsInBounds } = useFloorplanHitTesting({
10029
10193
  sceneRef: floorplanSceneRef,
10030
10194
  })
@@ -10254,6 +10418,7 @@ export function FloorplanPanel({
10254
10418
  unit,
10255
10419
  emitFloorplanGridEvent,
10256
10420
  setCursorPoint,
10421
+ locale,
10257
10422
  ],
10258
10423
  )
10259
10424
  const handleSvgClick = useCallback(