@pascal-app/editor 0.9.2 → 1.0.0-beta.1

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 (203) hide show
  1. package/package.json +9 -7
  2. package/src/components/editor/bake-exporter.tsx +3 -1
  3. package/src/components/editor/bake-thumbnail.tsx +95 -0
  4. package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
  5. package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
  6. package/src/components/editor/custom-camera-controls.tsx +307 -212
  7. package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
  8. package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +66 -2
  10. package/src/components/editor/first-person/build-collider-world.ts +20 -0
  11. package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
  12. package/src/components/editor/first-person/terrain-collider.ts +212 -0
  13. package/src/components/editor/first-person-controls.tsx +295 -132
  14. package/src/components/editor/floating-action-menu.tsx +59 -65
  15. package/src/components/editor/floorplan-background-selection.test.ts +16 -2
  16. package/src/components/editor/floorplan-background-selection.ts +4 -18
  17. package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
  18. package/src/components/editor/floorplan-camera-sync.ts +213 -0
  19. package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
  20. package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
  21. package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
  22. package/src/components/editor/floorplan-panel.tsx +931 -861
  23. package/src/components/editor/group-actions.ts +155 -15
  24. package/src/components/editor/group-floating-action-menu.tsx +1 -1
  25. package/src/components/editor/group-rotate-handle.tsx +1 -1
  26. package/src/components/editor/group-selection-box-3d.tsx +1 -1
  27. package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
  28. package/src/components/editor/handles/handle-drag-history.ts +14 -0
  29. package/src/components/editor/handles/preview-overrides.test.ts +26 -0
  30. package/src/components/editor/handles/preview-overrides.ts +13 -0
  31. package/src/components/editor/handles/resize-snap.test.ts +52 -0
  32. package/src/components/editor/handles/resize-snap.ts +23 -0
  33. package/src/components/editor/handles/use-handle-drag.ts +17 -7
  34. package/src/components/editor/index.tsx +42 -9
  35. package/src/components/editor/measurement-pill.tsx +6 -1
  36. package/src/components/editor/node-arrow-handles.tsx +51 -15
  37. package/src/components/editor/quick-measurement-card.tsx +9 -3
  38. package/src/components/editor/quick-measurement-hud.tsx +7 -1
  39. package/src/components/editor/selection-manager.tsx +3 -1
  40. package/src/components/editor/site-edge-labels.tsx +24 -2
  41. package/src/components/editor/snapshot-capture-overlay.tsx +4 -1
  42. package/src/components/editor/thumbnail-generator.tsx +66 -294
  43. package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
  44. package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
  45. package/src/components/editor/use-mesh-settle-epoch.ts +1 -0
  46. package/src/components/editor/wall-measurement-label.tsx +8 -16
  47. package/src/components/editor/wall-move-side-handles.tsx +193 -18
  48. package/src/components/editor/wall-opening-highlights.tsx +63 -32
  49. package/src/components/editor/wall-snap-beacon-layer.tsx +6 -4
  50. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
  51. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +39 -8
  52. package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
  53. package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
  54. package/src/components/editor-2d/floorplan-group-move.tsx +47 -4
  55. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +21 -5
  56. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
  57. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +93 -59
  58. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +27 -0
  59. package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
  60. package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
  61. package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
  62. package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
  63. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
  64. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
  65. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
  66. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
  67. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +75 -1
  68. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +92 -0
  69. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +93 -28
  70. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +279 -0
  71. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +621 -281
  72. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
  73. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +12 -7
  74. package/src/components/tools/fence/fence-drafting.ts +27 -0
  75. package/src/components/tools/item/use-draft-node.ts +131 -72
  76. package/src/components/tools/item/use-placement-coordinator.tsx +52 -4
  77. package/src/components/tools/registry/move-registry-node-tool.tsx +75 -4
  78. package/src/components/tools/roof/roof-tool.tsx +16 -0
  79. package/src/components/tools/shared/floor-stack-preview.ts +4 -0
  80. package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
  81. package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
  82. package/src/components/tools/shared/pointer-support-cap.ts +273 -0
  83. package/src/components/tools/site/site-boundary-editor.tsx +13 -6
  84. package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
  85. package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
  86. package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
  87. package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
  88. package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
  89. package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
  90. package/src/components/tools/stair/stair-click-guard.ts +72 -0
  91. package/src/components/tools/stair/stair-tool.tsx +98 -47
  92. package/src/components/tools/tool-manager.tsx +67 -9
  93. package/src/components/tools/wall/wall-drafting.test.ts +118 -1
  94. package/src/components/tools/wall/wall-drafting.ts +128 -9
  95. package/src/components/tools/wall/wall-snap-geometry.test.ts +8 -0
  96. package/src/components/tools/wall/wall-snap-geometry.ts +70 -3
  97. package/src/components/tools/zone/zone-tool.tsx +16 -0
  98. package/src/components/ui/action-menu/control-modes.tsx +4 -1
  99. package/src/components/ui/action-menu/measurement-control.tsx +107 -28
  100. package/src/components/ui/command-palette/editor-commands.tsx +14 -1
  101. package/src/components/ui/controls/material-paint-panel.tsx +7 -1
  102. package/src/components/ui/controls/material-picker.tsx +86 -7
  103. package/src/components/ui/controls/terrain-sculpt-panel.tsx +193 -0
  104. package/src/components/ui/floating-level-selector.tsx +73 -19
  105. package/src/components/ui/helpers/helper-manager.tsx +46 -0
  106. package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
  107. package/src/components/ui/panels/panel-manager.tsx +14 -1
  108. package/src/components/ui/panels/parametric-inspector.tsx +4 -2
  109. package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
  110. package/src/components/ui/panels/selection-breakdown.ts +20 -0
  111. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +14 -4
  112. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +16 -0
  113. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
  114. package/src/components/ui/sidebar/panels/site-panel/index.tsx +2 -0
  115. package/src/components/viewer/viewer-controls-bar.tsx +466 -0
  116. package/src/components/viewer/viewer-scene-header.tsx +235 -0
  117. package/src/components/viewer-overlay.tsx +20 -655
  118. package/src/components/walkthrough-hud.tsx +111 -0
  119. package/src/hooks/use-grid-events.ts +24 -0
  120. package/src/hooks/use-keyboard.ts +53 -30
  121. package/src/index.tsx +137 -4
  122. package/src/lib/active-placement-surface.test.ts +24 -0
  123. package/src/lib/active-placement-surface.ts +8 -1
  124. package/src/lib/camera-pose.test.ts +237 -0
  125. package/src/lib/camera-pose.ts +172 -0
  126. package/src/lib/direct-manipulation.test.ts +83 -0
  127. package/src/lib/direct-manipulation.ts +9 -0
  128. package/src/lib/door-interaction.ts +1 -1
  129. package/src/lib/elevation-guides.test.ts +107 -0
  130. package/src/lib/elevation-guides.ts +236 -0
  131. package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
  132. package/src/lib/floorplan/annotation-visibility.ts +133 -0
  133. package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
  134. package/src/lib/floorplan/drawing-coordination.ts +13 -0
  135. package/src/lib/floorplan/floorplan-export.test.ts +319 -0
  136. package/src/lib/floorplan/floorplan-export.tsx +745 -131
  137. package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
  138. package/src/lib/floorplan/floorplan-extension.ts +181 -0
  139. package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
  140. package/src/lib/floorplan/floorplan-mode.ts +73 -0
  141. package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
  142. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
  143. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
  144. package/src/lib/floorplan/geometry.ts +2 -3
  145. package/src/lib/fresh-planar-placement.test.ts +44 -1
  146. package/src/lib/fresh-planar-placement.ts +21 -4
  147. package/src/lib/glb-export.test.ts +116 -1
  148. package/src/lib/glb-export.ts +204 -7
  149. package/src/lib/ground-surface.ts +95 -0
  150. package/src/lib/history.test.ts +96 -0
  151. package/src/lib/history.ts +22 -0
  152. package/src/lib/interaction/overlay-policy.test.ts +2 -1
  153. package/src/lib/interaction/scope.ts +40 -8
  154. package/src/lib/level-duplication.test.ts +2 -1
  155. package/src/lib/measurements.test.ts +5 -0
  156. package/src/lib/measurements.ts +12 -1
  157. package/src/lib/quick-action-feedback.ts +33 -0
  158. package/src/lib/quick-action-nodes.test.ts +94 -0
  159. package/src/lib/quick-action-nodes.ts +47 -0
  160. package/src/lib/scene-clipboard.test.ts +130 -1
  161. package/src/lib/scene-clipboard.ts +237 -13
  162. package/src/lib/selection-routing.test.ts +16 -1
  163. package/src/lib/selection-routing.ts +5 -0
  164. package/src/lib/sfx-bus.ts +33 -2
  165. package/src/lib/sfx-player.test.ts +148 -6
  166. package/src/lib/sfx-player.ts +156 -8
  167. package/src/lib/site-boundary.test.ts +53 -0
  168. package/src/lib/site-boundary.ts +27 -0
  169. package/src/lib/snapping-mode.test.ts +29 -2
  170. package/src/lib/snapping-mode.ts +15 -3
  171. package/src/lib/stair-duplication.test.ts +103 -0
  172. package/src/lib/stair-duplication.ts +57 -85
  173. package/src/lib/stair-levels.ts +2 -0
  174. package/src/lib/terrain-sculpt.test.ts +429 -0
  175. package/src/lib/terrain-sculpt.ts +301 -0
  176. package/src/lib/terrain-verb-color.test.ts +51 -0
  177. package/src/lib/terrain-verb-color.ts +58 -0
  178. package/src/lib/touch-gesture-priority.test.ts +45 -0
  179. package/src/lib/touch-gesture-priority.ts +30 -0
  180. package/src/lib/window-interaction.ts +1 -1
  181. package/src/store/camera-pose-store.test.ts +29 -0
  182. package/src/store/camera-pose-store.ts +27 -0
  183. package/src/store/live-draft-preview-stores.test.ts +136 -0
  184. package/src/store/navigation-sync-pose-store.test.ts +34 -0
  185. package/src/store/navigation-sync-pose-store.ts +27 -0
  186. package/src/store/terrain-sculpt-mode.test.ts +223 -0
  187. package/src/store/use-delete-confirmation.ts +27 -0
  188. package/src/store/use-drawing-view.test.ts +23 -0
  189. package/src/store/use-drawing-view.ts +84 -0
  190. package/src/store/use-editor.tsx +184 -15
  191. package/src/store/use-elevation-guides.ts +40 -0
  192. package/src/store/use-fence-curve-draft.ts +38 -8
  193. package/src/store/use-first-person-hud.ts +40 -0
  194. package/src/store/use-floorplan-annotation-visibility.ts +60 -0
  195. package/src/store/use-floorplan-draft-preview.ts +54 -4
  196. package/src/store/use-floorplan-mode.ts +110 -0
  197. package/src/store/use-interaction-scope.test.ts +23 -3
  198. package/src/store/use-interaction-scope.ts +8 -0
  199. package/src/store/use-measurement-draft.test.ts +29 -0
  200. package/src/store/use-measurement-draft.ts +33 -6
  201. package/src/store/use-path-draft-preview.ts +90 -0
  202. package/src/store/use-stair-build-preview.ts +12 -2
  203. package/src/lib/floorplan/selection-tool.ts +0 -271
