@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,152 @@
1
+ import { beforeEach, expect, test } from 'bun:test'
2
+ import { emitter } from '@pascal-app/core'
3
+ import { cancelActiveTool, useKeyboard } from '../../hooks/use-keyboard'
4
+ import useEditor from '../../store/use-editor'
5
+ import {
6
+ PLACEMENT_TYPING_LENGTH_ANGLE,
7
+ placementTypingLengthOptions,
8
+ usePlacementTyping,
9
+ } from '../../store/use-placement-typing'
10
+ import { sceneLayout } from '../../store/view-layout'
11
+ import { withSelectionHarness } from '../../test-utils/selection-harness'
12
+ import type { WallPlanPoint } from '../tools/wall/wall-drafting'
13
+ import {
14
+ endFloorplanWallTypingOnCancel,
15
+ type FloorplanWallTypingDraft,
16
+ installFloorplanWallTypingKeys,
17
+ } from './floorplan-wall-typing-keys'
18
+
19
+ function GlobalShortcuts() {
20
+ useKeyboard()
21
+ return null
22
+ }
23
+
24
+ const press = (key: string) => {
25
+ const event = Object.assign(new Event('keydown', { cancelable: true }), {
26
+ key,
27
+ code: '',
28
+ metaKey: false,
29
+ ctrlKey: false,
30
+ altKey: false,
31
+ shiftKey: false,
32
+ repeat: false,
33
+ })
34
+ window.dispatchEvent(event)
35
+ }
36
+
37
+ beforeEach(() => {
38
+ usePlacementTyping.getState().clear()
39
+ })
40
+
41
+ test('2D-only typed wall keys run ahead of the global shortcuts', async () => {
42
+ await withSelectionHarness(async ({ render }) => {
43
+ useEditor.getState().setViewLayout(sceneLayout('2d'))
44
+ useEditor.getState().setPhase('building')
45
+ useEditor.getState().armToolMode({ mode: 'build', tool: 'wall' })
46
+ // The global handler is registered first, as the app mounts it.
47
+ await render(<GlobalShortcuts />)
48
+
49
+ let draft: FloorplanWallTypingDraft | null = {
50
+ start: [0, 0],
51
+ pointer: [2, 0],
52
+ lengthOptions: placementTypingLengthOptions('metric', 'meters'),
53
+ }
54
+ const committed: WallPlanPoint[] = []
55
+ const dropDraft = () => {
56
+ draft = null
57
+ }
58
+ emitter.on('tool:cancel', dropDraft)
59
+ const uninstall = installFloorplanWallTypingKeys({
60
+ getDraft: () => draft,
61
+ commit: (pointer) => committed.push(pointer),
62
+ })
63
+ try {
64
+ press('1')
65
+ press('m')
66
+ expect(usePlacementTyping.getState().fields[0]).toBe('1m')
67
+ expect(useEditor.getState().phase).toBe('building')
68
+ expect(useEditor.getState().mode).toBe('build')
69
+ expect(useEditor.getState().tool).toBe('wall')
70
+
71
+ press('Escape')
72
+ expect(usePlacementTyping.getState().isActive).toBe(false)
73
+ expect(draft).not.toBeNull()
74
+ expect(useEditor.getState().tool).toBe('wall')
75
+
76
+ press('Escape')
77
+ expect(draft).toBeNull()
78
+ expect(committed).toEqual([])
79
+ } finally {
80
+ uninstall()
81
+ emitter.off('tool:cancel', dropDraft)
82
+ usePlacementTyping.getState().clear()
83
+ }
84
+ })
85
+ })
86
+
87
+ test('Enter commits the pointer the typed fields re-project, only in 2D-only view', async () => {
88
+ await withSelectionHarness(async () => {
89
+ const committed: WallPlanPoint[] = []
90
+ const uninstall = installFloorplanWallTypingKeys({
91
+ getDraft: () => ({
92
+ start: [0, 0],
93
+ pointer: [2, 0],
94
+ lengthOptions: placementTypingLengthOptions('metric', 'meters'),
95
+ }),
96
+ commit: (pointer) => committed.push(pointer),
97
+ })
98
+ try {
99
+ useEditor.getState().setViewLayout(sceneLayout('split'))
100
+ press('3')
101
+ expect(usePlacementTyping.getState().isActive).toBe(false)
102
+
103
+ useEditor.getState().setViewLayout(sceneLayout('2d'))
104
+ press('3')
105
+ press('Enter')
106
+ expect(committed).toEqual([[2, 0]])
107
+ } finally {
108
+ uninstall()
109
+ usePlacementTyping.getState().clear()
110
+ }
111
+ })
112
+ })
113
+
114
+ test('in 2D-only view a cancel from any source first ends the typed session, then the draft', async () => {
115
+ await withSelectionHarness(async () => {
116
+ useEditor.getState().setViewLayout(sceneLayout('2d'))
117
+ useEditor.getState().armToolMode({ mode: 'build', tool: 'wall' })
118
+ let draftKept = true
119
+ // The plan's own `tool:cancel` handler.
120
+ const onCancel = () => {
121
+ if (endFloorplanWallTypingOnCancel()) return
122
+ draftKept = false
123
+ }
124
+ emitter.on('tool:cancel', onCancel)
125
+ try {
126
+ usePlacementTyping.getState().begin({ labels: PLACEMENT_TYPING_LENGTH_ANGLE })
127
+ usePlacementTyping.getState().append('3')
128
+
129
+ // The path ⌘Z takes mid-draft, not a keydown the plan sees itself.
130
+ expect(cancelActiveTool()).toBe(true)
131
+ expect(usePlacementTyping.getState().isActive).toBe(false)
132
+ expect(draftKept).toBe(true)
133
+ expect(useEditor.getState().mode).toBe('build')
134
+ expect(useEditor.getState().tool).toBe('wall')
135
+
136
+ cancelActiveTool()
137
+ expect(draftKept).toBe(false)
138
+ } finally {
139
+ emitter.off('tool:cancel', onCancel)
140
+ }
141
+ })
142
+ })
143
+
144
+ test('in split view the typed session and its cancel stay with the 3D wall tool', async () => {
145
+ await withSelectionHarness(async () => {
146
+ useEditor.getState().setViewLayout(sceneLayout('split'))
147
+ useEditor.getState().armToolMode({ mode: 'build', tool: 'wall' })
148
+ usePlacementTyping.getState().begin({ labels: PLACEMENT_TYPING_LENGTH_ANGLE })
149
+ expect(endFloorplanWallTypingOnCancel()).toBe(true)
150
+ expect(usePlacementTyping.getState().isActive).toBe(true)
151
+ })
152
+ })
@@ -0,0 +1,79 @@
1
+ import { markToolCancelConsumed } from '../../hooks/use-keyboard'
2
+ import type { ParseMeasurementOptions } from '../../lib/measurement-parser'
3
+ import { isTypingTarget } from '../../lib/typing-target'
4
+ import useEditor from '../../store/use-editor'
5
+ import {
6
+ PLACEMENT_TYPING_LENGTH_ANGLE,
7
+ routePlacementTypingKey,
8
+ usePlacementTyping,
9
+ } from '../../store/use-placement-typing'
10
+ import { visibleScene } from '../../store/view-layout'
11
+ import { getSegmentGridStep, type WallPlanPoint } from '../tools/wall/wall-drafting'
12
+ import { resolveTypedWallEnd } from '../tools/wall/wall-typed-entry'
13
+
14
+ export type FloorplanWallTypingDraft = {
15
+ start: WallPlanPoint
16
+ /** The plan-snapped pointer the typed fields re-project. */
17
+ pointer: WallPlanPoint
18
+ lengthOptions: ParseMeasurementOptions
19
+ }
20
+
21
+ /**
22
+ * Typed entry for the plan's own wall draft (2D-only view; in split view the
23
+ * 3D wall tool drafts and owns these keys). A window capture listener, so it
24
+ * runs before the global shortcuts and a typed `1`, `m` or the session's
25
+ * Escape never reaches them.
26
+ */
27
+ export function installFloorplanWallTypingKeys({
28
+ getDraft,
29
+ commit,
30
+ }: {
31
+ getDraft: () => FloorplanWallTypingDraft | null
32
+ commit: (pointer: WallPlanPoint) => void
33
+ }): () => void {
34
+ const onKeyDown = (event: KeyboardEvent) => {
35
+ if (isTypingTarget(event.target)) return
36
+ if (visibleScene(useEditor.getState()) !== '2d') return
37
+ const draft = getDraft()
38
+ if (!draft) return
39
+ const outcome = routePlacementTypingKey(event, {
40
+ begin: (activeField) => {
41
+ usePlacementTyping.getState().begin({ labels: PLACEMENT_TYPING_LENGTH_ANGLE, activeField })
42
+ return true
43
+ },
44
+ tabBegins: true,
45
+ })
46
+ if (!outcome) return
47
+ event.preventDefault()
48
+ event.stopPropagation()
49
+ if (outcome !== 'commit') return
50
+ const typedEnd = resolveTypedWallEnd(
51
+ draft.start,
52
+ draft.pointer,
53
+ usePlacementTyping.getState().fields,
54
+ draft.lengthOptions,
55
+ getSegmentGridStep(),
56
+ )
57
+ if (typedEnd) commit(draft.pointer)
58
+ }
59
+ window.addEventListener('keydown', onKeyDown, true)
60
+ return () => window.removeEventListener('keydown', onKeyDown, true)
61
+ }
62
+
63
+ /**
64
+ * The plan's `tool:cancel` first stage. A cancel from any source (Escape, ⌘Z,
65
+ * a toolbar button) while a typed wall session is open only ends that
66
+ * session: in 2D-only view the plan clears it and consumes the cancel. In
67
+ * split view the 3D wall tool owns the session and its cancel. Returns true
68
+ * when the plan's wall draft must be kept.
69
+ */
70
+ export function endFloorplanWallTypingOnCancel(): boolean {
71
+ const editor = useEditor.getState()
72
+ if (!(editor.mode === 'build' && editor.tool === 'wall')) return false
73
+ if (!usePlacementTyping.getState().isActive) return false
74
+ if (visibleScene(editor) === '2d') {
75
+ markToolCancelConsumed()
76
+ usePlacementTyping.getState().clear()
77
+ }
78
+ return true
79
+ }
@@ -2,6 +2,7 @@
2
2
 
