@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,265 @@
1
+ import {
2
+ type AnyNode,
3
+ type AnyNodeId,
4
+ findLevelAncestorId,
5
+ getWallBaseElevationForNodes,
6
+ getWallEffectiveHeightForNodes,
7
+ levelBaseElevationAt,
8
+ resolveCeilingHeight,
9
+ } from '@pascal-app/core'
10
+ import useElevationGuides from '../store/use-elevation-guides'
11
+
12
+ export const ELEVATION_ALIGNMENT_THRESHOLD_M = 0.08
13
+ const GUIDE_MATCH_EPSILON_M = 1e-4
14
+
15
+ export type ElevationGuideSource = {
16
+ nodeId: string
17
+ levelId: string | null | undefined
18
+ anchor: readonly [number, number]
19
+ }
20
+
21
+ export type ElevationSnapTarget = {
22
+ id: string
23
+ elevation: number
24
+ anchor: readonly [number, number]
25
+ label: string
26
+ }
27
+
28
+ export type ElevationSnapMatch = {
29
+ target: ElevationSnapTarget
30
+ elevation: number
31
+ }
32
+
33
+ function polygonCenter(polygon: ReadonlyArray<readonly [number, number]>): [number, number] {
34
+ if (polygon.length === 0) return [0, 0]
35
+ let x = 0
36
+ let z = 0
37
+ for (const point of polygon) {
38
+ x += point[0]
39
+ z += point[1]
40
+ }
41
+ return [x / polygon.length, z / polygon.length]
42
+ }
43
+
44
+ function segmentCenter(
45
+ start: readonly [number, number],
46
+ end: readonly [number, number],
47
+ ): [number, number] {
48
+ return [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2]
49
+ }
50
+
51
+ // Mirrors `resolveFenceLiftElevationForNodes` in `@pascal-app/nodes`, which this
52
+ // package cannot import (the fence definition imports the guides from here).
53
+ // A stale host resolves to the level base — the sculpted ground under the
54
+ // fence's start point, sampled where the builder samples it, so the guide line
55
+ // lands on the rail it claims to describe.
56
+ function fenceBaseElevation(node: AnyNode, nodes: Record<string, AnyNode>): number {
57
+ if (node.type !== 'fence') return 0
58
+ const host = node.supportSlabId ? nodes[node.supportSlabId as AnyNodeId] : undefined
59
+ const hosted = host?.type === 'slab' && (host.parentId ?? null) === (node.parentId ?? null)
60
+ const levelId = findLevelAncestorId(node.id as AnyNodeId, nodes)
61
+ const support = hosted
62
+ ? (host.elevation ?? 0)
63
+ : levelId
64
+ ? levelBaseElevationAt(nodes, levelId, node.start[0], node.start[1])
65
+ : 0
66
+ return support + (node.supportOffset ?? 0)
67
+ }
68
+
69
+ /**
70
+ * Structural Y datums on the source node's level. This is editor-runtime
71
+ * collection over authoritative vertical resolvers; matching remains a pure
72
+ * scalar operation in {@link resolveElevationSnapMatch}.
73
+ */
74
+ export function collectElevationSnapTargets(
75
+ source: ElevationGuideSource,
76
+ nodes: Record<string, AnyNode>,
77
+ ): ElevationSnapTarget[] {
78
+ if (!source.levelId) return []
79
+
80
+ const targets: ElevationSnapTarget[] = [
81
+ {
82
+ id: `${source.levelId}:level`,
83
+ elevation: 0,
84
+ anchor: source.anchor,
85
+ label: 'Level',
86
+ },
87
+ ]
88
+ // Sculpted ground under the thing being dragged. A separate target rather than
89
+ // a redefinition of `Level`: the storey plane is still a real datum a user may
90
+ // want (a fence sunk to the building's floor line), and on a hillside the
91
+ // ground is a second, different one. Emitted only when they actually differ,
92
+ // so a flat scene keeps exactly one target at 0.
93
+ const groundElevation = levelBaseElevationAt(
94
+ nodes,
95
+ source.levelId,
96
+ source.anchor[0],
97
+ source.anchor[1],
98
+ )
99
+ if (Math.abs(groundElevation) > GUIDE_MATCH_EPSILON_M) {
100
+ targets.push({
101
+ id: `${source.levelId}:ground`,
102
+ elevation: groundElevation,
103
+ anchor: source.anchor,
104
+ label: 'Ground',
105
+ })
106
+ }
107
+ const level = nodes[source.levelId as AnyNodeId]
108
+ if (level?.type !== 'level') return targets
109
+
110
+ for (const childId of level.children) {
111
+ if (childId === source.nodeId) continue
112
+ const node = nodes[childId as AnyNodeId]
113
+ if (!node) continue
114
+
115
+ if (node.type === 'slab') {
116
+ const center = polygonCenter(node.polygon)
117
+ const top = node.elevation ?? 0.05
118
+ targets.push({
119
+ id: `${node.id}:top`,
120
+ elevation: top,
121
+ anchor: center,
122
+ label: 'Slab top',
123
+ })
124
+ if (!node.recessed) {
125
+ targets.push({
126
+ id: `${node.id}:base`,
127
+ elevation: top - (node.thickness ?? 0.05),
128
+ anchor: center,
129
+ label: 'Slab underside',
130
+ })
131
+ }
132
+ continue
133
+ }
134
+
135
+ if (node.type === 'ceiling') {
136
+ targets.push({
137
+ id: `${node.id}:ceiling`,
138
+ elevation: resolveCeilingHeight(node, nodes as Record<AnyNodeId, AnyNode>),
139
+ anchor: polygonCenter(node.polygon),
140
+ label: 'Ceiling',
141
+ })
142
+ continue
143
+ }
144
+
145
+ if (node.type === 'wall') {
146
+ const base = getWallBaseElevationForNodes(node, nodes)
147
+ const center = segmentCenter(node.start, node.end)
148
+ targets.push({
149
+ id: `${node.id}:base`,
150
+ elevation: base,
151
+ anchor: center,
152
+ label: 'Wall base',
153
+ })
154
+ targets.push({
155
+ id: `${node.id}:top`,
156
+ elevation: base + getWallEffectiveHeightForNodes(node, nodes),
157
+ anchor: center,
158
+ label: 'Wall top',
159
+ })
160
+ continue
161
+ }
162
+
163
+ if (node.type === 'fence') {
164
+ const base = fenceBaseElevation(node, nodes)
165
+ const center = segmentCenter(node.start, node.end)
166
+ targets.push({
167
+ id: `${node.id}:base`,
168
+ elevation: base,
169
+ anchor: center,
170
+ label: 'Fence base',
171
+ })
172
+ targets.push({
173
+ id: `${node.id}:top`,
174
+ elevation: base + (node.height ?? 1.8),
175
+ anchor: center,
176
+ label: 'Fence top',
177
+ })
178
+ }
179
+ }
180
+
181
+ return targets
182
+ }
183
+
184
+ export function resolveElevationSnapMatch(
185
+ proposedElevation: number,
186
+ sourceAnchor: readonly [number, number],
187
+ targets: readonly ElevationSnapTarget[],
188
+ threshold = ELEVATION_ALIGNMENT_THRESHOLD_M,
189
+ ): ElevationSnapMatch | null {
190
+ let best: ElevationSnapTarget | null = null
191
+ let bestDelta = Number.POSITIVE_INFINITY
192
+ let bestPlanDistance = Number.POSITIVE_INFINITY
193
+
194
+ for (const target of targets) {
195
+ const delta = Math.abs(target.elevation - proposedElevation)
196
+ if (delta > threshold) continue
197
+ const dx = target.anchor[0] - sourceAnchor[0]
198
+ const dz = target.anchor[1] - sourceAnchor[1]
199
+ const planDistance = dx * dx + dz * dz
200
+ if (
201
+ delta < bestDelta - 1e-9 ||
202
+ (Math.abs(delta - bestDelta) <= 1e-9 && planDistance < bestPlanDistance)
203
+ ) {
204
+ best = target
205
+ bestDelta = delta
206
+ bestPlanDistance = planDistance
207
+ }
208
+ }
209
+
210
+ return best ? { target: best, elevation: best.elevation } : null
211
+ }
212
+
213
+ export function resolveStructuralElevationSnap(
214
+ source: ElevationGuideSource,
215
+ proposedElevation: number,
216
+ nodes: Record<string, AnyNode>,
217
+ ): number {
218
+ return (
219
+ resolveElevationSnapMatch(
220
+ proposedElevation,
221
+ source.anchor,
222
+ collectElevationSnapTargets(source, nodes),
223
+ )?.elevation ?? proposedElevation
224
+ )
225
+ }
226
+
227
+ export function publishStructuralElevationGuide(
228
+ source: ElevationGuideSource,
229
+ elevation: number,
230
+ nodes: Record<string, AnyNode>,
231
+ ): void {
232
+ if (!source.levelId) {
233
+ clearStructuralElevationGuide(source.nodeId)
234
+ return
235
+ }
236
+
237
+ const match = resolveElevationSnapMatch(
238
+ elevation,
239
+ source.anchor,
240
+ collectElevationSnapTargets(source, nodes),
241
+ GUIDE_MATCH_EPSILON_M,
242
+ )
243
+ if (!match) {
244
+ clearStructuralElevationGuide(source.nodeId)
245
+ return
246
+ }
247
+
248
+ const dx = match.target.anchor[0] - source.anchor[0]
249
+ const dz = match.target.anchor[1] - source.anchor[1]
250
+ const length = Math.hypot(dx, dz)
251
+ const direction: [number, number] = length > 1e-6 ? [dx / length, dz / length] : [1, 0]
252
+
253
+ useElevationGuides.getState().publish({
254
+ ownerId: source.nodeId,
255
+ levelId: source.levelId,
256
+ center: source.anchor,
257
+ direction,
258
+ elevation: match.elevation,
259
+ label: match.target.label,
260
+ })
261
+ }
262
+
263
+ export function clearStructuralElevationGuide(ownerId: string): void {
264
+ useElevationGuides.getState().clear(ownerId)
265
+ }
@@ -0,0 +1,327 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type { FloorplanGeometry } from '@pascal-app/core'
3
+ import {
4
+ DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
5
+ filterFloorplanAnnotationGeometry,
6
+ normalizeFloorplanAnnotationVisibility,
7
+ } from './annotation-visibility'
8
+ import { floorplanGeometryMetadata, withFloorplanGeometryMetadata } from './floorplan-extension'
9
+
10
+ describe('floor-plan annotation visibility', () => {
11
+ test('fills missing persisted categories with visible defaults', () => {
12
+ expect(normalizeFloorplanAnnotationVisibility({ measurements: false })).toEqual({
13
+ ...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
14
+ measurements: false,
15
+ })
16
+ })
17
+
18
+ test('removes automatic dimension primitives without removing plan geometry', () => {
19
+ const line = { kind: 'line', x1: 0, y1: 0, x2: 2, y2: 0 } satisfies FloorplanGeometry
20
+ const geometry = {
21
+ kind: 'group',
22
+ children: [
23
+ line,
24
+ {
25
+ kind: 'dimension-string',
26
+ segments: [{ start: [0, 0], end: [2, 0], text: '2.00m' }],
27
+ offsetNormal: [0, 1],
28
+ offsetDistance: 0.3,
29
+ extensionOvershoot: 0.1,
30
+ },
31
+ ],
32
+ } satisfies FloorplanGeometry
33
+
34
+ expect(
35
+ filterFloorplanAnnotationGeometry(geometry, {
36
+ ...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
37
+ automaticDimensions: false,
38
+ }),
39
+ ).toEqual({ kind: 'group', children: [line] })
40
+ })
41
+
42
+ test('removes a complete curved automatic dimension group', () => {
43
+ const curvedDimension = {
44
+ kind: 'group',
45
+ metadata: floorplanGeometryMetadata({ annotationRole: 'automatic-dimension' }),
46
+ children: [
47
+ { kind: 'line', x1: 0, y1: 0, x2: 2, y2: 2 },
48
+ { kind: 'dimension-label', cx: 1, cy: 1, text: 'R 2m', angle: 0 },
49
+ ],
50
+ } satisfies FloorplanGeometry
51
+
52
+ expect(
53
+ filterFloorplanAnnotationGeometry(curvedDimension, {
54
+ ...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
55
+ automaticDimensions: false,
56
+ }),
57
+ ).toBeNull()
58
+ })
59
+
60
+ test('removes only the opening mark from door geometry', () => {
61
+ const body = {
62
+ kind: 'polygon',
63
+ points: [
64
+ [0, 0],
65
+ [1, 0],
66
+ [1, 0.1],
67
+ ],
68
+ } satisfies FloorplanGeometry
69
+ const mark = {
70
+ kind: 'group',
71
+ metadata: floorplanGeometryMetadata({ annotationRole: 'opening-mark' }),
72
+ children: [
73
+ { kind: 'line', x1: 0.5, y1: 0, x2: 0.5, y2: 0.5 },
74
+ { kind: 'rect', x: 0.3, y: 0.5, width: 0.4, height: 0.3 },
75
+ { kind: 'text', x: 0.5, y: 0.65, text: '101', fontSize: 0.15, upright: true },
76
+ ],
77
+ } satisfies FloorplanGeometry
78
+ const geometry = { kind: 'group', children: [body, mark] } satisfies FloorplanGeometry
79
+
80
+ expect(
81
+ filterFloorplanAnnotationGeometry(geometry, {
82
+ ...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
83
+ openingMarks: false,
84
+ }),
85
+ ).toEqual({ kind: 'group', children: [body] })
86
+ })
87
+
88
+ test('hides manual dimensions and measurements independently', () => {
89
+ const manualDimension = {
90
+ kind: 'text',
91
+ x: 0,
92
+ y: 0,
93
+ text: 'Annotation',
94
+ fontSize: 0.15,
95
+ metadata: floorplanGeometryMetadata({ annotationRole: 'manual-dimension' }),
96
+ } satisfies FloorplanGeometry
97
+ const measurement = {
98
+ ...manualDimension,
99
+ metadata: floorplanGeometryMetadata({ annotationRole: 'measurement' }),
100
+ } satisfies FloorplanGeometry
101
+
102
+ expect(
103
+ filterFloorplanAnnotationGeometry(manualDimension, {
104
+ ...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
105
+ manualDimensions: false,
106
+ }),
107
+ ).toBeNull()
108
+ expect(
109
+ filterFloorplanAnnotationGeometry(
110
+ {
111
+ kind: 'group',
112
+ metadata: floorplanGeometryMetadata({ annotationRole: 'manual-dimension' }),
113
+ children: [
114
+ {
115
+ kind: 'dimension',
116
+ start: [0, 0],
117
+ end: [1, 0],
118
+ offsetNormal: [0, 1],
119
+ offsetDistance: 0.5,
120
+ extensionOvershoot: 0.1,
121
+ text: '1m',
122
+ },
123
+ ],
124
+ },
125
+ {
126
+ ...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
127
+ automaticDimensions: false,
128
+ },
129
+ ),
130
+ ).not.toBeNull()
131
+ expect(
132
+ filterFloorplanAnnotationGeometry(measurement, {
133
+ ...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
134
+ measurements: false,
135
+ }),
136
+ ).toBeNull()
137
+ })
138
+
139
+ test('keeps a contextual dimension when automatic dimensions are hidden', () => {
140
+ const contextualDimension = {
141
+ kind: 'group',
142
+ metadata: floorplanGeometryMetadata({ annotationRole: 'contextual-dimension' }),
143
+ children: [
144
+ {
145
+ kind: 'dimension',
146
+ start: [0, 0],
147
+ end: [2, 0],
148
+ offsetNormal: [0, 1],
149
+ offsetDistance: 0.3,
150
+ extensionOvershoot: 0.08,
151
+ text: '2m',
152
+ },
153
+ ],
154
+ } satisfies FloorplanGeometry
155
+
156
+ expect(
157
+ filterFloorplanAnnotationGeometry(contextualDimension, {
158
+ ...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
159
+ automaticDimensions: false,
160
+ contextualDimensions: true,
161
+ }),
162
+ ).toEqual(contextualDimension)
163
+ })
164
+
165
+ test('removes a nested automatic wall string beside a contextual dimension', () => {
166
+ const wallBody = {
167
+ kind: 'polygon',
168
+ points: [
169
+ [0, 0],
170
+ [0.2, 0],
171
+ [0.2, 6],
172
+ [0, 6],
173
+ ],
174
+ } satisfies FloorplanGeometry
175
+ const automaticDimension = {
176
+ kind: 'dimension-string',
177
+ segments: [{ start: [0, 0], end: [0, 18], text: '18m' }],
178
+ offsetNormal: [1, 0],
179
+ offsetDistance: 0.55,
180
+ extensionOvershoot: 0.12,
181
+ } satisfies FloorplanGeometry
182
+ const contextualDimension = withFloorplanGeometryMetadata(
183
+ {
184
+ kind: 'dimension',
185
+ start: [0, 0],
186
+ end: [0, 6],
187
+ offsetNormal: [1, 0],
188
+ offsetDistance: 0.34,
189
+ extensionOvershoot: 0.08,
190
+ text: '6m',
191
+ } satisfies FloorplanGeometry,
192
+ { annotationRole: 'contextual-dimension' },
193
+ )
194
+ const geometry = {
195
+ kind: 'group',
196
+ children: [
197
+ {
198
+ kind: 'group',
199
+ children: [wallBody, automaticDimension],
200
+ },
201
+ contextualDimension,
202
+ ],
203
+ } satisfies FloorplanGeometry
204
+
205
+ expect(
206
+ filterFloorplanAnnotationGeometry(geometry, {
207
+ ...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
208
+ automaticDimensions: false,
209
+ contextualDimensions: true,
210
+ }),
211
+ ).toEqual({
212
+ kind: 'group',
213
+ children: [{ kind: 'group', children: [wallBody] }, contextualDimension],
214
+ })
215
+ })
216
+
217
+ test('hides structural grids and only the center marks within column geometry', () => {
218
+ const centerMark = {
219
+ kind: 'line',
220
+ x1: -0.1,
221
+ y1: -0.1,
222
+ x2: 0.1,
223
+ y2: 0.1,
224
+ metadata: floorplanGeometryMetadata({ annotationRole: 'column-center' }),
225
+ } satisfies FloorplanGeometry
226
+ const gridReference = {
227
+ kind: 'text',
228
+ x: 0,
229
+ y: 0.3,
230
+ text: 'B-2',
231
+ fontSize: 0.13,
232
+ metadata: floorplanGeometryMetadata({ annotationRole: 'column-center' }),
233
+ } satisfies FloorplanGeometry
234
+ const footprint = {
235
+ kind: 'rect',
236
+ x: -0.2,
237
+ y: -0.2,
238
+ width: 0.4,
239
+ height: 0.4,
240
+ } satisfies FloorplanGeometry
241
+ const visibility = {
242
+ ...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
243
+ structuralGrids: false,
244
+ }
245
+
246
+ expect(
247
+ filterFloorplanAnnotationGeometry(
248
+ {
249
+ kind: 'group',
250
+ children: [footprint, centerMark, gridReference],
251
+ },
252
+ visibility,
253
+ ),
254
+ ).toEqual({ kind: 'group', children: [footprint] })
255
+ expect(
256
+ filterFloorplanAnnotationGeometry(
257
+ {
258
+ kind: 'group',
259
+ metadata: floorplanGeometryMetadata({ annotationRole: 'structural-grid' }),
260
+ children: [footprint],
261
+ },
262
+ visibility,
263
+ ),
264
+ ).toBeNull()
265
+ })
266
+
267
+ test('hides room labels without removing the room footprint', () => {
268
+ const footprint = {
269
+ kind: 'polygon',
270
+ points: [
271
+ [0, 0],
272
+ [4, 0],
273
+ [4, 3],
274
+ ],
275
+ } satisfies FloorplanGeometry
276
+ const roomName = {
277
+ kind: 'text',
278
+ x: 2,
279
+ y: 1.5,
280
+ text: 'Office',
281
+ fontSize: 0.2,
282
+ metadata: floorplanGeometryMetadata({ annotationRole: 'room-label' }),
283
+ } satisfies FloorplanGeometry
284
+
285
+ expect(
286
+ filterFloorplanAnnotationGeometry(
287
+ { kind: 'group', children: [footprint, roomName] },
288
+ { ...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY, roomLabels: false },
289
+ ),
290
+ ).toEqual({ kind: 'group', children: [footprint] })
291
+ })
292
+
293
+ test('hides stair notes and break lines without removing stair geometry', () => {
294
+ const footprint = {
295
+ kind: 'polygon',
296
+ points: [
297
+ [0, 0],
298
+ [1, 0],
299
+ [1, 3],
300
+ [0, 3],
301
+ ],
302
+ } satisfies FloorplanGeometry
303
+ const direction = {
304
+ kind: 'text',
305
+ x: 0.5,
306
+ y: 0.5,
307
+ text: 'UP',
308
+ fontSize: 0.16,
309
+ metadata: floorplanGeometryMetadata({ annotationRole: 'stair-annotation' }),
310
+ } satisfies FloorplanGeometry
311
+ const breakLine = {
312
+ kind: 'polyline',
313
+ points: [
314
+ [0, 2],
315
+ [1, 2],
316
+ ],
317
+ metadata: floorplanGeometryMetadata({ annotationRole: 'stair-annotation' }),
318
+ } satisfies FloorplanGeometry
319
+
320
+ expect(
321
+ filterFloorplanAnnotationGeometry(
322
+ { kind: 'group', children: [footprint, direction, breakLine] },
323
+ { ...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY, stairAnnotations: false },
324
+ ),
325
+ ).toEqual({ kind: 'group', children: [footprint] })
326
+ })
327
+ })