@@ -1,7 +1,15 @@
1
1
  'use client'
2
2
 
3
3
  import type { FloorplanPalette } from '@pascal-app/core'
4
- import { createContext, type ReactNode, useContext, useMemo } from 'react'
4
+ import {
5
+ createContext,
6
+ type ReactNode,
7
+ useContext,
8
+ useLayoutEffect,
9
+ useMemo,
10
+ useRef,
11
+ useSyncExternalStore,
12
+ } from 'react'
5
13
 
6
14
  /**
7
15
  * Per-frame render context shared between the legacy `floorplan-panel.tsx`
@@ -34,7 +42,43 @@ export type FloorplanRenderContextValue = {
34
42
  sceneRotationDeg: number
35
43
  }
36
44
 
37
- const FloorplanRenderContext = createContext<FloorplanRenderContextValue | null>(null)
45
+ export type FloorplanStaticRenderContextValue = Omit<
46
+ FloorplanRenderContextValue,
47
+ 'sceneRotationDeg' | 'unitsPerPixel'
48
+ > & {
49
+ getSceneRotationDeg: () => number
50
+ getUnitsPerPixel: () => number
51
+ subscribeUnitsPerPixel: (listener: () => void) => () => void
52
+ }
53
+
54
+ const FloorplanStaticRenderContext = createContext<FloorplanStaticRenderContextValue | null>(null)
55
+ const FloorplanSceneRotationContext = createContext(0)
56
+ const FloorplanUnitsPerPixelContext = createContext(1)
57
+
58
+ export type FloorplanRenderScaleReference = {
59
+ read: () => number
60
+ subscribe: (listener: () => void) => () => void
61
+ update: (unitsPerPixel: number) => void
62
+ }
63
+
64
+ export function createFloorplanRenderScaleReference(
65
+ initialUnitsPerPixel: number,
66
+ ): FloorplanRenderScaleReference {
67
+ let unitsPerPixel = initialUnitsPerPixel
68
+ const listeners = new Set<() => void>()
69
+ return {
70
+ read: () => unitsPerPixel,
71
+ subscribe: (listener) => {
72
+ listeners.add(listener)
73
+ return () => listeners.delete(listener)
74
+ },
75
+ update: (nextUnitsPerPixel) => {
76
+ if (Object.is(unitsPerPixel, nextUnitsPerPixel)) return
77
+ unitsPerPixel = nextUnitsPerPixel
78
+ for (const listener of listeners) listener()
79
+ },
80
+ }
81
+ }
38
82
 
39
83
  export function FloorplanRenderProvider({
40
84
  children,
@@ -42,12 +86,39 @@ export function FloorplanRenderProvider({
42
86
  palette,
43
87
  hatchPatternId,
44
88
  sceneRotationDeg,
45
- }: FloorplanRenderContextValue & { children: ReactNode }) {
46
- const value = useMemo<FloorplanRenderContextValue>(
47
- () => ({ unitsPerPixel, palette, hatchPatternId, sceneRotationDeg }),
48
- [unitsPerPixel, palette, hatchPatternId, sceneRotationDeg],
89
+ getSceneRotationDeg,
90
+ }: FloorplanRenderContextValue & {
91
+ children: ReactNode
92
+ getSceneRotationDeg: () => number
93
+ }) {
94
+ const renderScaleReference = useRef<FloorplanRenderScaleReference | null>(null)
95
+ if (!renderScaleReference.current) {
96
+ renderScaleReference.current = createFloorplanRenderScaleReference(unitsPerPixel)
97
+ }
98
+ const getUnitsPerPixel = renderScaleReference.current.read
99
+ const subscribeUnitsPerPixel = renderScaleReference.current.subscribe
100
+ useLayoutEffect(() => {
101
+ renderScaleReference.current?.update(unitsPerPixel)
102
+ }, [unitsPerPixel])
103
+ const staticValue = useMemo<FloorplanStaticRenderContextValue>(
104
+ () => ({
105
+ palette,
106
+ hatchPatternId,
107
+ getSceneRotationDeg,
108
+ getUnitsPerPixel,
109
+ subscribeUnitsPerPixel,
110
+ }),
111
+ [palette, hatchPatternId, getSceneRotationDeg, getUnitsPerPixel, subscribeUnitsPerPixel],
112
+ )
113
+ return (
114
+ <FloorplanStaticRenderContext.Provider value={staticValue}>
115
+ <FloorplanUnitsPerPixelContext.Provider value={unitsPerPixel}>
116
+ <FloorplanSceneRotationContext.Provider value={sceneRotationDeg}>
117
+ {children}
118
+ </FloorplanSceneRotationContext.Provider>
119
+ </FloorplanUnitsPerPixelContext.Provider>
120
+ </FloorplanStaticRenderContext.Provider>
49
121
  )
50
- return <FloorplanRenderContext.Provider value={value}>{children}</FloorplanRenderContext.Provider>
51
122
  }
52
123
 
53
124
  /**
@@ -58,5 +129,39 @@ export function FloorplanRenderProvider({
58
129
  * whole legacy panel along.
59
130
  */
