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

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 +265 -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
@@ -0,0 +1,516 @@
1
+ import type { FloorplanGeometry } from '@pascal-app/core'
2
+ import { readFloorplanGeometryMetadata } from '../../../lib/floorplan/floorplan-extension'
3
+
4
+ export type AnnotationLabelRectangle = {
5
+ id: string
6
+ x: number
7
+ y: number
8
+ width: number
9
+ height: number
10
+ priority: number
11
+ pinnedShift?: { dx: number; dy: number }
12
+ tangentX?: number
13
+ tangentY?: number
14
+ preferredShifts?: readonly { dx: number; dy: number }[]
15
+ }
16
+
17
+ export type AnnotationObstacleRectangle = Pick<
18
+ AnnotationLabelRectangle,
19
+ 'x' | 'y' | 'width' | 'height'
20
+ >
21
+
22
+ export type AnnotationLabelShift = {
23
+ id: string
24
+ dx: number
25
+ dy: number
26
+ resolved: boolean
27
+ }
28
+
29
+ const LABEL_GAP_PX = 6
30
+ const LABEL_PLACEMENT_GAP_PX = LABEL_GAP_PX + 0.5
31
+ const OUTLINE_SAMPLE_SPACING_PX = 6
32
+ const OUTLINE_OBSTACLE_PADDING_PX = 1
33
+ const MAX_LABEL_SHIFT_CANDIDATES = 512
34
+ const PREFERRED_SHIFT_COST_STEP = 1_000_000
35
+ const COLLISION_GRID_CELL_SIZE_PX = 64
36
+
37
+ class AnnotationObstacleIndex {
38
+ private readonly cells = new Map<string, Set<AnnotationObstacleRectangle>>()
39
+
40
+ add(rectangle: AnnotationObstacleRectangle): void {
41
+ this.forEachCell(rectangle, 0, (key) => {
42
+ let cell = this.cells.get(key)
43
+ if (!cell) {
44
+ cell = new Set()
45
+ this.cells.set(key, cell)
46
+ }
47
+ cell.add(rectangle)
48
+ })
49
+ }
50
+
51
+ findOverlaps(rectangle: AnnotationObstacleRectangle): AnnotationObstacleRectangle[] {
52
+ const candidates = new Set<AnnotationObstacleRectangle>()
53
+ this.forEachCell(rectangle, LABEL_GAP_PX, (key) => {
54
+ for (const candidate of this.cells.get(key) ?? []) candidates.add(candidate)
55
+ })
56
+ return [...candidates].filter((candidate) => rectanglesOverlap(rectangle, candidate))
57
+ }
58
+
59
+ private forEachCell(
60
+ rectangle: AnnotationObstacleRectangle,
61
+ padding: number,
62
+ visit: (key: string) => void,
63
+ ): void {
64
+ const minCellX = Math.floor((rectangle.x - padding) / COLLISION_GRID_CELL_SIZE_PX)
65
+ const maxCellX = Math.floor(
66
+ (rectangle.x + rectangle.width + padding) / COLLISION_GRID_CELL_SIZE_PX,
67
+ )
68
+ const minCellY = Math.floor((rectangle.y - padding) / COLLISION_GRID_CELL_SIZE_PX)
69
+ const maxCellY = Math.floor(
70
+ (rectangle.y + rectangle.height + padding) / COLLISION_GRID_CELL_SIZE_PX,
71
+ )
72
+ for (let cellX = minCellX; cellX <= maxCellX; cellX += 1) {
73
+ for (let cellY = minCellY; cellY <= maxCellY; cellY += 1) visit(`${cellX}:${cellY}`)
74
+ }
75
+ }
76
+ }
77
+
78
+ export type AnnotationLayoutOverride = { dx: number; dy: number; pinned?: boolean }
79
+ export type AnnotationLayoutOverrides = Readonly<Record<string, AnnotationLayoutOverride>>
80
+
81
+ export function resolveAnnotationLabelRectangles(
82
+ rectangles: readonly AnnotationLabelRectangle[],
83
+ obstacles: readonly AnnotationObstacleRectangle[] = [],
84
+ ): AnnotationLabelShift[] {
85
+ const occupied = new AnnotationObstacleIndex()
86
+ for (const obstacle of obstacles) occupied.add(obstacle)
87
+ const shifts = new Map<string, AnnotationLabelShift>()
88
+ const ordered = rectangles
89
+ .map((rectangle, order) => ({ order, rectangle }))
90
+ .sort(
91
+ (left, right) =>
92
+ Number(Boolean(right.rectangle.pinnedShift)) -
93
+ Number(Boolean(left.rectangle.pinnedShift)) ||
94
+ right.rectangle.priority - left.rectangle.priority ||
95
+ left.order - right.order,
96
+ )
97
+
98
+ for (const { rectangle } of ordered) {
99
+ const selected = rectangle.pinnedShift ?? resolveLabelShift(rectangle, occupied)
100
+ const shift = selected ?? { dx: 0, dy: 0 }
101
+ const resolved = rectangle.pinnedShift !== undefined || selected !== undefined
102
+ occupied.add({
103
+ x: rectangle.x + shift.dx,
104
+ y: rectangle.y + shift.dy,
105
+ width: rectangle.width,
106
+ height: rectangle.height,
107
+ })
108
+ shifts.set(rectangle.id, { id: rectangle.id, ...shift, resolved })
109
+ }
110
+
111
+ return rectangles.map(
112
+ (rectangle) => shifts.get(rectangle.id) ?? { id: rectangle.id, dx: 0, dy: 0, resolved: false },
113
+ )
114
+ }
115
+
116
+ export function resolveSvgAnnotationCollisions(
117
+ svg: SVGSVGElement,
118
+ options: {
119
+ labels?: readonly SVGGElement[]
120
+ layoutOverrides?: AnnotationLayoutOverrides
121
+ } = {},
122
+ ): void {
123
+ const labels =
124
+ options.labels ??
125
+ Array.from(svg.querySelectorAll<SVGGElement>('[data-floorplan-annotation-label]'))
126
+ if (labels.length === 0) return
127
+
128
+ for (const label of labels) {
129
+ const defaultTransform = label.dataset.floorplanAnnotationDefaultTransform
130
+ if (defaultTransform !== undefined) label.setAttribute('transform', defaultTransform)
131
+ delete label.dataset.floorplanAnnotationLayoutDx
132
+ delete label.dataset.floorplanAnnotationLayoutDy
133
+ }
134
+ resetDimensionConnectors(svg)
135
+
136
+ const pinnedLocalById = new Map<string, { x: number; y: number }>()
137
+ const rectangles: AnnotationLabelRectangle[] = labels.map((label, index) => {
138
+ const bounds = label.getBoundingClientRect()
139
+ const matrix = label.getScreenCTM()
140
+ const id = svgAnnotationLabelId(label, index)
141
+ label.dataset.floorplanAnnotationId = id
142
+ const override = options.layoutOverrides?.[id]
143
+ const pinnedLocal =
144
+ override?.pinned === true && Number.isFinite(override.dx) && Number.isFinite(override.dy)
145
+ ? { dx: override.dx, dy: override.dy }
146
+ : undefined
147
+ if (pinnedLocal) pinnedLocalById.set(id, { x: pinnedLocal.dx, y: pinnedLocal.dy })
148
+ const pinnedShift =
149
+ pinnedLocal && matrix
150
+ ? {
151
+ dx: matrix.a * pinnedLocal.dx + matrix.c * pinnedLocal.dy,
152
+ dy: matrix.b * pinnedLocal.dx + matrix.d * pinnedLocal.dy,
153
+ }
154
+ : undefined
155
+ const tangentLength = matrix ? Math.hypot(matrix.a, matrix.b) : 0
156
+ const outsideStartLocalX = Number(
157
+ label.dataset.floorplanDimensionOutsideStartLocalX ?? Number.NaN,
158
+ )
159
+ const outsideStartLocalY = Number(
160
+ label.dataset.floorplanDimensionOutsideStartLocalY ?? Number.NaN,
161
+ )
162
+ const preferredShifts =
163
+ matrix && Number.isFinite(outsideStartLocalX) && Number.isFinite(outsideStartLocalY)
164
+ ? [
165
+ {
166
+ dx: matrix.a * outsideStartLocalX + matrix.c * outsideStartLocalY,
167
+ dy: matrix.b * outsideStartLocalX + matrix.d * outsideStartLocalY,
168
+ },
169
+ ]
170
+ : undefined
171
+ return {
172
+ id,
173
+ x: bounds.x,
174
+ y: bounds.y,
175
+ width: bounds.width,
176
+ height: bounds.height,
177
+ priority: Number(label.dataset.floorplanAnnotationPriority ?? 0),
178
+ pinnedShift,
179
+ tangentX: tangentLength > 1e-9 && matrix ? matrix.a / tangentLength : undefined,
180
+ tangentY: tangentLength > 1e-9 && matrix ? matrix.b / tangentLength : undefined,
181
+ preferredShifts,
182
+ }
183
+ })
184
+ const obstacles = Array.from(
185
+ svg.querySelectorAll<SVGGraphicsElement>('[data-floorplan-annotation-obstacle]'),
186
+ ).flatMap(svgAnnotationObstacleRectangles)
187
+ const shifts = resolveAnnotationLabelRectangles(rectangles, obstacles)
188
+
189
+ labels.forEach((label, index) => {
190
+ const rectangle = rectangles[index]
191
+ const shift = rectangle && shifts.find((candidate) => candidate.id === rectangle.id)
192
+ if (!shift || (shift.dx === 0 && shift.dy === 0)) {
193
+ label.dataset.floorplanAnnotationLayoutDx = '0'
194
+ label.dataset.floorplanAnnotationLayoutDy = '0'
195
+ return
196
+ }
197
+ const matrix = label.getScreenCTM()
198
+ if (!matrix) return
199
+ const local = pinnedLocalById.get(shift.id) ?? screenVectorToLocal(matrix, shift.dx, shift.dy)
200
+ label.dataset.floorplanAnnotationLayoutDx = String(local.x)
201
+ label.dataset.floorplanAnnotationLayoutDy = String(local.y)
202
+ const defaultTransform = label.dataset.floorplanAnnotationDefaultTransform ?? ''
203
+ label.setAttribute('transform', `${defaultTransform} translate(${local.x} ${local.y})`.trim())
204
+ const preferredShift = rectangle.preferredShifts?.[0]
205
+ const usedOutsideStart =
206
+ preferredShift !== undefined &&
207
+ Math.hypot(shift.dx - preferredShift.dx, shift.dy - preferredShift.dy) < 0.5
208
+ if (usedOutsideStart) applyOutsideStartDimensionLine(label)
209
+ else if (label.dataset.floorplanDimensionLabelPlacement === 'outside-end') {
210
+ showDimensionLeader(label, matrix, shift.dx, shift.dy)
211
+ }
212
+ })
213
+ }
214
+
215
+ export function svgAnnotationLabelId(label: SVGGElement, index: number): string {
216
+ const explicit = label.dataset.floorplanAnnotationId?.trim()
217
+ if (explicit) return explicit
218
+ const defaultTransform = label.dataset.floorplanAnnotationDefaultTransform ?? ''
219
+ const text = label.textContent?.trim() ?? ''
220
+ return `annotation:${index}:${text}:${defaultTransform}`
221
+ }
222
+
223
+ function resetDimensionConnectors(svg: SVGSVGElement): void {
224
+ for (const line of svg.querySelectorAll<SVGLineElement>('[data-floorplan-dimension-line]')) {
225
+ line.setAttribute(
226
+ 'x1',
227
+ line.dataset.floorplanDimensionDefaultX1 ?? line.getAttribute('x1') ?? '0',
228
+ )
229
+ line.setAttribute(
230
+ 'y1',
231
+ line.dataset.floorplanDimensionDefaultY1 ?? line.getAttribute('y1') ?? '0',
232
+ )
233
+ line.setAttribute(
234
+ 'x2',
235
+ line.dataset.floorplanDimensionDefaultX2 ?? line.getAttribute('x2') ?? '0',
236
+ )
237
+ line.setAttribute(
238
+ 'y2',
239
+ line.dataset.floorplanDimensionDefaultY2 ?? line.getAttribute('y2') ?? '0',
240
+ )
241
+ }
242
+ for (const leader of svg.querySelectorAll<SVGLineElement>('[data-floorplan-dimension-leader]')) {
243
+ leader.setAttribute('visibility', 'hidden')
244
+ }
245
+ }
246
+
247
+ function applyOutsideStartDimensionLine(label: SVGGElement): void {
248
+ const dimension = label.closest('[data-floorplan-dimension]')
249
+ const line = dimension?.querySelector<SVGLineElement>('[data-floorplan-dimension-line]')
250
+ if (!line) return
251
+ const { dataset } = line
252
+ if (
253
+ dataset.floorplanDimensionOutsideStartX1 === undefined ||
254
+ dataset.floorplanDimensionOutsideStartY1 === undefined ||
255
+ dataset.floorplanDimensionOutsideStartX2 === undefined ||
256
+ dataset.floorplanDimensionOutsideStartY2 === undefined
257
+ ) {
258
+ return
259
+ }
260
+ line.setAttribute('x1', dataset.floorplanDimensionOutsideStartX1)
261
+ line.setAttribute('y1', dataset.floorplanDimensionOutsideStartY1)
262
+ line.setAttribute('x2', dataset.floorplanDimensionOutsideStartX2)
263
+ line.setAttribute('y2', dataset.floorplanDimensionOutsideStartY2)
264
+ }
265
+
266
+ function showDimensionLeader(
267
+ label: SVGGElement,
268
+ labelMatrix: DOMMatrix,
269
+ dx: number,
270
+ dy: number,
271
+ ): void {
272
+ const dimension = label.closest('[data-floorplan-dimension]') as SVGGElement | null
273
+ const leader = dimension?.querySelector<SVGLineElement>('[data-floorplan-dimension-leader]')
274
+ const dimensionLine = dimension?.querySelector<SVGLineElement>('[data-floorplan-dimension-line]')
275
+ const dimensionMatrix = dimension?.getScreenCTM()
276
+ if (!leader || !dimensionLine || !dimensionMatrix) return
277
+
278
+ const start = dimensionEndpoint(label, 'start')
279
+ const end = dimensionEndpoint(label, 'end')
280
+ if (!start || !end) return
281
+ dimensionLine.setAttribute('x1', String(start.x))
282
+ dimensionLine.setAttribute('y1', String(start.y))
283
+ dimensionLine.setAttribute('x2', String(end.x))
284
+ dimensionLine.setAttribute('y2', String(end.y))
285
+ const labelScreen = { x: labelMatrix.e + dx, y: labelMatrix.f + dy }
286
+ const startScreen = localPointToScreen(dimensionMatrix, start.x, start.y)
287
+ const endScreen = localPointToScreen(dimensionMatrix, end.x, end.y)
288
+ const anchor =
289
+ Math.hypot(labelScreen.x - startScreen.x, labelScreen.y - startScreen.y) <
290
+ Math.hypot(labelScreen.x - endScreen.x, labelScreen.y - endScreen.y)
291
+ ? start
292
+ : end
293
+ const labelPoint = screenPointToLocal(dimensionMatrix, labelScreen.x, labelScreen.y)
294
+
295
+ leader.setAttribute('x1', String(anchor.x))
296
+ leader.setAttribute('y1', String(anchor.y))
297
+ leader.setAttribute('x2', String(labelPoint.x))
298
+ leader.setAttribute('y2', String(labelPoint.y))
299
+ leader.setAttribute('visibility', 'visible')
300
+ }
301
+
302
+ function dimensionEndpoint(
303
+ label: SVGGElement,
304
+ endpoint: 'start' | 'end',
305
+ ): { x: number; y: number } | null {
306
+ const x = Number(label.dataset[`floorplanDimension${endpoint === 'start' ? 'Start' : 'End'}X`])
307
+ const y = Number(label.dataset[`floorplanDimension${endpoint === 'start' ? 'Start' : 'End'}Y`])
308
+ return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : null
309
+ }
310
+
311
+ function localPointToScreen(matrix: DOMMatrix, x: number, y: number) {
312
+ return {
313
+ x: matrix.a * x + matrix.c * y + matrix.e,
314
+ y: matrix.b * x + matrix.d * y + matrix.f,
315
+ }
316
+ }
317
+
318
+ function screenPointToLocal(matrix: DOMMatrix, x: number, y: number) {
319
+ const local = screenVectorToLocal(matrix, x - matrix.e, y - matrix.f)
320
+ return { x: local.x, y: local.y }
321
+ }
322
+
323
+ function svgAnnotationObstacleRectangles(
324
+ obstacle: SVGGraphicsElement,
325
+ ): AnnotationObstacleRectangle[] {
326
+ const bounds = obstacle.getBoundingClientRect()
327
+ const fallback = [{ x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }]
328
+ if (obstacle.getAttribute('data-floorplan-annotation-obstacle') !== 'outline') return fallback
329
+
330
+ const geometry = obstacle as SVGGeometryElement
331
+ const matrix = geometry.getScreenCTM()
332
+ if (!matrix || typeof geometry.getTotalLength !== 'function') return fallback
333
+
334
+ try {
335
+ const length = geometry.getTotalLength()
336
+ const screenScale = Math.max(Math.hypot(matrix.a, matrix.b), Math.hypot(matrix.c, matrix.d))
337
+ const sampleCount = Math.max(1, Math.ceil((length * screenScale) / OUTLINE_SAMPLE_SPACING_PX))
338
+ const points = Array.from({ length: sampleCount + 1 }, (_, index) => {
339
+ const point = geometry.getPointAtLength((length * index) / sampleCount)
340
+ return {
341
+ x: matrix.a * point.x + matrix.c * point.y + matrix.e,
342
+ y: matrix.b * point.x + matrix.d * point.y + matrix.f,
343
+ }
344
+ })
345
+ return polylineObstacleRectangles(points)
346
+ } catch {
347
+ return fallback
348
+ }
349
+ }
350
+
351
+ export function polylineObstacleRectangles(
352
+ points: readonly { x: number; y: number }[],
353
+ ): AnnotationObstacleRectangle[] {
354
+ if (points.length === 1) {
355
+ const point = points[0]!
356
+ return [
357
+ {
358
+ x: point.x - OUTLINE_OBSTACLE_PADDING_PX,
359
+ y: point.y - OUTLINE_OBSTACLE_PADDING_PX,
360
+ width: OUTLINE_OBSTACLE_PADDING_PX * 2,
361
+ height: OUTLINE_OBSTACLE_PADDING_PX * 2,
362
+ },
363
+ ]
364
+ }
365
+
366
+ const rectangles: AnnotationObstacleRectangle[] = []
367
+ for (let index = 1; index < points.length; index += 1) {
368
+ const start = points[index - 1]!
369
+ const end = points[index]!
370
+ rectangles.push({
371
+ x: Math.min(start.x, end.x) - OUTLINE_OBSTACLE_PADDING_PX,
372
+ y: Math.min(start.y, end.y) - OUTLINE_OBSTACLE_PADDING_PX,
373
+ width: Math.abs(end.x - start.x) + OUTLINE_OBSTACLE_PADDING_PX * 2,
374
+ height: Math.abs(end.y - start.y) + OUTLINE_OBSTACLE_PADDING_PX * 2,
375
+ })
376
+ }
377
+ return rectangles
378
+ }
379
+
380
+ function resolveLabelShift(
381
+ rectangle: AnnotationLabelRectangle,
382
+ occupied: AnnotationObstacleIndex,
383
+ ): { dx: number; dy: number } | undefined {
384
+ const candidates = new Map<string, { dx: number; dy: number; preference: number; cost: number }>()
385
+ const visited = new Set<string>()
386
+ const addCandidate = (dx: number, dy: number, preference = 0) => {
387
+ if (!Number.isFinite(dx) || !Number.isFinite(dy)) return
388
+ const key = `${dx}:${dy}`
389
+ if (visited.has(key)) return
390
+ const existing = candidates.get(key)
391
+ if (!existing || preference < existing.preference) {
392
+ candidates.set(key, {
393
+ dx,
394
+ dy,
395
+ preference,
396
+ cost: candidateCost({ dx, dy, preference }, rectangle),
397
+ })
398
+ }
399
+ }
400
+ addCandidate(0, 0, -2)
401
+ for (const preferred of rectangle.preferredShifts ?? []) {
402
+ addCandidate(preferred.dx, preferred.dy, -1)
403
+ }
404
+
405
+ while (candidates.size > 0 && visited.size < MAX_LABEL_SHIFT_CANDIDATES) {
406
+ let candidate: { dx: number; dy: number; preference: number; cost: number } | undefined
407
+ for (const queued of candidates.values()) {
408
+ if (!candidate || queued.cost < candidate.cost) candidate = queued
409
+ }
410
+ if (!candidate) return undefined
411
+ const key = `${candidate.dx}:${candidate.dy}`
412
+ candidates.delete(key)
413
+ visited.add(key)
414
+
415
+ const shifted = {
416
+ ...rectangle,
417
+ x: rectangle.x + candidate.dx,
418
+ y: rectangle.y + candidate.dy,
419
+ }
420
+ const blockers = occupied.findOverlaps(shifted)
421
+ if (blockers.length === 0) return { dx: candidate.dx, dy: candidate.dy }
422
+
423
+ const blockerBounds = blockers.reduce(
424
+ (bounds, blocker) => ({
425
+ minX: Math.min(bounds.minX, blocker.x),
426
+ minY: Math.min(bounds.minY, blocker.y),
427
+ maxX: Math.max(bounds.maxX, blocker.x + blocker.width),
428
+ maxY: Math.max(bounds.maxY, blocker.y + blocker.height),
429
+ }),
430
+ {
431
+ minX: Number.POSITIVE_INFINITY,
432
+ minY: Number.POSITIVE_INFINITY,
433
+ maxX: Number.NEGATIVE_INFINITY,
434
+ maxY: Number.NEGATIVE_INFINITY,
435
+ },
436
+ )
437
+ const left = blockerBounds.minX - LABEL_PLACEMENT_GAP_PX - rectangle.width - rectangle.x
438
+ const right = blockerBounds.maxX + LABEL_PLACEMENT_GAP_PX - rectangle.x
439
+ const above = blockerBounds.minY - LABEL_PLACEMENT_GAP_PX - rectangle.height - rectangle.y
440
+ const below = blockerBounds.maxY + LABEL_PLACEMENT_GAP_PX - rectangle.y
441
+
442
+ addCandidate(candidate.dx, above)
443
+ addCandidate(candidate.dx, below)
444
+ addCandidate(left, candidate.dy)
445
+ addCandidate(right, candidate.dy)
446
+ addCandidate(left, above)
447
+ addCandidate(right, above)
448
+ addCandidate(left, below)
449
+ addCandidate(right, below)
450
+ }
451
+ return undefined
452
+ }
453
+
454
+ function candidateCost(
455
+ candidate: { dx: number; dy: number; preference?: number },
456
+ rectangle: AnnotationLabelRectangle,
457
+ ): number {
458
+ const distance = Math.hypot(candidate.dx, candidate.dy)
459
+ const preference = (candidate.preference ?? 0) * PREFERRED_SHIFT_COST_STEP
460
+ if (rectangle.tangentX === undefined || rectangle.tangentY === undefined) {
461
+ return preference + distance
462
+ }
463
+
464
+ const perpendicularMovement = Math.abs(
465
+ candidate.dx * -rectangle.tangentY + candidate.dy * rectangle.tangentX,
466
+ )
467
+ return preference + distance + perpendicularMovement * 4
468
+ }
469
+
470
+ export function isFloorplanAnnotationObstacleGeometry(geometry: FloorplanGeometry): boolean {
471
+ if (floorplanAnnotationObstacleMode(geometry)) return true
472
+ if (geometry.kind !== 'group') return false
473
+ const hasPlate = geometry.children.some(
474
+ (child) => child.kind === 'rect' || child.kind === 'circle',
475
+ )
476
+ const hasUprightText = geometry.children.some((child) => child.kind === 'text' && child.upright)
477
+ return hasPlate && hasUprightText
478
+ }
479
+
480
+ export function floorplanAnnotationObstacleMode(
481
+ geometry: FloorplanGeometry,
482
+ ): 'bounds' | 'outline' | '' | undefined {
483
+ const metadata = readFloorplanGeometryMetadata(geometry)
484
+ if (metadata.annotationObstacle) return metadata.annotationObstacle
485
+ switch (metadata.annotationRole) {
486
+ case 'room-label':
487
+ return geometry.kind === 'text' ? 'bounds' : undefined
488
+ case 'column-center':
489
+ return geometry.kind === 'line' || geometry.kind === 'text' ? 'bounds' : undefined
490
+ case 'stair-annotation':
491
+ return geometry.kind === 'polyline' || geometry.kind === 'line' ? 'outline' : 'bounds'
492
+ default:
493
+ return undefined
494
+ }
495
+ }
496
+
497
+ function rectanglesOverlap(
498
+ left: Pick<AnnotationLabelRectangle, 'x' | 'y' | 'width' | 'height'>,
499
+ right: Pick<AnnotationLabelRectangle, 'x' | 'y' | 'width' | 'height'>,
500
+ ): boolean {
501
+ return !(
502
+ left.x + left.width + LABEL_GAP_PX <= right.x ||
503
+ right.x + right.width + LABEL_GAP_PX <= left.x ||
504
+ left.y + left.height + LABEL_GAP_PX <= right.y ||
505
+ right.y + right.height + LABEL_GAP_PX <= left.y
506
+ )
507
+ }
508
+
509
+ function screenVectorToLocal(matrix: DOMMatrix, dx: number, dy: number) {
510
+ const determinant = matrix.a * matrix.d - matrix.b * matrix.c
511
+ if (Math.abs(determinant) < 1e-9) return { x: 0, y: 0 }
512
+ return {
513
+ x: (matrix.d * dx - matrix.c * dy) / determinant,
514
+ y: (-matrix.b * dx + matrix.a * dy) / determinant,
515
+ }
516
+ }