@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,133 @@
1
+ import type { FloorplanGeometry } from '@pascal-app/core'
2
+ import { type FloorplanAnnotationRole, readFloorplanGeometryMetadata } from './floorplan-extension'
3
+
4
+ export type FloorplanAnnotationCategory =
5
+ | 'automaticDimensions'
6
+ | 'contextualDimensions'
7
+ | 'manualDimensions'
8
+ | 'measurements'
9
+ | 'openingMarks'
10
+ | 'structuralGrids'
11
+ | 'roomLabels'
12
+ | 'stairAnnotations'
13
+
14
+ export type FloorplanAnnotationVisibility = Record<FloorplanAnnotationCategory, boolean>
15
+
16
+ export const DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY: FloorplanAnnotationVisibility = {
17
+ automaticDimensions: true,
18
+ contextualDimensions: false,
19
+ manualDimensions: true,
20
+ measurements: true,
21
+ openingMarks: true,
22
+ structuralGrids: true,
23
+ roomLabels: true,
24
+ stairAnnotations: true,
25
+ }
26
+
27
+ export function revealFloorplanAnnotationRole(
28
+ visibility: FloorplanAnnotationVisibility,
29
+ role: FloorplanAnnotationRole,
30
+ ): FloorplanAnnotationVisibility {
31
+ const category = annotationCategoryForRole(role)
32
+ return visibility[category] ? visibility : { ...visibility, [category]: true }
33
+ }
34
+
35
+ export function normalizeFloorplanAnnotationVisibility(
36
+ value: unknown,
37
+ ): FloorplanAnnotationVisibility {
38
+ if (!value || typeof value !== 'object') return { ...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY }
39
+ const persisted = value as Partial<Record<FloorplanAnnotationCategory, unknown>>
40
+ return {
41
+ automaticDimensions:
42
+ typeof persisted.automaticDimensions === 'boolean'
43
+ ? persisted.automaticDimensions
44
+ : DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY.automaticDimensions,
45
+ contextualDimensions:
46
+ typeof persisted.contextualDimensions === 'boolean'
47
+ ? persisted.contextualDimensions
48
+ : DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY.contextualDimensions,
49
+ manualDimensions:
50
+ typeof persisted.manualDimensions === 'boolean'
51
+ ? persisted.manualDimensions
52
+ : DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY.manualDimensions,
53
+ measurements:
54
+ typeof persisted.measurements === 'boolean'
55
+ ? persisted.measurements
56
+ : DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY.measurements,
57
+ openingMarks:
58
+ typeof persisted.openingMarks === 'boolean'
59
+ ? persisted.openingMarks
60
+ : DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY.openingMarks,
61
+ structuralGrids:
62
+ typeof persisted.structuralGrids === 'boolean'
63
+ ? persisted.structuralGrids
64
+ : DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY.structuralGrids,
65
+ roomLabels:
66
+ typeof persisted.roomLabels === 'boolean'
67
+ ? persisted.roomLabels
68
+ : DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY.roomLabels,
69
+ stairAnnotations:
70
+ typeof persisted.stairAnnotations === 'boolean'
71
+ ? persisted.stairAnnotations
72
+ : DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY.stairAnnotations,
73
+ }
74
+ }
75
+
76
+ export function filterFloorplanAnnotationGeometry(
77
+ geometry: FloorplanGeometry,
78
+ visibility: FloorplanAnnotationVisibility,
79
+ inheritedRole?: FloorplanAnnotationRole,
80
+ ): FloorplanGeometry | null {
81
+ const role = readFloorplanGeometryMetadata(geometry).annotationRole ?? inheritedRole
82
+ if (role && !isAnnotationRoleVisible(role, visibility)) return null
83
+ if (
84
+ !visibility.automaticDimensions &&
85
+ (role === undefined || role === 'automatic-dimension') &&
86
+ (geometry.kind === 'dimension' ||
87
+ geometry.kind === 'dimension-string' ||
88
+ geometry.kind === 'dimension-label' ||
89
+ geometry.kind === 'equal-spacing-badge')
90
+ ) {
91
+ return null
92
+ }
93
+ if (geometry.kind !== 'group') return geometry
94
+
95
+ const children = geometry.children
96
+ .map((child) => filterFloorplanAnnotationGeometry(child, visibility, role))
97
+ .filter((child): child is FloorplanGeometry => child !== null)
98
+ if (children.length === 0) return null
99
+ const childrenUnchanged =
100
+ children.length === geometry.children.length &&
101
+ children.every((child, index) => child === geometry.children[index])
102
+ if (childrenUnchanged) return geometry
103
+ return { ...geometry, children }
104
+ }
105
+
106
+ function isAnnotationRoleVisible(
107
+ role: FloorplanAnnotationRole,
108
+ visibility: FloorplanAnnotationVisibility,
109
+ ): boolean {
110
+ return visibility[annotationCategoryForRole(role)]
111
+ }
112
+
113
+ function annotationCategoryForRole(role: FloorplanAnnotationRole): FloorplanAnnotationCategory {
114
+ switch (role) {
115
+ case 'automatic-dimension':
116
+ return 'automaticDimensions'
117
+ case 'contextual-dimension':
118
+ return 'contextualDimensions'
119
+ case 'manual-dimension':
120
+ return 'manualDimensions'
121
+ case 'measurement':
122
+ return 'measurements'
123
+ case 'opening-mark':
124
+ return 'openingMarks'
125
+ case 'structural-grid':
126
+ case 'column-center':
127
+ return 'structuralGrids'
128
+ case 'room-label':
129
+ return 'roomLabels'
130
+ case 'stair-annotation':
131
+ return 'stairAnnotations'
132
+ }
133
+ }
@@ -0,0 +1,46 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
+ import {
3
+ type AnyNode,
4
+ type ConstructionDrawingType,
5
+ nodeRegistry,
6
+ registerNode,
7
+ } from '@pascal-app/core'
8
+ import { z } from 'zod'
9
+ import { resolveNodeForDrawingType } from './drawing-coordination'
10
+ import { FLOORPLAN_NODE_EXTENSION_KEY } from './floorplan-extension'
11
+
12
+ describe('resolveNodeForDrawingType', () => {
13
+ afterEach(() => nodeRegistry._reset())
14
+
15
+ test('dispatches drawing coordination through the registered extension', () => {
16
+ const node = {
17
+ id: 'drawing-test_main',
18
+ type: 'drawing-test',
19
+ object: 'node',
20
+ parentId: null,
21
+ visible: true,
22
+ metadata: {},
23
+ } as unknown as AnyNode
24
+ registerNode({
25
+ kind: 'drawing-test',
26
+ schemaVersion: 1,
27
+ schema: z.object({ type: z.literal('drawing-test') }) as never,
28
+ category: 'utility',
29
+ defaults: () => ({}) as never,
30
+ extensions: {
31
+ [FLOORPLAN_NODE_EXTENSION_KEY]: {
32
+ resolveForDrawing: ({ drawingType }: { drawingType: ConstructionDrawingType }) =>
33
+ drawingType === 'floor-plan' ? null : node,
34
+ },
35
+ },
36
+ } as never)
37
+
38
+ expect(resolveNodeForDrawingType(node, { [node.id]: node }, 'floor-plan')).toBeNull()
39
+ expect(resolveNodeForDrawingType(node, { [node.id]: node }, 'foundation-plan')).toBe(node)
40
+ })
41
+
42
+ test('leaves nodes without a drawing extension unchanged', () => {
43
+ const node = { id: 'unknown', type: 'unknown' } as unknown as AnyNode
44
+ expect(resolveNodeForDrawingType(node, { [node.id]: node }, 'floor-plan')).toBe(node)
45
+ })
46
+ })
@@ -0,0 +1,13 @@
1
+ import { type AnyNode, type ConstructionDrawingType, nodeRegistry } from '@pascal-app/core'
2
+ import { getFloorplanNodeExtension } from './floorplan-extension'
3
+
4
+ export function resolveNodeForDrawingType(
5
+ node: AnyNode,
6
+ nodes: Record<string, AnyNode>,
7
+ drawingType: ConstructionDrawingType,
8
+ ): AnyNode | null {
9
+ const extension = getFloorplanNodeExtension(nodeRegistry.get(node.type))
10
+ return extension?.resolveForDrawing
11
+ ? extension.resolveForDrawing({ node, nodes, drawingType })
12
+ : node
13
+ }
@@ -0,0 +1,319 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type { FloorplanGeometry } from '@pascal-app/core'
3
+ import { splitFloorplanOverlay } from '../../components/editor-2d/renderers/floorplan-registry-layer'
4
+ import { DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY } from './annotation-visibility'
5
+ import {
6
+ filterFloorplanExportOverlay,
7
+ fitPlanToBox,
8
+ isFloorplanExportAnnotationGeometry,
9
+ partitionFloorplanExportOverlay,
10
+ resolveFloorplanExportAnnotationVisibility,
11
+ resolveFloorplanExportNodeGeometry,
12
+ resolveFloorplanExportPlacement,
13
+ resolveFloorplanExportRotationDeg,
14
+ resolveFloorplanExportViewport,
15
+ resolveFloorplanExportViewState,
16
+ resolveFloorplanMeasurementSize,
17
+ resolveFloorplanPageLayout,
18
+ resolveFloorplanScreenUnitsPerPixel,
19
+ rotateFloorplanExportBounds,
20
+ } from './floorplan-export'
21
+ import { floorplanGeometryMetadata } from './floorplan-extension'
22
+
23
+ describe('filterFloorplanExportOverlay', () => {
24
+ test('preserves annotation metadata while splitting geometry passes', () => {
25
+ const contextualDimension = {
26
+ kind: 'group',
27
+ metadata: floorplanGeometryMetadata({ annotationRole: 'contextual-dimension' }),
28
+ children: [
29
+ {
30
+ kind: 'dimension',
31
+ start: [0, 0],
32
+ end: [2, 0],
33
+ offsetNormal: [0, 1],
34
+ offsetDistance: 0.3,
35
+ extensionOvershoot: 0.08,
36
+ text: '2m',
37
+ },
38
+ ],
39
+ } satisfies FloorplanGeometry
40
+
41
+ expect(splitFloorplanOverlay(contextualDimension).overlay).toMatchObject(contextualDimension)
42
+ })
43
+
44
+ test('preserves value labels and removes editing handles', () => {
45
+ const label = {
46
+ kind: 'dimension-label',
47
+ appearance: 'outlined',
48
+ cx: 1,
49
+ cy: 0,
50
+ text: '2.00m',
51
+ angle: 0,
52
+ } satisfies FloorplanGeometry
53
+ const overlay = {
54
+ kind: 'group',
55
+ children: [
56
+ label,
57
+ {
58
+ kind: 'endpoint-handle',
59
+ point: [0, 0],
60
+ state: 'idle',
61
+ affordance: 'move-measurement-vertex',
62
+ payload: { vertexIndex: 0 },
63
+ },
64
+ ],
65
+ } satisfies FloorplanGeometry
66
+
67
+ expect(filterFloorplanExportOverlay(overlay)).toEqual({
68
+ kind: 'group',
69
+ children: [label],
70
+ })
71
+ })
72
+
73
+ test('preserves wall, door, and window shapes used as annotation obstacles', () => {
74
+ const fixedGeometry = {
75
+ kind: 'group',
76
+ children: [
77
+ {
78
+ kind: 'polygon',
79
+ points: [
80
+ [0, 0],
81
+ [4, 0],
82
+ [4, 0.2],
83
+ [0, 0.2],
84
+ ],
85
+ fill: '#374151',
86
+ stroke: '#1f2937',
87
+ metadata: floorplanGeometryMetadata({ annotationObstacle: 'outline' }),
88
+ },
89
+ {
90
+ kind: 'path',
91
+ d: 'M 1 0 A 1 1 0 0 1 2 1',
92
+ fill: 'none',
93
+ stroke: '#64748b',
94
+ metadata: floorplanGeometryMetadata({ annotationObstacle: 'bounds' }),
95
+ },
96
+ {
97
+ kind: 'line',
98
+ x1: 2.5,
99
+ y1: 0,
100
+ x2: 3.5,
101
+ y2: 0,
102
+ stroke: '#1f2937',
103
+ metadata: floorplanGeometryMetadata({ annotationObstacle: 'bounds' }),
104
+ },
105
+ { kind: 'move-handle', point: [2, 0.1] },
106
+ ],
107
+ } satisfies FloorplanGeometry
108
+
109
+ const { overlay } = splitFloorplanOverlay(fixedGeometry)
110
+ expect(overlay).not.toBeNull()
111
+ expect(filterFloorplanExportOverlay(overlay!)).toEqual({
112
+ kind: 'group',
113
+ children: fixedGeometry.children.slice(0, 3),
114
+ transform: undefined,
115
+ })
116
+ })
117
+
118
+ test('keeps structural obstacles in model bounds while leaving marks as annotations', () => {
119
+ const wall = {
120
+ kind: 'polygon',
121
+ points: [
122
+ [0, 0],
123
+ [4, 0],
124
+ [4, 0.2],
125
+ [0, 0.2],
126
+ ],
127
+ fill: '#374151',
128
+ metadata: floorplanGeometryMetadata({ annotationObstacle: 'outline' }),
129
+ } satisfies FloorplanGeometry
130
+ const openingMark = {
131
+ kind: 'group',
132
+ metadata: floorplanGeometryMetadata({ annotationRole: 'opening-mark' }),
133
+ children: [
134
+ {
135
+ kind: 'rect',
136
+ x: 1,
137
+ y: 1,
138
+ width: 0.4,
139
+ height: 0.2,
140
+ fill: '#ffffff',
141
+ stroke: '#334155',
142
+ },
143
+ { kind: 'text', x: 1.2, y: 1.1, text: 'W01', fontSize: 0.1, upright: true },
144
+ ],
145
+ } satisfies FloorplanGeometry
146
+
147
+ expect(
148
+ partitionFloorplanExportOverlay({ kind: 'group', children: [wall, openingMark] }),
149
+ ).toEqual({
150
+ model: { kind: 'group', children: [wall], transform: undefined },
151
+ annotations: { kind: 'group', children: [openingMark], transform: undefined },
152
+ })
153
+ })
154
+
155
+ test('moves automatic dimensions embedded in base wall geometry into the PDF annotation layer', () => {
156
+ const wall = {
157
+ kind: 'polygon',
158
+ points: [
159
+ [0, 0],
160
+ [4, 0],
161
+ [4, 0.2],
162
+ [0, 0.2],
163
+ ],
164
+ fill: '#374151',
165
+ } satisfies FloorplanGeometry
166
+ const dimensions = {
167
+ kind: 'dimension-string',
168
+ segments: [{ start: [0, 0], end: [4, 0], text: '4m' }],
169
+ offsetNormal: [0, -1],
170
+ offsetDistance: 1,
171
+ extensionOvershoot: 0.12,
172
+ } satisfies FloorplanGeometry
173
+
174
+ expect(
175
+ resolveFloorplanExportNodeGeometry(
176
+ { kind: 'group', children: [wall, dimensions] },
177
+ null,
178
+ false,
179
+ ),
180
+ ).toEqual({
181
+ model: { kind: 'group', children: [wall], transform: undefined },
182
+ annotations: { kind: 'group', children: [dimensions], transform: undefined },
183
+ })
184
+ })
185
+ })
186
+
187
+ describe('fitPlanToBox', () => {
188
+ test('preserves aspect ratio and centers the plan', () => {
189
+ expect(fitPlanToBox(20, 10, 10, 20, 400, 300)).toEqual({
190
+ x: 10,
191
+ y: 70,
192
+ width: 400,
193
+ height: 200,
194
+ })
195
+ })
196
+ })
197
+
198
+ describe('floor plan export policy', () => {
199
+ test('uses the live floor-plan formatting profile for metric and imperial dimensions', () => {
200
+ expect(resolveFloorplanExportViewState('metric', 'millimeters')).toMatchObject({
201
+ purpose: 'edit',
202
+ unit: 'metric',
203
+ metricNotation: 'millimeters',
204
+ })
205
+ expect(resolveFloorplanExportViewState('imperial', 'meters')).toMatchObject({
206
+ purpose: 'edit',
207
+ unit: 'imperial',
208
+ metricNotation: 'meters',
209
+ })
210
+ })
211
+
212
+ test('fits an oversized plan inside the complete export viewport', () => {
213
+ const placement = resolveFloorplanExportPlacement(30, 20, 10, 20, 400, 300)
214
+
215
+ expect(placement.x).toBe(10)
216
+ expect(placement.y).toBeCloseTo(36.67, 2)
217
+ expect(placement.width).toBe(400)
218
+ expect(placement.height).toBeCloseTo(266.67, 2)
219
+ expect(placement.x).toBeGreaterThanOrEqual(10)
220
+ expect(placement.y).toBeGreaterThanOrEqual(20)
221
+ expect(placement.x + placement.width).toBeLessThanOrEqual(410)
222
+ expect(placement.y + placement.height).toBeLessThanOrEqual(320)
223
+ })
224
+
225
+ test('exports the same annotation categories that are visible in the live view', () => {
226
+ const liveVisibility = {
227
+ automaticDimensions: true,
228
+ contextualDimensions: false,
229
+ manualDimensions: false,
230
+ measurements: true,
231
+ openingMarks: true,
232
+ structuralGrids: false,
233
+ roomLabels: false,
234
+ stairAnnotations: true,
235
+ }
236
+
237
+ expect(resolveFloorplanExportAnnotationVisibility('expert', liveVisibility)).toEqual(
238
+ liveVisibility,
239
+ )
240
+ })
241
+
242
+ test('exports only model geometry and room labels in Default', () => {
243
+ expect(
244
+ resolveFloorplanExportAnnotationVisibility(
245
+ 'default',
246
+ DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
247
+ ),
248
+ ).toEqual({
249
+ automaticDimensions: false,
250
+ contextualDimensions: false,
251
+ manualDimensions: false,
252
+ measurements: false,
253
+ openingMarks: false,
254
+ structuralGrids: false,
255
+ roomLabels: true,
256
+ stairAnnotations: false,
257
+ })
258
+ })
259
+
260
+ test('matches live screen sizing to the fitted export viewport', () => {
261
+ expect(resolveFloorplanScreenUnitsPerPixel(7, 4.5, 572, 463)).toBeCloseTo(0.012_237_762, 8)
262
+ })
263
+
264
+ test('keeps the export viewport anchored to the structural drawing bounds', () => {
265
+ expect(resolveFloorplanExportViewport({ x: -5, y: -6, width: 13, height: 13.5 })).toEqual({
266
+ x: -7.7,
267
+ y: -8.7,
268
+ width: 18.4,
269
+ height: 18.9,
270
+ })
271
+ })
272
+
273
+ test('fits the viewport around the rotated plan instead of clipping its corners', () => {
274
+ const bounds = rotateFloorplanExportBounds({ x: 0, y: 0, width: 10, height: 5 }, 90)
275
+
276
+ expect(bounds.x).toBeCloseTo(-5, 8)
277
+ expect(bounds.y).toBeCloseTo(0, 8)
278
+ expect(bounds.width).toBeCloseTo(5, 8)
279
+ expect(bounds.height).toBeCloseTo(10, 8)
280
+ })
281
+
282
+ test('keeps annotation-only nodes out of primary model bounds', () => {
283
+ expect(
284
+ isFloorplanExportAnnotationGeometry({
285
+ kind: 'group',
286
+ children: [],
287
+ metadata: { 'pascal:editor/floorplan': { annotationRole: 'measurement' } },
288
+ }),
289
+ ).toBe(true)
290
+ expect(
291
+ isFloorplanExportAnnotationGeometry({
292
+ kind: 'group',
293
+ children: [],
294
+ metadata: { 'pascal:editor/floorplan': { annotationRole: 'manual-dimension' } },
295
+ }),
296
+ ).toBe(true)
297
+ expect(isFloorplanExportAnnotationGeometry({ kind: 'polygon', points: [] })).toBe(false)
298
+ })
299
+
300
+ test('matches the current floor-plan rotation instead of forcing north-up', () => {
301
+ expect(resolveFloorplanExportRotationDeg(Math.PI / 6, Math.PI / 2)).toBeCloseTo(60, 8)
302
+ })
303
+ })
304
+
305
+ describe('resolveFloorplanMeasurementSize', () => {
306
+ test('sizes the hidden SVG in screen pixels before resolving label collisions', () => {
307
+ expect(
308
+ resolveFloorplanMeasurementSize({ x: -2, y: -3, width: 18.4, height: 18.9 }, 0.024),
309
+ ).toEqual({ width: 18.4 / 0.024, height: 18.9 / 0.024 })
310
+ })
311
+ })
312
+
313
+ describe('resolveFloorplanPageLayout', () => {
314
+ test('uses the available A4 page area for the fitted plan', () => {
315
+ expect(resolveFloorplanPageLayout(842, 595)).toEqual({
316
+ planBox: { x: 36, y: 64, width: 770, height: 495 },
317
+ })
318
+ })
319
+ })