60
131
  export function useFloorplanRender(): FloorplanRenderContextValue | null {
61
- return useContext(FloorplanRenderContext)
132
+ const staticValue = useContext(FloorplanStaticRenderContext)
133
+ const sceneRotationDeg = useContext(FloorplanSceneRotationContext)
134
+ const unitsPerPixel = useContext(FloorplanUnitsPerPixelContext)
135
+ return useMemo(
136
+ () =>
137
+ staticValue
138
+ ? {
139
+ unitsPerPixel,
140
+ palette: staticValue.palette,
141
+ hatchPatternId: staticValue.hatchPatternId,
142
+ sceneRotationDeg,
143
+ }
144
+ : null,
145
+ [sceneRotationDeg, staticValue, unitsPerPixel],
146
+ )
147
+ }
148
+
149
+ export function useFloorplanStaticRender(): FloorplanStaticRenderContextValue | null {
150
+ return useContext(FloorplanStaticRenderContext)
151
+ }
152
+
153
+ const subscribeToNoFloorplanScale = () => () => {}
154
+ const readDefaultFloorplanScale = () => 1
155
+
156
+ export function useFloorplanStaticUnitsPerPixel(): number {
157
+ const staticValue = useContext(FloorplanStaticRenderContext)
158
+ return useSyncExternalStore(
159
+ staticValue?.subscribeUnitsPerPixel ?? subscribeToNoFloorplanScale,
160
+ staticValue?.getUnitsPerPixel ?? readDefaultFloorplanScale,
161
+ staticValue?.getUnitsPerPixel ?? readDefaultFloorplanScale,
162
+ )
163
+ }
164
+
165
+ export function useFloorplanSceneRotation(): number {
166
+ return useContext(FloorplanSceneRotationContext)
62
167
  }