3
3
  import {
4
4
  type AnyNodeId,
5
+ formatLinearMeasurement,
5
6
  getStoredLevelHeight,
6
7
  type Point,
7
8
  type SlabNode,
@@ -20,7 +21,6 @@ import {
20
21
  THICK_FLOOR_HINT,
21
22
  thickFloorAdvice,
22
23
  } from '../../lib/floor-footprints'
23
- import { formatLinearMeasurement } from '../../lib/measurements'
24
24
  import { runRoomHandleDrag } from '../../lib/room-handle-drag'
25
25
  import useEditor, { isGridSnapActive } from '../../store/use-editor'
26
26
  import useInteractionScope from '../../store/use-interaction-scope'
@@ -3,6 +3,7 @@ import {
3
3
  type AnyNodeId,
4
4
  calculateLevelMiters,
5
5
  getWallPlanFootprint,
6
+ isMoveLocked,
6
7
  nodeRegistry,
7
8
  resolveBuildingForLevel,
8
9
  sceneRegistry,
@@ -99,7 +100,8 @@ export function classifyParticipant(
99
100
  levelId: string | null,
100
101
  sceneNodes: Record<string, AnyNode | undefined>,
101
102
  ): ParticipantKind | null {
102
- if (!node || !isInGroupTransformScope(node, levelId, sceneNodes)) return null
103
+ if (!node || !isInGroupTransformScope(node, levelId, sceneNodes) || isMoveLocked(node))
104
+ return null
103
105
  const shape = classifyPlacementShape(node)
104
106
  if (shape) return shape
105
107
  const start = (node as { start?: unknown }).start
@@ -4,6 +4,7 @@ import { Icon } from '@iconify/react'
4
4
  import {
5
5
  acquireSceneReadOnlyLease,
6
6
  createI18n,
7
+ emitter,
7
8
  getCatalogMaterialById,
8
9
  getLibraryMaterialIdFromRef,
9
10
  getMaterialSwatch,
@@ -43,6 +44,7 @@ import {
43
44
  } from 'react'
44
45
  import { ViewerOverlay } from '../../components/viewer-overlay'
45
46
  import { ViewerZoneSystem } from '../../components/viewer-zone-system'
47
+ import { useAutoFrame } from '../../hooks/use-auto-frame'
46
48
  import { type SaveStatus, useAutoSave } from '../../hooks/use-auto-save'
47
49
  import { useKeyboard } from '../../hooks/use-keyboard'
48
50
  import { useIsMobile } from '../../hooks/use-mobile'
@@ -67,6 +69,7 @@ import {
67
69
  type SceneGraph,
68
70
  writePersistedSelection,
69
71
  } from '../../lib/scene'
72
+ import { computeSceneBoundsXZ } from '../../lib/scene-bounds'
70
73
  import { disposeSFXBus, initSFXBus } from '../../lib/sfx-bus'
71
74
  import { useUnitFocusRules } from '../../lib/units'
72
75
  import { cn } from '../../lib/utils'
@@ -1570,6 +1573,8 @@ function EditorContent({
1570
1573
  useKeyboard({ isVersionPreviewMode, disabled: isFirstPersonMode || isStudioMode })
1571
1574
  useGestureLifecycleOwner()
1572
1575
 
1576
+ useAutoFrame()
1577
+
1573
1578
  const { beginSceneLoad, completeSceneLoad, saveNow } = useAutoSave({
1574
1579
  guardAgainstSceneWipe,
1575
1580
  onSave,
@@ -1741,6 +1746,21 @@ function EditorContent({
1741
1746
  return () => window.clearTimeout(timer)
1742
1747
  }, [hasLoadedInitialScene, isLoading, isSceneLoading, isViewerSceneReady, sceneReadyKey])
1743
1748
 
1749
+ // The useAutoFrame emit can be clobbered by the level-follow effect's
1750
+ // first-run pivot rest on fast (client-side navigation) loads. Re-emitting
1751
+ // here is the last word after every load-driven camera effect has run.
1752
+ // Once per load: a remounted viewer reports ready again, but only a new
1753
+ // load reframes; remounted controls rest their own pivot on the level.
1754
+ const framedSceneReadyKeyRef = useRef<number | null>(null)
1755
+ useEffect(() => {
1756
+ if (!isViewerSceneReady || framedSceneReadyKeyRef.current === sceneReadyKey) return
1757
+ framedSceneReadyKeyRef.current = sceneReadyKey
1758
+ const nodes = useScene.getState().nodes
1759
+ if (Object.keys(nodes).length === 0) return
1760
+ const bounds = computeSceneBoundsXZ(nodes)
1761
+ emitter.emit('camera-controls:fit-scene', bounds ? { bounds } : {})
1762
+ }, [isViewerSceneReady, sceneReadyKey])
1763
+
1744
1764
  const isSceneDataLoading = isLoading || isSceneLoading || !hasLoadedInitialScene
1745
1765
  const showLoader = isSceneDataLoading || !isViewerSceneReady
1746
1766
  const visibleLoader =
@@ -1,8 +1,8 @@
1
1
  'use client'
2
2
 
3
+ import { formatLinearMeasurement, type MetricNotation } from '@pascal-app/core'
3
4
  import { useViewer } from '@pascal-app/viewer'
4
5
  import { type ForwardedRef, Fragment, forwardRef } from 'react'
5
- import { formatLinearMeasurement, type MetricNotation } from '../../lib/measurements'
6
6
 
7
7
  // Canonical in-world dimension formatter — metric metres or imperial
8
8
  // feet/inches. Shared by every measurement readout so they read the same.
@@ -9,6 +9,7 @@ import {
9
9
  createAngleAccumulator,
10
10
  createSceneApi,
11
11
  DEFAULT_ANGLE_STEP,
12
+ formatAngleRadians,
12
13
  type HandleDescriptor,
13
14
  type HandlePortal,
14
15
  type LatchHandle,
@@ -59,7 +60,6 @@ import useInteractionScope, {
59
60
  useMovingNode,
60
61
  } from '../../store/use-interaction-scope'
61
62
  import useOpeningGuides from '../../store/use-opening-guides'
62
- import { formatAngleRadians } from '../tools/shared/segment-angle'
63
63
  import {
64
64
  ARROW_COLOR,
65
65
  ARROW_HOVER_COLOR,
@@ -1,7 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import { type OpenWallEnd, sceneRegistry } from '@pascal-app/core'
4
- import { useViewer } from '@pascal-app/viewer'
4
+ import { MEASUREMENT_DANGLING_COLOR, useViewer } from '@pascal-app/viewer'
5
5
  import { useFrame } from '@react-three/fiber'
6
6
  import { memo, useEffect, useMemo, useRef } from 'react'
7
7
  import { BoxGeometry, CircleGeometry, type Group, RingGeometry } from 'three'
@@ -13,7 +13,6 @@ import {
13
13
  } from '../../hooks/use-open-wall-ends'
14
14
  import { EDITOR_LAYER } from '../../lib/constants'
15
15
  import { openWallEndKey, openWallEndLabel } from '../../lib/floorplan/open-wall-ends'
16
- import { MEASUREMENT_DANGLING_COLOR } from '../../lib/measurements'
17
16
  import { cn } from '../../lib/utils'
18
17
  import useEditor from '../../store/use-editor'
19
18
  import { isViewVisible, VIEW_3D, visibleScene } from '../../store/view-layout'
@@ -1,10 +1,10 @@
1
1
  'use client'
2
2
 
3
3
  import { type AnyNodeId, emitter, type OpenWallEnd } from '@pascal-app/core'
4
+ import { MEASUREMENT_DANGLING_COLOR } from '@pascal-app/viewer'
4
5
  import { useEffect, useState } from 'react'
5
6
  import { createPortal } from 'react-dom'
6
7
  import { openWallEndsSummary } from '../../lib/floorplan/open-wall-ends'
7
- import { MEASUREMENT_DANGLING_COLOR } from '../../lib/measurements'
8
8
 
9
9
  // The viewer area holds the floor plan and the 3D view side by side, so its
10
10
  // top centre clears both views' corner controls in 2D, 3D and split alike.
@@ -1,9 +1,9 @@
1
1
  'use client'
2
2
 
3
3
  import type { QuickMeasurementMetric, QuickMeasurementReport } from '@pascal-app/core'
4
+ import { formatAreaLabel, formatLinearMeasurement, formatVolumeLabel } from '@pascal-app/core'
4
5
  import { Crosshair, MapPin } from 'lucide-react'
5
6
  import { memo } from 'react'
6
- import { formatAreaLabel, formatLinearMeasurement, formatVolumeLabel } from '../../lib/measurements'
7
7
 
8
8
  function formatMetric(
9
9
  metric: QuickMeasurementMetric,
@@ -6,7 +6,9 @@ import {
6
6
  DEFAULT_LEVEL_HEIGHT,
7
7
  deleteZone,
8
8
  emitter,
9
+ formatLinearMeasurement,
9
10
  type Point,
11
+ roomFloorElevation,
10
12
  type StructureNodes,
11
13
  sceneRegistry,
12
14
  useScene,
@@ -35,7 +37,6 @@ import { getRoomSelectionIndex } from '../../hooks/use-selected-room'
35
37
  import { EDITOR_LAYER } from '../../lib/constants'
36
38
  import { getFloatingMenuScale } from '../../lib/floating-menu-scale'
37
39
  import { clientToPlan } from '../../lib/floorplan/plan-coords'
38
- import { formatLinearMeasurement } from '../../lib/measurements'
39
40
  import { addMezzanineStairs } from '../../lib/mezzanine-stairs'
40
41
  import {
41
42
  cancelRoomDivide,
@@ -46,7 +47,7 @@ import {
46
47
  roomDivideContains,
47
48
  startRoomDivide,
48
49
  } from '../../lib/room-divide-session'
49
- import { roomFloorElevation, useRoomHandleDrag } from '../../lib/room-handle-drag'
50
+ import { useRoomHandleDrag } from '../../lib/room-handle-drag'
50
51
  import type { RoomSelectionRecord } from '../../lib/room-selection'
51
52
  import { requestRoomDeletion } from '../../lib/room-structure-commands'
52
53
  import {
@@ -4,6 +4,7 @@ import {
4
4
  type AnyNode,
5
5
  type AnyNodeId,
6
6
  type FloorplanGeometry,
7
+ formatLinearMeasurement,
7
8
  nodeRegistry,
8
9
  type Point,
9
10
  roomDrawnFloor,
@@ -15,7 +16,7 @@ import { type PointerEvent as ReactPointerEvent, useMemo, useState } from 'react
15
16
  import { useShallow } from 'zustand/react/shallow'
16
17
  import { useRoomRecords, useSelectedRoom } from '../../hooks/use-selected-room'
17
18
  import { getFloorplanNodeExtension } from '../../lib/floorplan/floorplan-extension'
18
- import { formatLinearMeasurement, MEASUREMENT_FLOORPLAN_COLOR } from '../../lib/measurements'
19
+ import { MEASUREMENT_FLOORPLAN_COLOR } from '../../lib/measurements'
19
20
  import {
20
21
  commitRoomFloorElevation,
21
22
  roomRelativeFloorHeight,
@@ -3,13 +3,15 @@
3
3
  import {
4
4
  type AnyNodeId,
5
5
  clampRoomFloorHandle,
6
+ formatLinearMeasurement,
6
7
  type Point,
7
8
  roomDrawnFloor,
9
+ roomFloorElevation,
8
10
  sceneRegistry,
9
11
  usePascalI18n,
10
12
  useScene,
11
13
  } from '@pascal-app/core'
12
- import { useViewer } from '@pascal-app/viewer'
14
+ import { MEASUREMENT_ACTIVE_COLOR, useViewer } from '@pascal-app/viewer'
13
15
  import { type ThreeEvent, useFrame, useThree } from '@react-three/fiber'
14
16
  import { useEffect, useMemo, useRef, useState } from 'react'
15
17
  import {
@@ -35,7 +37,6 @@ import {
35
37
  resolveStructuralElevationSnap,
36
38
  } from '../../lib/elevation-guides'
37
39
  import { parseDimensionInput } from '../../lib/floorplan/dimension-drive'
38
- import { formatLinearMeasurement, MEASUREMENT_ACTIVE_COLOR } from '../../lib/measurements'
39
40
  import { commitRoomFloorElevation, roomFloorBase } from '../../lib/room-construction-commands'
40
41
  import {
41
42
  type MezzanineEdgeHandle,
@@ -44,7 +45,6 @@ import {
44
45
  ownFloorElevationBounds,
45
46
  ROOM_ELEVATION_DRAG_LABEL,
46
47
  type RoomHandleDrag,
47
- roomFloorElevation,
48
48
  roomPushHandles,
49
49
  runMezzanineEdgeDrag,
50
50
  runRoomHandleDrag,
@@ -8,11 +8,15 @@ import {
8
8
  DEFAULT_LEVEL_HEIGHT,
9
9
  emitter,
10
10
  formatNumber,
11
+ getAreaUnitLabel,
12
+ type LinearUnit,
11
13
  type PascalI18n,
12
14
  type Polygon,
13
15
  polygonInteriorPoint,
14
16
  type Ring,
17
+ roomFloorElevation,
15
18
  sceneRegistry,
19
+ squareMetersToAreaUnit,
16
20
  useLocale,
17
21
  usePascalI18n,
18
22
  useScene,
@@ -48,8 +52,6 @@ import {
48
52
  } from 'three'
49
53
  import { useShallow } from 'zustand/react/shallow'
50
54
  import { useRoomRecords } from '../../hooks/use-selected-room'
51
- import { getAreaUnitLabel, type LinearUnit, squareMetersToAreaUnit } from '../../lib/measurements'
52
- import { roomFloorElevation } from '../../lib/room-handle-drag'
53
55
  import { displayRoomName } from '../../lib/room-name-messages'
54
56
  import type { RoomSelectionRecord } from '../../lib/room-selection'
55
57
  import { clickZoneArea, hoverZoneArea, leaveZoneArea } from '../../lib/room-zone-routing'
@@ -1,13 +1,17 @@
1
1
  'use client'
2
2
 
3
3
  import type { SiteNode } from '@pascal-app/core'
4
- import { sceneRegistry, useLiveNodeOverrides, useScene } from '@pascal-app/core'
4
+ import {
5
+ formatLinearMeasurement,
6
+ sceneRegistry,
7
+ useLiveNodeOverrides,
8
+ useScene,
9
+ } from '@pascal-app/core'
5
10
  import { getSceneTheme, useViewer } from '@pascal-app/viewer'
6
11
  import { createPortal, useFrame, useThree } from '@react-three/fiber'
7
12
  import { useCallback, useMemo, useRef, useState } from 'react'
8
13
  import { type Camera, type Object3D, Vector3 } from 'three'
9
14
  import { groundHeightAt } from '../../lib/ground-surface'
10
- import { formatLinearMeasurement } from '../../lib/measurements'
11
15
  import { SITE_BOUNDARY_DRAG_LABEL } from '../../lib/site-boundary'
12
16
  import { useActiveHandleDrag } from '../../store/use-interaction-scope'
13
17
  import { Html } from './localized-html'
@@ -4,6 +4,7 @@ import {
4
4
  type AnyNode,
5
5
  type AnyNodeId,
6
6
  calculateLevelMiters,
7
+ formatLinearMeasurement,
7
8
  getWallCurveLength,
8
9
  getWallEffectiveHeightForNodes,
9
10
  getWallMiterBoundaryPoints,
@@ -23,7 +24,6 @@ import { getSceneTheme, useViewer } from '@pascal-app/viewer'
23
24
  import { createPortal, useFrame } from '@react-three/fiber'
24
25
  import { useMemo, useState } from 'react'
25
26
  import * as THREE from 'three'
26
- import { formatLinearMeasurement } from '../../lib/measurements'
27
27
  import { Html } from './localized-html'
28
28
 
29
29
  const GUIDE_Y_OFFSET = 0.08
@@ -315,16 +315,25 @@ function buildMeasurementGuide(
315
315
  const measurementPoints = measurementLine ?? fallbackMiddlePoints
316
316
  if (!measurementPoints) return null
317
317
 
318
- const height = getWallEffectiveHeightForNodes(wall, nodes)
318
+ const heightStart = getWallEffectiveHeightForNodes(wall, nodes, 0)
319
+ const heightEnd = getWallEffectiveHeightForNodes(wall, nodes, 1)
319
320
  const startLocal = worldPointToWallLocal(wall, measurementPoints.start)
320
321
  const endLocal = worldPointToWallLocal(wall, measurementPoints.end)
321
322
  const curvedMeasurementPath = isCurvedWall(wall)
322
323
  ? getCurvedWallMeasurementPath(wall, miterData, levelWalls)
323
324
  : null
325
+ const dx = wall.end[0] - wall.start[0]
326
+ const dz = wall.end[1] - wall.start[1]
327
+ const wallChordLength = Math.hypot(dx, dz)
328
+ const getChordT = (localX: number) =>
329
+ wallChordLength > 1e-6 ? Math.max(0, Math.min(1, localX / wallChordLength)) : 0
330
+
324
331
  const guidePath: Vec3[] = curvedMeasurementPath
325
332
  ? curvedMeasurementPath.map((point) => {
326
333
  const localPoint = worldPointToWallLocal(wall, point)
327
- return [localPoint[0], height + GUIDE_Y_OFFSET, localPoint[2]]
334
+ const t = getChordT(localPoint[0])
335
+ const h = getWallEffectiveHeightForNodes(wall, nodes, t)
336
+ return [localPoint[0], h + GUIDE_Y_OFFSET, localPoint[2]]
328
337
  })
329
338
  : isCurvedWall(wall)
330
339
  ? sampleWallCenterline(wall, 24).map((point, index, points) => {
@@ -334,12 +343,14 @@ function buildMeasurementGuide(
334
343
  : index === points.length - 1
335
344
  ? endLocal
336
345
  : worldPointToWallLocal(wall, point)
346
+ const t = getChordT(localPoint[0])
347
+ const h = getWallEffectiveHeightForNodes(wall, nodes, t)
337
348
 
338
- return [localPoint[0], height + GUIDE_Y_OFFSET, localPoint[2]]
349
+ return [localPoint[0], h + GUIDE_Y_OFFSET, localPoint[2]]
339
350
  })
340
351
  : [
341
- [startLocal[0], height + GUIDE_Y_OFFSET, startLocal[2]],
342
- [endLocal[0], height + GUIDE_Y_OFFSET, endLocal[2]],
352
+ [startLocal[0], heightStart + GUIDE_Y_OFFSET, startLocal[2]],
353
+ [endLocal[0], heightEnd + GUIDE_Y_OFFSET, endLocal[2]],
343
354
  ]
344
355
 
345
356
  if (guidePath.length < 2) return null
@@ -397,26 +408,30 @@ function buildMeasurementGuide(
397
408
  ],
398
409
  })
399
410
  const bottomHeightTick = getHorizontalHeightTick(0)
400
- const topHeightTick = getHorizontalHeightTick(height)
411
+ const topHeightTick = getHorizontalHeightTick(heightEnd)
401
412
 
402
413
  return {
403
414
  guidePath,
404
- extStartStart: [extensionStartBase[0], height, extensionStartBase[2]],
415
+ extStartStart: [extensionStartBase[0], heightStart, extensionStartBase[2]],
405
416
  extStartEnd: [
406
417
  extensionStartBase[0],
407
- height + GUIDE_Y_OFFSET + extOvershoot,
418
+ heightStart + GUIDE_Y_OFFSET + extOvershoot,
408
419
  extensionStartBase[2],
409
420
  ],
410
- extEndStart: [extensionEndBase[0], height, extensionEndBase[2]],
411
- extEndEnd: [extensionEndBase[0], height + GUIDE_Y_OFFSET + extOvershoot, extensionEndBase[2]],
421
+ extEndStart: [extensionEndBase[0], heightEnd, extensionEndBase[2]],
422
+ extEndEnd: [
423
+ extensionEndBase[0],
424
+ heightEnd + GUIDE_Y_OFFSET + extOvershoot,
425
+ extensionEndBase[2],
426
+ ],
412
427
  labelPosition: [midpoint[0], midpoint[1] + LABEL_LIFT, midpoint[2]],
413
428
  heightStart: [heightGuidePosition[0], 0, heightGuidePosition[2]],
414
- heightEnd: [heightGuidePosition[0], height, heightGuidePosition[2]],
429
+ heightEnd: [heightGuidePosition[0], heightEnd, heightGuidePosition[2]],
415
430
  heightBottomTickStart: bottomHeightTick.start,
416
431
  heightBottomTickEnd: bottomHeightTick.end,
417
432
  heightTopTickStart: topHeightTick.start,
418
433
  heightTopTickEnd: topHeightTick.end,
419
- heightLabelPosition: [heightGuidePosition[0], height / 2, heightGuidePosition[2]],
434
+ heightLabelPosition: [heightGuidePosition[0], heightEnd / 2, heightGuidePosition[2]],
420
435
  }
421
436
  }
422
437
 
@@ -530,7 +545,8 @@ function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
530
545
  return total
531
546
  }, [guide, wall])
532
547
  const label = formatLinearMeasurement(length, unit, metricNotation)
533
- const height = useMemo(() => getWallEffectiveHeightForNodes(wall, nodes), [nodes, wall])
548
+ // Height annotation uses t=1 (wall end) because the vertical guide is drawn at the endpoint
549
+ const height = useMemo(() => getWallEffectiveHeightForNodes(wall, nodes, 1), [nodes, wall])
534
550
  const heightLabel = `H ${formatLinearMeasurement(height, unit, metricNotation)}`
535
551
 
536
552
  if (!(guide && Number.isFinite(length) && length >= 0.01)) return null