@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.
- package/package.json +9 -7
- package/src/components/editor/bake-exporter.tsx +3 -1
- package/src/components/editor/bake-thumbnail.tsx +95 -0
- package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
- package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
- package/src/components/editor/custom-camera-controls.tsx +307 -212
- package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
- package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
- package/src/components/editor/first-person/build-collider-world.test.ts +66 -2
- package/src/components/editor/first-person/build-collider-world.ts +20 -0
- package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
- package/src/components/editor/first-person/terrain-collider.ts +212 -0
- package/src/components/editor/first-person-controls.tsx +295 -132
- package/src/components/editor/floating-action-menu.tsx +59 -65
- package/src/components/editor/floorplan-background-selection.test.ts +16 -2
- package/src/components/editor/floorplan-background-selection.ts +4 -18
- package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
- package/src/components/editor/floorplan-camera-sync.ts +213 -0
- package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
- package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
- package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
- package/src/components/editor/floorplan-panel.tsx +931 -861
- package/src/components/editor/group-actions.ts +155 -15
- package/src/components/editor/group-floating-action-menu.tsx +1 -1
- package/src/components/editor/group-rotate-handle.tsx +1 -1
- package/src/components/editor/group-selection-box-3d.tsx +1 -1
- package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
- package/src/components/editor/handles/handle-drag-history.ts +14 -0
- package/src/components/editor/handles/preview-overrides.test.ts +26 -0
- package/src/components/editor/handles/preview-overrides.ts +13 -0
- package/src/components/editor/handles/resize-snap.test.ts +52 -0
- package/src/components/editor/handles/resize-snap.ts +23 -0
- package/src/components/editor/handles/use-handle-drag.ts +17 -7
- package/src/components/editor/index.tsx +42 -9
- package/src/components/editor/measurement-pill.tsx +6 -1
- package/src/components/editor/node-arrow-handles.tsx +51 -15
- package/src/components/editor/quick-measurement-card.tsx +9 -3
- package/src/components/editor/quick-measurement-hud.tsx +7 -1
- package/src/components/editor/selection-manager.tsx +3 -1
- package/src/components/editor/site-edge-labels.tsx +24 -2
- package/src/components/editor/snapshot-capture-overlay.tsx +4 -1
- package/src/components/editor/thumbnail-generator.tsx +66 -294
- package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
- package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
- package/src/components/editor/use-mesh-settle-epoch.ts +1 -0
- package/src/components/editor/wall-measurement-label.tsx +8 -16
- package/src/components/editor/wall-move-side-handles.tsx +193 -18
- package/src/components/editor/wall-opening-highlights.tsx +63 -32
- package/src/components/editor/wall-snap-beacon-layer.tsx +6 -4
- package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +39 -8
- package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
- package/src/components/editor-2d/floorplan-group-move.tsx +47 -4
- package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +21 -5
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +93 -59
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +27 -0
- package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
- package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
- package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
- package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
- package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +75 -1
- package/src/components/editor-2d/renderers/floorplan-label-angle.ts +92 -0
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +93 -28
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +279 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +621 -281
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +12 -7
- package/src/components/tools/fence/fence-drafting.ts +27 -0
- package/src/components/tools/item/use-draft-node.ts +131 -72
- package/src/components/tools/item/use-placement-coordinator.tsx +52 -4
- package/src/components/tools/registry/move-registry-node-tool.tsx +75 -4
- package/src/components/tools/roof/roof-tool.tsx +16 -0
- package/src/components/tools/shared/floor-stack-preview.ts +4 -0
- package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
- package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
- package/src/components/tools/shared/pointer-support-cap.ts +273 -0
- package/src/components/tools/site/site-boundary-editor.tsx +13 -6
- package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
- package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
- package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
- package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
- package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
- package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
- package/src/components/tools/stair/stair-click-guard.ts +72 -0
- package/src/components/tools/stair/stair-tool.tsx +98 -47
- package/src/components/tools/tool-manager.tsx +67 -9
- package/src/components/tools/wall/wall-drafting.test.ts +118 -1
- package/src/components/tools/wall/wall-drafting.ts +128 -9
- package/src/components/tools/wall/wall-snap-geometry.test.ts +8 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +70 -3
- package/src/components/tools/zone/zone-tool.tsx +16 -0
- package/src/components/ui/action-menu/control-modes.tsx +4 -1
- package/src/components/ui/action-menu/measurement-control.tsx +107 -28
- package/src/components/ui/command-palette/editor-commands.tsx +14 -1
- package/src/components/ui/controls/material-paint-panel.tsx +7 -1
- package/src/components/ui/controls/material-picker.tsx +86 -7
- package/src/components/ui/controls/terrain-sculpt-panel.tsx +193 -0
- package/src/components/ui/floating-level-selector.tsx +73 -19
- package/src/components/ui/helpers/helper-manager.tsx +46 -0
- package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
- package/src/components/ui/panels/panel-manager.tsx +14 -1
- package/src/components/ui/panels/parametric-inspector.tsx +4 -2
- package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
- package/src/components/ui/panels/selection-breakdown.ts +20 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +14 -4
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +16 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +2 -0
- package/src/components/viewer/viewer-controls-bar.tsx +466 -0
- package/src/components/viewer/viewer-scene-header.tsx +235 -0
- package/src/components/viewer-overlay.tsx +20 -655
- package/src/components/walkthrough-hud.tsx +111 -0
- package/src/hooks/use-grid-events.ts +24 -0
- package/src/hooks/use-keyboard.ts +53 -30
- package/src/index.tsx +137 -4
- package/src/lib/active-placement-surface.test.ts +24 -0
- package/src/lib/active-placement-surface.ts +8 -1
- package/src/lib/camera-pose.test.ts +237 -0
- package/src/lib/camera-pose.ts +172 -0
- package/src/lib/direct-manipulation.test.ts +83 -0
- package/src/lib/direct-manipulation.ts +9 -0
- package/src/lib/door-interaction.ts +1 -1
- package/src/lib/elevation-guides.test.ts +107 -0
- package/src/lib/elevation-guides.ts +265 -0
- package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
- package/src/lib/floorplan/annotation-visibility.ts +133 -0
- package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
- package/src/lib/floorplan/drawing-coordination.ts +13 -0
- package/src/lib/floorplan/floorplan-export.test.ts +319 -0
- package/src/lib/floorplan/floorplan-export.tsx +745 -131
- package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
- package/src/lib/floorplan/floorplan-extension.ts +181 -0
- package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
- package/src/lib/floorplan/floorplan-mode.ts +73 -0
- package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
- package/src/lib/floorplan/geometry.ts +2 -3
- package/src/lib/fresh-planar-placement.test.ts +44 -1
- package/src/lib/fresh-planar-placement.ts +21 -4
- package/src/lib/glb-export.test.ts +116 -1
- package/src/lib/glb-export.ts +204 -7
- package/src/lib/ground-surface.ts +95 -0
- package/src/lib/history.test.ts +96 -0
- package/src/lib/history.ts +22 -0
- package/src/lib/interaction/overlay-policy.test.ts +2 -1
- package/src/lib/interaction/scope.ts +40 -8
- package/src/lib/level-duplication.test.ts +2 -1
- package/src/lib/measurements.test.ts +5 -0
- package/src/lib/measurements.ts +12 -1
- package/src/lib/quick-action-feedback.ts +33 -0
- package/src/lib/quick-action-nodes.test.ts +94 -0
- package/src/lib/quick-action-nodes.ts +47 -0
- package/src/lib/scene-clipboard.test.ts +130 -1
- package/src/lib/scene-clipboard.ts +237 -13
- package/src/lib/selection-routing.test.ts +16 -1
- package/src/lib/selection-routing.ts +5 -0
- package/src/lib/sfx-bus.ts +33 -2
- package/src/lib/sfx-player.test.ts +148 -6
- package/src/lib/sfx-player.ts +156 -8
- package/src/lib/site-boundary.test.ts +53 -0
- package/src/lib/site-boundary.ts +27 -0
- package/src/lib/snapping-mode.test.ts +29 -2
- package/src/lib/snapping-mode.ts +15 -3
- package/src/lib/stair-duplication.test.ts +103 -0
- package/src/lib/stair-duplication.ts +57 -85
- package/src/lib/stair-levels.ts +2 -0
- package/src/lib/terrain-sculpt.test.ts +429 -0
- package/src/lib/terrain-sculpt.ts +301 -0
- package/src/lib/terrain-verb-color.test.ts +51 -0
- package/src/lib/terrain-verb-color.ts +58 -0
- package/src/lib/touch-gesture-priority.test.ts +45 -0
- package/src/lib/touch-gesture-priority.ts +30 -0
- package/src/lib/window-interaction.ts +1 -1
- package/src/store/camera-pose-store.test.ts +29 -0
- package/src/store/camera-pose-store.ts +27 -0
- package/src/store/live-draft-preview-stores.test.ts +136 -0
- package/src/store/navigation-sync-pose-store.test.ts +34 -0
- package/src/store/navigation-sync-pose-store.ts +27 -0
- package/src/store/terrain-sculpt-mode.test.ts +223 -0
- package/src/store/use-delete-confirmation.ts +27 -0
- package/src/store/use-drawing-view.test.ts +23 -0
- package/src/store/use-drawing-view.ts +84 -0
- package/src/store/use-editor.tsx +184 -15
- package/src/store/use-elevation-guides.ts +40 -0
- package/src/store/use-fence-curve-draft.ts +38 -8
- package/src/store/use-first-person-hud.ts +40 -0
- package/src/store/use-floorplan-annotation-visibility.ts +60 -0
- package/src/store/use-floorplan-draft-preview.ts +54 -4
- package/src/store/use-floorplan-mode.ts +110 -0
- package/src/store/use-interaction-scope.test.ts +23 -3
- package/src/store/use-interaction-scope.ts +8 -0
- package/src/store/use-measurement-draft.test.ts +29 -0
- package/src/store/use-measurement-draft.ts +33 -6
- package/src/store/use-path-draft-preview.ts +90 -0
- package/src/store/use-stair-build-preview.ts +12 -2
- 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
|
+
}
|