@@ -0,0 +1,285 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { floorplanGeometryMetadata } from '../../../lib/floorplan/floorplan-extension'
3
+ import {
4
+ floorplanAnnotationObstacleMode,
5
+ polylineObstacleRectangles,
6
+ resolveAnnotationLabelRectangles,
7
+ resolveSvgAnnotationCollisions,
8
+ } from './floorplan-annotation-layout'
9
+
10
+ describe('floorplanAnnotationObstacleMode', () => {
11
+ test('treats fixed annotation categories as layout obstacles', () => {
12
+ expect(
13
+ floorplanAnnotationObstacleMode({
14
+ kind: 'text',
15
+ x: 0,
16
+ y: 0,
17
+ text: 'BEDROOM',
18
+ fontSize: 0.18,
19
+ metadata: floorplanGeometryMetadata({ annotationRole: 'room-label' }),
20
+ }),
21
+ ).toBe('bounds')
22
+ expect(
23
+ floorplanAnnotationObstacleMode({
24
+ kind: 'line',
25
+ x1: 0,
26
+ y1: 0,
27
+ x2: 1,
28
+ y2: 0,
29
+ metadata: floorplanGeometryMetadata({ annotationRole: 'column-center' }),
30
+ }),
31
+ ).toBe('bounds')
32
+ expect(
33
+ floorplanAnnotationObstacleMode({
34
+ kind: 'polyline',
35
+ points: [
36
+ [0, 0],
37
+ [1, 0],
38
+ ],
39
+ metadata: floorplanGeometryMetadata({ annotationRole: 'stair-annotation' }),
40
+ }),
41
+ ).toBe('outline')
42
+ })
43
+ })
44
+
45
+ describe('resolveAnnotationLabelRectangles', () => {
46
+ test('keeps the higher-priority label and moves the conflicting label', () => {
47
+ const shifts = resolveAnnotationLabelRectangles([
48
+ { id: 'overall', x: 0, y: 0, width: 80, height: 12, priority: 100 },
49
+ { id: 'opening', x: 20, y: 0, width: 50, height: 12, priority: 50 },
50
+ ])
51
+
52
+ expect(shifts.find((entry) => entry.id === 'overall')).toMatchObject({ dx: 0, dy: 0 })
53
+ expect(shifts.find((entry) => entry.id === 'opening')).not.toMatchObject({ dx: 0, dy: 0 })
54
+ expect(shifts.every((entry) => entry.resolved)).toBe(true)
55
+ })
56
+
57
+ test('keeps pinned labels at their drawing-view override and routes other labels around them', () => {
58
+ const shifts = resolveAnnotationLabelRectangles([
59
+ {
60
+ id: 'pinned',
61
+ x: 0,
62
+ y: 0,
63
+ width: 80,
64
+ height: 12,
65
+ priority: 1,
66
+ pinnedShift: { dx: 30, dy: 0 },
67
+ },
68
+ { id: 'automatic', x: 30, y: 0, width: 80, height: 12, priority: 100 },
69
+ ])
70
+
71
+ expect(shifts.find((entry) => entry.id === 'pinned')).toEqual({
72
+ id: 'pinned',
73
+ dx: 30,
74
+ dy: 0,
75
+ resolved: true,
76
+ })
77
+ expect(shifts.find((entry) => entry.id === 'automatic')).not.toMatchObject({
78
+ dx: 0,
79
+ dy: 0,
80
+ })
81
+ })
82
+
83
+ test('does not move labels that are already clear', () => {
84
+ expect(
85
+ resolveAnnotationLabelRectangles([
86
+ { id: 'left', x: 0, y: 0, width: 40, height: 12, priority: 10 },
87
+ { id: 'right', x: 100, y: 0, width: 40, height: 12, priority: 10 },
88
+ ]),
89
+ ).toEqual([
90
+ { id: 'left', dx: 0, dy: 0, resolved: true },
91
+ { id: 'right', dx: 0, dy: 0, resolved: true },
92
+ ])
93
+ })
94
+
95
+ test('preserves drawing order for labels on the same dimension string', () => {
96
+ const shifts = resolveAnnotationLabelRectangles([
97
+ { id: 'first', x: 0, y: 0, width: 20, height: 12, priority: 10 },
98
+ { id: 'second', x: 0, y: 0, width: 80, height: 12, priority: 10 },
99
+ ])
100
+
101
+ expect(shifts.find((entry) => entry.id === 'first')).toMatchObject({ dx: 0, dy: 0 })
102
+ expect(shifts.find((entry) => entry.id === 'second')).not.toMatchObject({ dx: 0, dy: 0 })
103
+ })
104
+
105
+ test('slides a colliding label along its dimension string before crossing tiers', () => {
106
+ const shifts = resolveAnnotationLabelRectangles([
107
+ { id: 'datum', x: 0, y: 0, width: 40, height: 12, priority: 20 },
108
+ {
109
+ id: 'adjacent',
110
+ x: 0,
111
+ y: 0,
112
+ width: 40,
113
+ height: 12,
114
+ priority: 10,
115
+ tangentX: 1,
116
+ tangentY: 0,
117
+ },
118
+ ])
119
+
120
+ expect(shifts.find((entry) => entry.id === 'adjacent')).toMatchObject({ dy: 0, resolved: true })
121
+ expect(shifts.find((entry) => entry.id === 'adjacent')?.dx).not.toBe(0)
122
+ })
123
+
124
+ test('tries a short dimension alternative before generic relocation', () => {
125
+ const shifts = resolveAnnotationLabelRectangles(
126
+ [
127
+ {
128
+ id: 'short-dimension',
129
+ x: 0,
130
+ y: 0,
131
+ width: 40,
132
+ height: 12,
133
+ priority: 10,
134
+ preferredShifts: [{ dx: 100, dy: 0 }],
135
+ },
136
+ ],
137
+ [{ x: 0, y: 0, width: 40, height: 12 }],
138
+ )
139
+
140
+ expect(shifts).toEqual([{ id: 'short-dimension', dx: 100, dy: 0, resolved: true }])
141
+ })
142
+
143
+ test('falls back to a third position when both short-dimension sides are blocked', () => {
144
+ const shifts = resolveAnnotationLabelRectangles(
145
+ [
146
+ {
147
+ id: 'short-dimension',
148
+ x: 0,
149
+ y: 0,
150
+ width: 40,
151
+ height: 12,
152
+ priority: 10,
153
+ preferredShifts: [{ dx: 100, dy: 0 }],
154
+ },
155
+ ],
156
+ [
157
+ { x: 0, y: 0, width: 40, height: 12 },
158
+ { x: 100, y: 0, width: 40, height: 12 },
159
+ ],
160
+ )
161
+
162
+ expect(shifts[0]).toMatchObject({ id: 'short-dimension', resolved: true })
163
+ expect(shifts[0]).not.toMatchObject({ dx: 0, dy: 0 })
164
+ expect(shifts[0]).not.toMatchObject({ dx: 100, dy: 0 })
165
+ })
166
+
167
+ test('approximates diagonal outlines without blocking their full bounding box', () => {
168
+ expect(
169
+ polylineObstacleRectangles([
170
+ { x: 0, y: 0 },
171
+ { x: 4, y: 4 },
172
+ { x: 8, y: 8 },
173
+ ]),
174
+ ).toEqual([
175
+ { x: -1, y: -1, width: 6, height: 6 },
176
+ { x: 3, y: 3, width: 6, height: 6 },
177
+ ])
178
+ })
179
+
180
+ test('moves a dimension value clear of a fixed door-mark pill', () => {
181
+ const shifts = resolveAnnotationLabelRectangles(
182
+ [{ id: 'door-width', x: 94, y: 88, width: 42, height: 16, priority: 100 }],
183
+ [{ x: 100, y: 82, width: 48, height: 32 }],
184
+ )
185
+
186
+ expect(shifts).toEqual([expect.objectContaining({ id: 'door-width', resolved: true })])
187
+ const shift = shifts[0]
188
+ expect(shift).not.toMatchObject({ dx: 0, dy: 0 })
189
+ expect(
190
+ 94 + (shift?.dx ?? 0) + 42 + 6 <= 100 ||
191
+ 148 + 6 <= 94 + (shift?.dx ?? 0) ||
192
+ 88 + (shift?.dy ?? 0) + 16 + 6 <= 82 ||
193
+ 114 + 6 <= 88 + (shift?.dy ?? 0),
194
+ ).toBe(true)
195
+ })
196
+
197
+ test('finds separate nearby positions for a dense label cluster', () => {
198
+ const shifts = resolveAnnotationLabelRectangles(
199
+ Array.from({ length: 4 }, (_, index) => ({
200
+ id: `label-${index}`,
201
+ x: 0,
202
+ y: 0,
203
+ width: 40,
204
+ height: 12,
205
+ priority: 10 - index,
206
+ })),
207
+ )
208
+
209
+ expect(new Set(shifts.map(({ dx, dy }) => `${dx},${dy}`))).toHaveLength(4)
210
+ expect(shifts.every((entry) => entry.resolved)).toBe(true)
211
+ })
212
+
213
+ test('keeps a large dense label cluster readable', () => {
214
+ const rectangles = Array.from({ length: 12 }, (_, index) => ({
215
+ id: `label-${index}`,
216
+ x: 100 + (index % 3) * 4,
217
+ y: 100 + (index % 2) * 3,
218
+ width: 48 + (index % 4) * 8,
219
+ height: 14,
220
+ priority: 20 - index,
221
+ }))
222
+ const shifts = resolveAnnotationLabelRectangles(rectangles)
223
+ const placed = rectangles.map((rectangle) => {
224
+ const shift = shifts.find(({ id }) => id === rectangle.id)
225
+ return {
226
+ ...rectangle,
227
+ x: rectangle.x + (shift?.dx ?? 0),
228
+ y: rectangle.y + (shift?.dy ?? 0),
229
+ }
230
+ })
231
+
232
+ expect(shifts.every((entry) => entry.resolved)).toBe(true)
233
+ for (let index = 0; index < placed.length; index += 1) {
234
+ for (let otherIndex = index + 1; otherIndex < placed.length; otherIndex += 1) {
235
+ const left = placed[index]
236
+ const right = placed[otherIndex]
237
+ if (!left || !right) continue
238
+ const overlaps = !(
239
+ left.x + left.width + 6 <= right.x ||
240
+ right.x + right.width + 6 <= left.x ||
241
+ left.y + left.height + 6 <= right.y ||
242
+ right.y + right.height + 6 <= left.y
243
+ )
244
+ expect(overlaps).toBe(false)
245
+ }
246
+ }
247
+ })
248
+
249
+ test('resolves a construction-plan label set without blocking the view transition', () => {
250
+ const labels = Array.from({ length: 25 }, (_, index) => ({
251
+ id: `label-${index}`,
252
+ x: index % 5,
253
+ y: index % 7,
254
+ width: 80,
255
+ height: 20,
256
+ priority: 25 - index,
257
+ }))
258
+ const obstacles = Array.from({ length: 100 }, (_, index) => ({
259
+ x: (index % 10) * 2,
260
+ y: (index % 13) * 2,
261
+ width: 100,
262
+ height: 40,
263
+ }))
264
+
265
+ const startedAt = performance.now()
266
+ const shifts = resolveAnnotationLabelRectangles(labels, obstacles)
267
+ const elapsedMs = performance.now() - startedAt
268
+
269
+ expect(shifts).toHaveLength(labels.length)
270
+ expect(shifts.every((entry) => entry.resolved)).toBe(true)
271
+ expect(elapsedMs).toBeLessThan(500)
272
+ })
273
+ })
274
+
275
+ describe('resolveSvgAnnotationCollisions', () => {
276
+ test('uses captured label references instead of querying for them again', () => {
277
+ const svg = {
278
+ querySelectorAll: () => {
279
+ throw new Error('labels were rediscovered')
280
+ },
281
+ } as unknown as SVGSVGElement
282
+
283
+ expect(resolveSvgAnnotationCollisions(svg, { labels: [] })).toBeUndefined()
284
+ })
285
+ })