@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,43 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type { GeometryContext } from '@pascal-app/core'
3
+ import {
4
+ createFloorplanContextExtensions,
5
+ normalizeFloorplanWallDimensionReference,
6
+ readFloorplanContext,
7
+ } from './floorplan-extension'
8
+
9
+ function context(extensions?: Readonly<Record<string, unknown>>): GeometryContext {
10
+ return {
11
+ resolve: () => undefined,
12
+ children: [],
13
+ siblings: [],
14
+ parent: null,
15
+ extensions,
16
+ }
17
+ }
18
+
19
+ describe('floor-plan context extensions', () => {
20
+ test('defaults wall dimensions to finished faces', () => {
21
+ expect(readFloorplanContext(context()).wallDimensionReference).toBe('finished-faces')
22
+ })
23
+
24
+ test('carries the selected centerline or stud-face reference', () => {
25
+ for (const wallDimensionReference of ['centerline', 'stud-faces'] as const) {
26
+ const extensions = createFloorplanContextExtensions({ wallDimensionReference })
27
+ expect(readFloorplanContext(context(extensions)).wallDimensionReference).toBe(
28
+ wallDimensionReference,
29
+ )
30
+ }
31
+ })
32
+
33
+ test('normalizes stale persisted references to the finished-face default', () => {
34
+ expect(normalizeFloorplanWallDimensionReference('unknown')).toBe('finished-faces')
35
+ expect(normalizeFloorplanWallDimensionReference(null)).toBe('finished-faces')
36
+ })
37
+
38
+ test('lets presentation code suppress automatic annotation construction', () => {
39
+ expect(readFloorplanContext(context()).automaticDimensions).toBe(true)
40
+ const extensions = createFloorplanContextExtensions({ automaticDimensions: false })
41
+ expect(readFloorplanContext(context(extensions)).automaticDimensions).toBe(false)
42
+ })
43
+ })
@@ -0,0 +1,181 @@
1
+ import type {
2
+ AnyNode,
3
+ AnyNodeId,
4
+ ConstructionDrawingType,
5
+ FloorplanGeometry,
6
+ GeometryContext,
7
+ NodeDefinition,
8
+ SceneApi,
9
+ } from '@pascal-app/core'
10
+ import type { ComponentType } from 'react'
11
+
12
+ export const FLOORPLAN_NODE_EXTENSION_KEY = 'pascal:editor/floorplan'
13
+ export const FLOORPLAN_GEOMETRY_METADATA_KEY = 'pascal:editor/floorplan'
14
+ export const FLOORPLAN_CONTEXT_EXTENSION_KEY = 'pascal:editor/floorplan'
15
+
16
+ export type FloorplanRenderPurpose = 'edit' | 'document'
17
+ export type FloorplanMetricNotation = 'meters' | 'millimeters'
18
+ export type FloorplanToolMode = 'default' | 'expert'
19
+ export type FloorplanWallDimensionReference = 'finished-faces' | 'centerline' | 'stud-faces'
20
+ export const DEFAULT_FLOORPLAN_WALL_DIMENSION_REFERENCE = 'finished-faces'
21
+ export type FloorplanAnnotationRole =
22
+ | 'automatic-dimension'
23
+ | 'contextual-dimension'
24
+ | 'manual-dimension'
25
+ | 'measurement'
26
+ | 'opening-mark'
27
+ | 'structural-grid'
28
+ | 'column-center'
29
+ | 'room-label'
30
+ | 'stair-annotation'
31
+
32
+ export type FloorplanSchedule = {
33
+ id: string
34
+ title: string
35
+ columns: ReadonlyArray<{
36
+ key: string
37
+ label: string
38
+ weight?: number
39
+ }>
40
+ rows: ReadonlyArray<{
41
+ id: string
42
+ cells: Readonly<Record<string, string>>
43
+ }>
44
+ issues?: readonly string[]
45
+ }
46
+
47
+ export type FloorplanToolContext = {
48
+ sceneApi: SceneApi
49
+ activeLevelId: AnyNodeId | null
50
+ unit: 'metric' | 'imperial'
51
+ metricNotation: FloorplanMetricNotation
52
+ gridSnapStep: number
53
+ toolDefaults: Readonly<Record<string, unknown>> | null
54
+ selectNode: (id: AnyNodeId) => void
55
+ finishTool: () => void
56
+ }
57
+
58
+ export type FloorplanNodeExtension<N extends AnyNode = AnyNode> = {
59
+ tool?: () => Promise<{ default: ComponentType<FloorplanToolContext> }>
60
+ availableModes?: readonly FloorplanToolMode[]
61
+ preferredView?: '2d' | '3d'
62
+ referencedSelectionAnnotationRole?: FloorplanAnnotationRole
63
+ contextualDimensions?: (node: N, ctx: GeometryContext) => FloorplanGeometry | null
64
+ actionMenu?: {
65
+ canCurve?: (args: { node: N; nodes: Readonly<Record<AnyNodeId, AnyNode>> }) => boolean
66
+ }
67
+ schedule?: (args: {
68
+ siblings: ReadonlyArray<N>
69
+ nodes: Readonly<Record<string, AnyNode>>
70
+ levelId: AnyNodeId
71
+ unit: 'metric' | 'imperial'
72
+ }) => FloorplanSchedule | null
73
+ linkedLevelIds?: (node: N) => readonly AnyNodeId[]
74
+ resolveForDrawing?: (args: {
75
+ node: N
76
+ nodes: Record<string, AnyNode>
77
+ drawingType: ConstructionDrawingType
78
+ }) => AnyNode | null
79
+ }
80
+
81
+ type FloorplanGeometryMetadata = {
82
+ annotationRole?: FloorplanAnnotationRole
83
+ annotationObstacle?: 'bounds' | 'outline'
84
+ renderPass?: 'overlay'
85
+ }
86
+
87
+ type FloorplanContextExtension = {
88
+ automaticDimensions: boolean
89
+ purpose: FloorplanRenderPurpose
90
+ metricNotation: FloorplanMetricNotation
91
+ wallDimensionReference: FloorplanWallDimensionReference
92
+ }
93
+
94
+ export function normalizeFloorplanWallDimensionReference(
95
+ value: unknown,
96
+ ): FloorplanWallDimensionReference {
97
+ return value === 'centerline' || value === 'stud-faces'
98
+ ? value
99
+ : DEFAULT_FLOORPLAN_WALL_DIMENSION_REFERENCE
100
+ }
101
+
102
+ export function getFloorplanNodeExtension(
103
+ definition: NodeDefinition<any> | undefined,
104
+ ): FloorplanNodeExtension | undefined {
105
+ return definition?.extensions?.[FLOORPLAN_NODE_EXTENSION_KEY] as
106
+ | FloorplanNodeExtension
107
+ | undefined
108
+ }
109
+
110
+ export function floorplanGeometryMetadata(
111
+ values: FloorplanGeometryMetadata,
112
+ ): Readonly<Record<string, unknown>> {
113
+ return { [FLOORPLAN_GEOMETRY_METADATA_KEY]: values }
114
+ }
115
+
116
+ export function withFloorplanGeometryMetadata<T extends FloorplanGeometry | null>(
117
+ geometry: T,
118
+ values: FloorplanGeometryMetadata,
119
+ ): T {
120
+ if (!geometry) return geometry
121
+ const existing = readFloorplanGeometryMetadata(geometry)
122
+ return {
123
+ ...geometry,
124
+ metadata: floorplanGeometryMetadata({ ...existing, ...values }),
125
+ } as T
126
+ }
127
+
128
+ export function readFloorplanGeometryMetadata(geometry: unknown): FloorplanGeometryMetadata {
129
+ const metadata = (geometry as { metadata?: Readonly<Record<string, unknown>> } | null)?.metadata
130
+ const value = metadata?.[FLOORPLAN_GEOMETRY_METADATA_KEY]
131
+ return value && typeof value === 'object' && !Array.isArray(value)
132
+ ? (value as FloorplanGeometryMetadata)
133
+ : {}
134
+ }
135
+
136
+ export function createFloorplanContextExtensions(
137
+ values: Partial<FloorplanContextExtension>,
138
+ ): Readonly<Record<string, unknown>> {
139
+ return {
140
+ [FLOORPLAN_CONTEXT_EXTENSION_KEY]: {
141
+ automaticDimensions: values.automaticDimensions !== false,
142
+ purpose: values.purpose === 'document' ? 'document' : 'edit',
143
+ metricNotation: values.metricNotation === 'millimeters' ? 'millimeters' : 'meters',
144
+ wallDimensionReference: normalizeFloorplanWallDimensionReference(
145
+ values.wallDimensionReference,
146
+ ),
147
+ } satisfies FloorplanContextExtension,
148
+ }
149
+ }
150
+
151
+ export function readFloorplanContext(ctx: GeometryContext): FloorplanContextExtension {
152
+ const value = ctx.extensions?.[FLOORPLAN_CONTEXT_EXTENSION_KEY]
153
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
154
+ const extension = value as Partial<FloorplanContextExtension>
155
+ return {
156
+ automaticDimensions: extension.automaticDimensions !== false,
157
+ purpose: extension.purpose === 'document' ? 'document' : 'edit',
158
+ metricNotation: extension.metricNotation === 'millimeters' ? 'millimeters' : 'meters',
159
+ wallDimensionReference: normalizeFloorplanWallDimensionReference(
160
+ extension.wallDimensionReference,
161
+ ),
162
+ }
163
+ }
164
+ return {
165
+ automaticDimensions: true,
166
+ purpose: 'edit',
167
+ metricNotation: 'meters',
168
+ wallDimensionReference: DEFAULT_FLOORPLAN_WALL_DIMENSION_REFERENCE,
169
+ }
170
+ }
171
+
172
+ export function readFloorplanMetricNotationOverride(
173
+ ctx: GeometryContext,
174
+ ): FloorplanMetricNotation | undefined {
175
+ const value = ctx.extensions?.[FLOORPLAN_CONTEXT_EXTENSION_KEY]
176
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
177
+ const metricNotation = (value as { metricNotation?: unknown }).metricNotation
178
+ return metricNotation === 'meters' || metricNotation === 'millimeters'
179
+ ? metricNotation
180
+ : undefined
181
+ }
@@ -0,0 +1,120 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY } from './annotation-visibility'
3
+ import {
4
+ isFloorplanToolAvailableInMode,
5
+ normalizeFloorplanMode,
6
+ normalizeFloorplanModesByProject,
7
+ resolveFloorplanAnnotationVisibility,
8
+ resolveFloorplanWallDimensionReference,
9
+ } from './floorplan-mode'
10
+
11
+ describe('floor-plan mode', () => {
12
+ test('defaults unknown and missing values to Default', () => {
13
+ expect(normalizeFloorplanMode(undefined)).toBe('default')
14
+ expect(normalizeFloorplanMode('legacy')).toBe('default')
15
+ expect(normalizeFloorplanMode('expert')).toBe('expert')
16
+ })
17
+
18
+ test('keeps only valid per-project preferences', () => {
19
+ expect(
20
+ normalizeFloorplanModesByProject({
21
+ alpha: 'expert',
22
+ beta: 'default',
23
+ invalid: 'legacy',
24
+ empty: null,
25
+ }),
26
+ ).toEqual({ alpha: 'expert', beta: 'default' })
27
+ })
28
+
29
+ test('limits tools only when their registry extension declares available modes', () => {
30
+ expect(isFloorplanToolAvailableInMode(undefined, 'default')).toBe(true)
31
+ expect(isFloorplanToolAvailableInMode(['expert'], 'default')).toBe(false)
32
+ expect(isFloorplanToolAvailableInMode(['expert'], 'expert')).toBe(true)
33
+ expect(isFloorplanToolAvailableInMode(['default', 'expert'], 'default')).toBe(true)
34
+ })
35
+
36
+ test('preserves the existing annotation profile in Expert', () => {
37
+ const expertVisibility = {
38
+ ...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
39
+ openingMarks: false,
40
+ roomLabels: false,
41
+ }
42
+
43
+ expect(
44
+ resolveFloorplanAnnotationVisibility('expert', expertVisibility, {
45
+ selected: false,
46
+ target: 'editor',
47
+ }),
48
+ ).toBe(expertVisibility)
49
+ })
50
+
51
+ test('keeps Default clean until an item is selected', () => {
52
+ expect(
53
+ resolveFloorplanAnnotationVisibility('default', DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY, {
54
+ selected: false,
55
+ target: 'editor',
56
+ }),
57
+ ).toEqual({
58
+ automaticDimensions: false,
59
+ contextualDimensions: false,
60
+ manualDimensions: false,
61
+ measurements: false,
62
+ openingMarks: false,
63
+ structuralGrids: false,
64
+ roomLabels: true,
65
+ stairAnnotations: false,
66
+ })
67
+
68
+ expect(
69
+ resolveFloorplanAnnotationVisibility('default', DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY, {
70
+ selected: true,
71
+ target: 'editor',
72
+ }),
73
+ ).toMatchObject({
74
+ automaticDimensions: false,
75
+ contextualDimensions: true,
76
+ manualDimensions: true,
77
+ measurements: true,
78
+ })
79
+ })
80
+
81
+ test('reveals a measurement when a referenced object is selected', () => {
82
+ expect(
83
+ resolveFloorplanAnnotationVisibility('default', DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY, {
84
+ referencedAnnotationRole: 'measurement',
85
+ target: 'editor',
86
+ }).measurements,
87
+ ).toBe(true)
88
+ expect(
89
+ resolveFloorplanAnnotationVisibility('default', DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY, {
90
+ referencedAnnotationRole: 'manual-dimension',
91
+ target: 'editor',
92
+ }).manualDimensions,
93
+ ).toBe(true)
94
+ })
95
+
96
+ test('exports Default without selection chrome or technical annotations', () => {
97
+ expect(
98
+ resolveFloorplanAnnotationVisibility('default', DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY, {
99
+ selected: true,
100
+ target: 'export',
101
+ }),
102
+ ).toEqual({
103
+ automaticDimensions: false,
104
+ contextualDimensions: false,
105
+ manualDimensions: false,
106
+ measurements: false,
107
+ openingMarks: false,
108
+ structuralGrids: false,
109
+ roomLabels: true,
110
+ stairAnnotations: false,
111
+ })
112
+ })
113
+
114
+ test('uses centerline dimensions only in Default', () => {
115
+ expect(resolveFloorplanWallDimensionReference('default', 'finished-faces')).toBe('centerline')
116
+ expect(resolveFloorplanWallDimensionReference('expert', 'finished-faces')).toBe(
117
+ 'finished-faces',
118
+ )
119
+ })
120
+ })
@@ -0,0 +1,73 @@
1
+ import {
2
+ type FloorplanAnnotationVisibility,
3
+ revealFloorplanAnnotationRole,
4
+ } from './annotation-visibility'
5
+ import type {
6
+ FloorplanAnnotationRole,
7
+ FloorplanToolMode,
8
+ FloorplanWallDimensionReference,
9
+ } from './floorplan-extension'
10
+
11
+ export const FLOORPLAN_MODES = ['default', 'expert'] as const
12
+
13
+ export type FloorplanMode = (typeof FLOORPLAN_MODES)[number]
14
+
15
+ export const DEFAULT_FLOORPLAN_MODE: FloorplanMode = 'default'
16
+
17
+ export type FloorplanPresentationContext = {
18
+ referencedAnnotationRole?: FloorplanAnnotationRole
19
+ selected?: boolean
20
+ target: 'editor' | 'export'
21
+ }
22
+
23
+ export function normalizeFloorplanMode(value: unknown): FloorplanMode {
24
+ return value === 'expert' ? 'expert' : DEFAULT_FLOORPLAN_MODE
25
+ }
26
+
27
+ export function normalizeFloorplanModesByProject(value: unknown): Record<string, FloorplanMode> {
28
+ if (!value || typeof value !== 'object') return {}
29
+ const modes: Record<string, FloorplanMode> = {}
30
+ for (const [projectId, mode] of Object.entries(value)) {
31
+ if (!projectId || (mode !== 'default' && mode !== 'expert')) continue
32
+ modes[projectId] = mode
33
+ }
34
+ return modes
35
+ }
36
+
37
+ export function isFloorplanToolAvailableInMode(
38
+ availableModes: readonly FloorplanToolMode[] | undefined,
39
+ mode: FloorplanMode,
40
+ ): boolean {
41
+ return availableModes === undefined || availableModes.includes(mode)
42
+ }
43
+
44
+ export function resolveFloorplanAnnotationVisibility(
45
+ mode: FloorplanMode,
46
+ expertVisibility: FloorplanAnnotationVisibility,
47
+ context: FloorplanPresentationContext,
48
+ ): FloorplanAnnotationVisibility {
49
+ if (mode === 'expert') return expertVisibility
50
+
51
+ const interactive = context.target === 'editor'
52
+ const selected = interactive && context.selected === true
53
+ const visibility: FloorplanAnnotationVisibility = {
54
+ automaticDimensions: false,
55
+ contextualDimensions: selected,
56
+ manualDimensions: selected,
57
+ measurements: selected,
58
+ openingMarks: false,
59
+ structuralGrids: false,
60
+ roomLabels: true,
61
+ stairAnnotations: false,
62
+ }
63
+ return interactive && context.referencedAnnotationRole
64
+ ? revealFloorplanAnnotationRole(visibility, context.referencedAnnotationRole)
65
+ : visibility
66
+ }
67
+
68
+ export function resolveFloorplanWallDimensionReference(
69
+ mode: FloorplanMode,
70
+ expertReference: FloorplanWallDimensionReference,
71
+ ): FloorplanWallDimensionReference {
72
+ return mode === 'default' ? 'centerline' : expertReference
73
+ }
@@ -0,0 +1,194 @@
1
+ import type PdfKitDocument from 'pdfkit'
2
+
3
+ type PdfKitDocumentInstance = InstanceType<typeof PdfKitDocument>
4
+
5
+ type PdfTextOptions = {
6
+ align?: 'left' | 'center' | 'right'
7
+ maxWidth?: number
8
+ }
9
+
10
+ type PdfShapeStyle = 'F' | 'S'
11
+
12
+ export class FloorplanPdfDocument {
13
+ readonly raw: PdfKitDocumentInstance
14
+ readonly internal: {
15
+ pageSize: {
16
+ getWidth: () => number
17
+ getHeight: () => number
18
+ }
19
+ }
20
+
21
+ private currentFontSize = 12
22
+ private readonly defaultPageSize: readonly [number, number]
23
+
24
+ constructor(raw: PdfKitDocumentInstance, defaultPageSize: readonly [number, number]) {
25
+ this.raw = raw
26
+ this.defaultPageSize = defaultPageSize
27
+ this.internal = {
28
+ pageSize: {
29
+ getWidth: () => this.raw.page?.width ?? this.defaultPageSize[0],
30
+ getHeight: () => this.raw.page?.height ?? this.defaultPageSize[1],
31
+ },
32
+ }
33
+ }
34
+
35
+ addPage(
36
+ size: readonly [number, number] = this.defaultPageSize,
37
+ _orientation?: 'portrait' | 'landscape',
38
+ ): this {
39
+ this.raw.addPage({ size: [size[0], size[1]], margin: 0 })
40
+ return this
41
+ }
42
+
43
+ setTextColor(color: string): this {
44
+ this.raw.fillColor(color)
45
+ return this
46
+ }
47
+
48
+ setDrawColor(color: string): this {
49
+ this.raw.strokeColor(color)
50
+ return this
51
+ }
52
+
53
+ setFillColor(color: string): this {
54
+ this.raw.fillColor(color)
55
+ return this
56
+ }
57
+
58
+ setLineWidth(width: number): this {
59
+ this.raw.lineWidth(width)
60
+ return this
61
+ }
62
+
63
+ setFont(family: string, weight: string = 'normal'): this {
64
+ const normalizedFamily = family.toLocaleLowerCase()
65
+ const normalizedWeight = weight.toLocaleLowerCase()
66
+ const bold = normalizedWeight === 'bold' || Number.parseInt(normalizedWeight, 10) >= 500
67
+ const base = normalizedFamily.includes('courier') ? 'Courier' : 'Helvetica'
68
+ this.raw.font(bold ? `${base}-Bold` : base)
69
+ return this
70
+ }
71
+
72
+ setFontSize(size: number): this {
73
+ this.currentFontSize = size
74
+ this.raw.fontSize(size)
75
+ return this
76
+ }
77
+
78
+ getTextWidth(value: string): number {
79
+ return this.raw.widthOfString(value, { lineBreak: false })
80
+ }
81
+
82
+ splitTextToSize(value: string, maxWidth: number): string[] {
83
+ if (maxWidth <= 0 || this.getTextWidth(value) <= maxWidth) return [value]
84
+ const words = value.trim().split(/\s+/)
85
+ const lines: string[] = []
86
+ let line = ''
87
+ for (const word of words) {
88
+ const candidate = line ? `${line} ${word}` : word
89
+ if (!line || this.getTextWidth(candidate) <= maxWidth) {
90
+ line = candidate
91
+ continue
92
+ }
93
+ lines.push(line)
94
+ line = word
95
+ }
96
+ if (line) lines.push(line)
97
+ return lines.length > 0 ? lines : ['']
98
+ }
99
+
100
+ text(
101
+ value: string | readonly string[],
102
+ x: number,
103
+ baselineY: number,
104
+ options: PdfTextOptions = {},
105
+ ) {
106
+ const lines = typeof value === 'string' ? value.split('\n') : value
107
+ const lineHeight = this.currentFontSize * 1.2
108
+ lines.forEach((line, index) => {
109
+ const width = this.getTextWidth(line)
110
+ const drawX =
111
+ options.align === 'center' ? x - width / 2 : options.align === 'right' ? x - width : x
112
+ this.raw.text(line, drawX, baselineY - this.currentFontSize * 0.78 + index * lineHeight, {
113
+ lineBreak: false,
114
+ width: options.maxWidth,
115
+ })
116
+ })
117
+ return this
118
+ }
119
+
120
+ line(x1: number, y1: number, x2: number, y2: number): this {
121
+ this.raw.moveTo(x1, y1).lineTo(x2, y2).stroke()
122
+ return this
123
+ }
124
+
125
+ rect(x: number, y: number, width: number, height: number, style: PdfShapeStyle = 'S'): this {
126
+ this.raw.rect(x, y, width, height)
127
+ if (style === 'F') this.raw.fill()
128
+ else this.raw.stroke()
129
+ return this
130
+ }
131
+
132
+ roundedRect(
133
+ x: number,
134
+ y: number,
135
+ width: number,
136
+ height: number,
137
+ radiusX: number,
138
+ _radiusY: number,
139
+ style: PdfShapeStyle = 'S',
140
+ ): this {
141
+ this.raw.roundedRect(x, y, width, height, radiusX)
142
+ if (style === 'F') this.raw.fill()
143
+ else this.raw.stroke()
144
+ return this
145
+ }
146
+
147
+ circle(x: number, y: number, radius: number, style: PdfShapeStyle = 'S'): this {
148
+ this.raw.circle(x, y, radius)
149
+ if (style === 'F') this.raw.fill()
150
+ else this.raw.stroke()
151
+ return this
152
+ }
153
+
154
+ triangle(
155
+ x1: number,
156
+ y1: number,
157
+ x2: number,
158
+ y2: number,
159
+ x3: number,
160
+ y3: number,
161
+ style: PdfShapeStyle = 'S',
162
+ ): this {
163
+ this.raw.polygon([x1, y1], [x2, y2], [x3, y3])
164
+ if (style === 'F') this.raw.fill()
165
+ else this.raw.stroke()
166
+ return this
167
+ }
168
+ }
169
+
170
+ export async function createFloorplanPdfDocument(defaultPageSize: readonly [number, number]) {
171
+ const [{ default: PDFDocument }, { default: blobStream }] = await Promise.all([
172
+ import('pdfkit/js/pdfkit.standalone'),
173
+ import('blob-stream'),
174
+ ])
175
+ const raw = new PDFDocument({ autoFirstPage: false, compress: true, margin: 0 })
176
+ const stream = raw.pipe(blobStream())
177
+ return {
178
+ doc: new FloorplanPdfDocument(raw, defaultPageSize),
179
+ save: async (filename: string) => {
180
+ const blob = await new Promise<Blob>((resolve, reject) => {
181
+ stream.on('finish', () => resolve(stream.toBlob('application/pdf')))
182
+ stream.on('error', reject)
183
+ raw.on('error', reject)
184
+ raw.end()
185
+ })
186
+ const url = URL.createObjectURL(blob)
187
+ const anchor = document.createElement('a')
188
+ anchor.href = url
189
+ anchor.download = filename
190
+ anchor.click()
191
+ setTimeout(() => URL.revokeObjectURL(url), 0)
192
+ },
193
+ }
194
+ }