@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
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
import {
|
|
4
4
|
type AnyNode,
|
|
5
5
|
type AnyNodeId,
|
|
6
|
+
type ConstructionDrawingType,
|
|
6
7
|
type FloorplanGeometry,
|
|
8
|
+
type FloorplanPalette,
|
|
9
|
+
type FloorplanPoint,
|
|
7
10
|
type LiveNodeOverrides,
|
|
8
11
|
nodeRegistry,
|
|
9
12
|
resolveBuildingForLevel,
|
|
@@ -13,14 +16,39 @@ import { useViewer } from '@pascal-app/viewer'
|
|
|
13
16
|
import { createElement } from 'react'
|
|
14
17
|
import { flushSync } from 'react-dom'
|
|
15
18
|
import { createRoot } from 'react-dom/client'
|
|
19
|
+
import { resolveSvgAnnotationCollisions } from '../../components/editor-2d/renderers/floorplan-annotation-layout'
|
|
16
20
|
import { FloorplanGeometryRenderer } from '../../components/editor-2d/renderers/floorplan-geometry-renderer'
|
|
17
21
|
import {
|
|
18
22
|
buildContext,
|
|
23
|
+
collectFloorplanLinkedLevelNodes,
|
|
19
24
|
floorplanLayerRank,
|
|
20
25
|
getFloorplanLevelData,
|
|
21
26
|
isFloorplanNodeVisible,
|
|
22
27
|
splitFloorplanOverlay,
|
|
23
28
|
} from '../../components/editor-2d/renderers/floorplan-registry-layer'
|
|
29
|
+
import useDrawingView, { DRAWING_TYPE_OPTIONS } from '../../store/use-drawing-view'
|
|
30
|
+
import useEditor from '../../store/use-editor'
|
|
31
|
+
import useFloorplanAnnotationVisibility from '../../store/use-floorplan-annotation-visibility'
|
|
32
|
+
import useFloorplanMode from '../../store/use-floorplan-mode'
|
|
33
|
+
import {
|
|
34
|
+
type FloorplanAnnotationVisibility,
|
|
35
|
+
filterFloorplanAnnotationGeometry,
|
|
36
|
+
} from './annotation-visibility'
|
|
37
|
+
import { resolveNodeForDrawingType } from './drawing-coordination'
|
|
38
|
+
import {
|
|
39
|
+
type FloorplanMetricNotation,
|
|
40
|
+
type FloorplanSchedule,
|
|
41
|
+
type FloorplanWallDimensionReference,
|
|
42
|
+
getFloorplanNodeExtension,
|
|
43
|
+
readFloorplanGeometryMetadata,
|
|
44
|
+
} from './floorplan-extension'
|
|
45
|
+
import {
|
|
46
|
+
type FloorplanMode,
|
|
47
|
+
resolveFloorplanAnnotationVisibility,
|
|
48
|
+
resolveFloorplanWallDimensionReference,
|
|
49
|
+
} from './floorplan-mode'
|
|
50
|
+
import { createFloorplanPdfDocument, type FloorplanPdfDocument } from './floorplan-pdfkit-document'
|
|
51
|
+
import { renderFloorplanGeometryToPdfKit } from './floorplan-pdfkit-renderer'
|
|
24
52
|
import { FLOORPLAN_VIEW_ROTATION_DEG } from './geometry'
|
|
25
53
|
|
|
26
54
|
/**
|
|
@@ -31,8 +59,9 @@ import { FLOORPLAN_VIEW_ROTATION_DEG } from './geometry'
|
|
|
31
59
|
* a neutral `viewState` so nodes render in their default, unselected form.
|
|
32
60
|
* Every level of the active building becomes its own page, titled with the
|
|
33
61
|
* level's label, with the plan fit to the page (independent of the live
|
|
34
|
-
* pan/zoom).
|
|
35
|
-
*
|
|
62
|
+
* pan/zoom). PDFKit is dynamically imported so it only loads when an export
|
|
63
|
+
* actually runs. Geometry and labels are emitted as native PDF vectors and
|
|
64
|
+
* text instead of being reinterpreted from browser SVG.
|
|
36
65
|
*
|
|
37
66
|
* `scope: 'structure'` keeps only `category === 'structure'` nodes (walls,
|
|
38
67
|
* slabs, ceilings, doors, windows, stairs, columns, roofs…); `'full'` keeps
|
|
@@ -41,37 +70,101 @@ import { FLOORPLAN_VIEW_ROTATION_DEG } from './geometry'
|
|
|
41
70
|
export type FloorplanExportScope = 'full' | 'structure'
|
|
42
71
|
|
|
43
72
|
const SVG_NS = 'http://www.w3.org/2000/svg'
|
|
44
|
-
/**
|
|
45
|
-
const
|
|
73
|
+
/** Minimum and proportional margin around the structural drawing bounds. */
|
|
74
|
+
const MIN_PLAN_PADDING_M = 1
|
|
75
|
+
const PLAN_PADDING_RATIO = 0.2
|
|
46
76
|
/** PDF page margin + title band, in pt. */
|
|
47
77
|
const PAGE_MARGIN_PT = 36
|
|
48
78
|
const TITLE_BAND_PT = 28
|
|
79
|
+
const A4_LANDSCAPE_WIDTH_PT = (297 / 25.4) * 72
|
|
80
|
+
const A4_LANDSCAPE_HEIGHT_PT = (210 / 25.4) * 72
|
|
81
|
+
|
|
82
|
+
const NEUTRAL_PALETTE: FloorplanPalette = {
|
|
83
|
+
selectedStroke: '#334155',
|
|
84
|
+
selectedFill: '#ffffff',
|
|
85
|
+
selectedHatch: '#334155',
|
|
86
|
+
wallHoverStroke: '#334155',
|
|
87
|
+
endpointHandleFill: '#ffffff',
|
|
88
|
+
endpointHandleStroke: '#334155',
|
|
89
|
+
endpointHandleHoverStroke: '#334155',
|
|
90
|
+
endpointHandleActiveFill: '#334155',
|
|
91
|
+
endpointHandleActiveStroke: '#334155',
|
|
92
|
+
curveHandleFill: '#ffffff',
|
|
93
|
+
curveHandleStroke: '#334155',
|
|
94
|
+
curveHandleHoverStroke: '#334155',
|
|
95
|
+
measurementStroke: '#334155',
|
|
96
|
+
measurementLabelBackground: '#ffffff',
|
|
97
|
+
measurementLabelText: '#111827',
|
|
98
|
+
}
|
|
49
99
|
|
|
50
|
-
// Neutral view state — no selection / hover
|
|
51
|
-
//
|
|
100
|
+
// Neutral view state — no selection / hover. A neutral palette keeps the
|
|
101
|
+
// full view state (including unit preference) available to node builders.
|
|
52
102
|
const NEUTRAL_VIEW_STATE = {
|
|
53
103
|
selected: false,
|
|
104
|
+
purpose: 'edit',
|
|
54
105
|
highlighted: false,
|
|
55
106
|
hovered: false,
|
|
56
107
|
moving: false,
|
|
57
|
-
palette:
|
|
108
|
+
palette: NEUTRAL_PALETTE,
|
|
58
109
|
} as const
|
|
59
110
|
|
|
111
|
+
export function resolveFloorplanExportViewState(
|
|
112
|
+
unit: 'metric' | 'imperial',
|
|
113
|
+
metricNotation: FloorplanMetricNotation,
|
|
114
|
+
wallDimensionReference?: FloorplanWallDimensionReference,
|
|
115
|
+
automaticDimensions = true,
|
|
116
|
+
) {
|
|
117
|
+
return {
|
|
118
|
+
...NEUTRAL_VIEW_STATE,
|
|
119
|
+
automaticDimensions,
|
|
120
|
+
unit,
|
|
121
|
+
metricNotation,
|
|
122
|
+
wallDimensionReference,
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
60
126
|
type ExportLevel = { id: AnyNodeId; label: string }
|
|
61
127
|
|
|
128
|
+
type ExportGeometry = {
|
|
129
|
+
id: AnyNodeId
|
|
130
|
+
model: FloorplanGeometry | null
|
|
131
|
+
annotations: FloorplanGeometry | null
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export type FloorplanPageLayout = {
|
|
135
|
+
planBox: { x: number; y: number; width: number; height: number }
|
|
136
|
+
}
|
|
137
|
+
|
|
62
138
|
export async function exportFloorplanPdf(scope: FloorplanExportScope): Promise<void> {
|
|
63
139
|
const nodes = useScene.getState().nodes
|
|
64
|
-
const
|
|
140
|
+
const viewer = useViewer.getState()
|
|
141
|
+
const unit = viewer.unit
|
|
142
|
+
const metricNotation = viewer.metricNotation
|
|
143
|
+
const floorplanMode = useFloorplanMode.getState().mode
|
|
144
|
+
const expertAnnotationState = useFloorplanAnnotationVisibility.getState()
|
|
145
|
+
const annotationVisibility = resolveFloorplanExportAnnotationVisibility(
|
|
146
|
+
floorplanMode,
|
|
147
|
+
expertAnnotationState.visibility,
|
|
148
|
+
)
|
|
149
|
+
const wallDimensionReference = resolveFloorplanWallDimensionReference(
|
|
150
|
+
floorplanMode,
|
|
151
|
+
expertAnnotationState.wallDimensionReference,
|
|
152
|
+
)
|
|
153
|
+
const navigationAzimuth = useEditor.getState().navigationSyncPose?.azimuth
|
|
154
|
+
const drawingType = useDrawingView.getState().drawingType
|
|
155
|
+
const annotationLayoutOverrides = useDrawingView.getState().annotationLayoutOverrides
|
|
156
|
+
const drawingLabel =
|
|
157
|
+
DRAWING_TYPE_OPTIONS.find((option) => option.id === drawingType)?.label ?? 'Floor plan'
|
|
65
158
|
const levels = resolveExportLevels(nodes)
|
|
66
159
|
if (levels.length === 0) {
|
|
67
160
|
console.warn('[floorplan-export] no level to export')
|
|
68
161
|
return
|
|
69
162
|
}
|
|
70
163
|
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
164
|
+
const { doc, save } = await createFloorplanPdfDocument([
|
|
165
|
+
A4_LANDSCAPE_WIDTH_PT,
|
|
166
|
+
A4_LANDSCAPE_HEIGHT_PT,
|
|
167
|
+
])
|
|
75
168
|
|
|
76
169
|
const host = document.createElement('div')
|
|
77
170
|
host.style.cssText =
|
|
@@ -81,53 +174,83 @@ export async function exportFloorplanPdf(scope: FloorplanExportScope): Promise<v
|
|
|
81
174
|
let pageCount = 0
|
|
82
175
|
try {
|
|
83
176
|
for (const level of levels) {
|
|
84
|
-
const geometries = collectFloorplanGeometry(
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
177
|
+
const geometries = collectFloorplanGeometry(
|
|
178
|
+
nodes,
|
|
179
|
+
level.id,
|
|
180
|
+
scope,
|
|
181
|
+
unit,
|
|
182
|
+
metricNotation,
|
|
183
|
+
annotationVisibility,
|
|
184
|
+
drawingType,
|
|
185
|
+
wallDimensionReference,
|
|
186
|
+
)
|
|
187
|
+
const schedules = collectFloorplanSchedules(nodes, level.id, unit)
|
|
188
|
+
if (geometries.length === 0 && schedules.length === 0) continue
|
|
189
|
+
const layout = resolveFloorplanPageLayout(A4_LANDSCAPE_WIDTH_PT, A4_LANDSCAPE_HEIGHT_PT)
|
|
190
|
+
|
|
191
|
+
if (geometries.length > 0) {
|
|
192
|
+
// Preserve the live floor-plan orientation rather than forcing north-up.
|
|
193
|
+
const buildingId = resolveBuildingForLevel(level.id, nodes as Record<AnyNodeId, AnyNode>)
|
|
194
|
+
const building = buildingId ? nodes[buildingId] : undefined
|
|
195
|
+
const buildingRotationY = building?.type === 'building' ? (building.rotation[1] ?? 0) : 0
|
|
196
|
+
const rotationDeg = resolveFloorplanExportRotationDeg(buildingRotationY, navigationAzimuth)
|
|
197
|
+
|
|
198
|
+
const mounted = await mountFloorplanSvg(
|
|
199
|
+
host,
|
|
200
|
+
geometries,
|
|
201
|
+
rotationDeg,
|
|
202
|
+
annotationLayoutOverrides,
|
|
203
|
+
)
|
|
204
|
+
if (mounted) {
|
|
205
|
+
try {
|
|
206
|
+
doc.addPage([A4_LANDSCAPE_WIDTH_PT, A4_LANDSCAPE_HEIGHT_PT], 'landscape')
|
|
207
|
+
pageCount++
|
|
208
|
+
|
|
209
|
+
const screenUnitsPerPixel = resolveFloorplanScreenUnitsPerPixel(
|
|
210
|
+
mounted.width,
|
|
211
|
+
mounted.height,
|
|
212
|
+
layout.planBox.width,
|
|
213
|
+
layout.planBox.height,
|
|
214
|
+
)
|
|
215
|
+
await mounted.setScreenUnitsPerPixel(screenUnitsPerPixel)
|
|
216
|
+
const fitted = resolveFloorplanExportPlacement(
|
|
217
|
+
mounted.width,
|
|
218
|
+
mounted.height,
|
|
219
|
+
layout.planBox.x,
|
|
220
|
+
layout.planBox.y,
|
|
221
|
+
layout.planBox.width,
|
|
222
|
+
layout.planBox.height,
|
|
223
|
+
)
|
|
224
|
+
drawFloorplanPageHeader(doc, level.label, drawingLabel)
|
|
225
|
+
const model = combineGeometryList(geometries.map((geometry) => geometry.model))
|
|
226
|
+
if (model) {
|
|
227
|
+
await renderFloorplanGeometryToPdfKit(doc, model, {
|
|
228
|
+
annotationLayer: false,
|
|
229
|
+
placement: fitted,
|
|
230
|
+
rotationDeg,
|
|
231
|
+
viewport: mounted.viewport,
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
const annotations = combineGeometryList(
|
|
235
|
+
geometries.map((geometry) => geometry.annotations),
|
|
236
|
+
)
|
|
237
|
+
if (annotations) {
|
|
238
|
+
await renderFloorplanGeometryToPdfKit(doc, annotations, {
|
|
239
|
+
annotationLabelShifts: mounted.annotationLabelShifts,
|
|
240
|
+
annotationLayer: true,
|
|
241
|
+
placement: fitted,
|
|
242
|
+
rotationDeg,
|
|
243
|
+
viewport: mounted.viewport,
|
|
244
|
+
})
|
|
245
|
+
}
|
|
246
|
+
} finally {
|
|
247
|
+
mounted.cleanup()
|
|
248
|
+
}
|
|
116
249
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
// many builders use to keep door/window/stair line weights constant
|
|
122
|
-
// on screen). Left as-is, those pixel-sized widths render as
|
|
123
|
-
// metre-wide strokes — huge grey blobs. Convert them to the real-unit
|
|
124
|
-
// width that lands at the intended point weight once svg2pdf scales
|
|
125
|
-
// the plan onto the page.
|
|
126
|
-
inlineNonScalingStrokes(mounted.svg, w / mounted.width)
|
|
127
|
-
|
|
128
|
-
await svg2pdf(mounted.svg, doc, { x, y, width: w, height: h })
|
|
129
|
-
} finally {
|
|
130
|
-
mounted.cleanup()
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (schedules.length > 0) {
|
|
253
|
+
pageCount = drawFloorplanSchedulePages(doc, level.label, schedules, pageCount)
|
|
131
254
|
}
|
|
132
255
|
}
|
|
133
256
|
|
|
@@ -137,24 +260,314 @@ export async function exportFloorplanPdf(scope: FloorplanExportScope): Promise<v
|
|
|
137
260
|
}
|
|
138
261
|
|
|
139
262
|
const date = new Date().toISOString().split('T')[0]
|
|
140
|
-
|
|
263
|
+
await save(`${drawingType}_${scope}_${date}.pdf`)
|
|
141
264
|
} finally {
|
|
142
265
|
host.remove()
|
|
143
266
|
}
|
|
144
267
|
}
|
|
145
268
|
|
|
269
|
+
export function collectFloorplanSchedules(
|
|
270
|
+
nodes: Record<string, AnyNode>,
|
|
271
|
+
levelId: AnyNodeId,
|
|
272
|
+
unit: 'metric' | 'imperial',
|
|
273
|
+
): FloorplanSchedule[] {
|
|
274
|
+
const siblingsByType = new Map<string, AnyNode[]>()
|
|
275
|
+
const visit = (id: AnyNodeId) => {
|
|
276
|
+
const node = nodes[id]
|
|
277
|
+
if (!node) return
|
|
278
|
+
if (node.visible !== false) {
|
|
279
|
+
const siblings = siblingsByType.get(node.type)
|
|
280
|
+
if (siblings) siblings.push(node)
|
|
281
|
+
else siblingsByType.set(node.type, [node])
|
|
282
|
+
}
|
|
283
|
+
const children = (node as { children?: AnyNodeId[] }).children
|
|
284
|
+
if (Array.isArray(children)) for (const childId of children) visit(childId)
|
|
285
|
+
}
|
|
286
|
+
visit(levelId)
|
|
287
|
+
|
|
288
|
+
const schedules: FloorplanSchedule[] = []
|
|
289
|
+
for (const [kind, definition] of nodeRegistry.entries()) {
|
|
290
|
+
const scheduleContribution = getFloorplanNodeExtension(definition)?.schedule
|
|
291
|
+
if (!scheduleContribution) continue
|
|
292
|
+
const siblings = siblingsByType.get(kind) ?? []
|
|
293
|
+
const schedule = scheduleContribution({ siblings, nodes, levelId, unit })
|
|
294
|
+
if (schedule && schedule.rows.length > 0) schedules.push(schedule)
|
|
295
|
+
}
|
|
296
|
+
return schedules
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function resolveFloorplanPageLayout(
|
|
300
|
+
pageWidth: number,
|
|
301
|
+
pageHeight: number,
|
|
302
|
+
): FloorplanPageLayout {
|
|
303
|
+
const planY = PAGE_MARGIN_PT + TITLE_BAND_PT
|
|
304
|
+
return {
|
|
305
|
+
planBox: {
|
|
306
|
+
x: PAGE_MARGIN_PT,
|
|
307
|
+
y: planY,
|
|
308
|
+
width: pageWidth - PAGE_MARGIN_PT * 2,
|
|
309
|
+
height: pageHeight - planY - PAGE_MARGIN_PT,
|
|
310
|
+
},
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function drawFloorplanPageHeader(
|
|
315
|
+
doc: FloorplanPdfDocument,
|
|
316
|
+
levelLabel: string,
|
|
317
|
+
drawingLabel: string,
|
|
318
|
+
): void {
|
|
319
|
+
doc.setTextColor('#111827')
|
|
320
|
+
doc.setFont('helvetica', 'bold')
|
|
321
|
+
doc.setFontSize(14)
|
|
322
|
+
doc.text(`${levelLabel} - ${drawingLabel}`, PAGE_MARGIN_PT, PAGE_MARGIN_PT + 12)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function drawFloorplanSchedulePages(
|
|
326
|
+
doc: FloorplanPdfDocument,
|
|
327
|
+
levelLabel: string,
|
|
328
|
+
schedules: readonly FloorplanSchedule[],
|
|
329
|
+
initialPageCount: number,
|
|
330
|
+
): number {
|
|
331
|
+
const pageW = doc.internal.pageSize.getWidth()
|
|
332
|
+
const pageH = doc.internal.pageSize.getHeight()
|
|
333
|
+
const tableWidth = pageW - PAGE_MARGIN_PT * 2
|
|
334
|
+
const bottom = pageH - PAGE_MARGIN_PT
|
|
335
|
+
const headerHeight = 22
|
|
336
|
+
const rowHeight = 20
|
|
337
|
+
let pageCount = initialPageCount
|
|
338
|
+
let y = 0
|
|
339
|
+
|
|
340
|
+
const startPage = () => {
|
|
341
|
+
doc.addPage([pageW, pageH])
|
|
342
|
+
pageCount++
|
|
343
|
+
doc.setTextColor('#111827')
|
|
344
|
+
doc.setFont('helvetica', 'bold')
|
|
345
|
+
doc.setFontSize(14)
|
|
346
|
+
doc.setLineWidth(0.5)
|
|
347
|
+
doc.text(`${levelLabel} - Construction Schedules`, PAGE_MARGIN_PT, PAGE_MARGIN_PT + 12)
|
|
348
|
+
y = PAGE_MARGIN_PT + TITLE_BAND_PT + 8
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const drawHeader = (schedule: FloorplanSchedule, continued: boolean) => {
|
|
352
|
+
doc.setTextColor('#111827')
|
|
353
|
+
doc.setFont('helvetica', 'bold')
|
|
354
|
+
doc.setFontSize(11)
|
|
355
|
+
doc.text(`${schedule.title}${continued ? ' (CONTINUED)' : ''}`, PAGE_MARGIN_PT, y + 11)
|
|
356
|
+
y += 18
|
|
357
|
+
doc.setFillColor('#334155')
|
|
358
|
+
doc.rect(PAGE_MARGIN_PT, y, tableWidth, headerHeight, 'F')
|
|
359
|
+
doc.setTextColor('#ffffff')
|
|
360
|
+
doc.setFontSize(8)
|
|
361
|
+
const widths = scheduleColumnWidths(schedule, tableWidth)
|
|
362
|
+
doc.setDrawColor('#64748b')
|
|
363
|
+
drawScheduleColumnDividers(doc, widths, y, headerHeight)
|
|
364
|
+
let x = PAGE_MARGIN_PT
|
|
365
|
+
schedule.columns.forEach((column, index) => {
|
|
366
|
+
const width = widths[index] ?? 0
|
|
367
|
+
doc.text(column.label, x + 4, y + 14, { maxWidth: Math.max(0, width - 8) })
|
|
368
|
+
x += width
|
|
369
|
+
})
|
|
370
|
+
y += headerHeight
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
startPage()
|
|
374
|
+
for (const schedule of schedules) {
|
|
375
|
+
const issueHeight = (schedule.issues?.length ?? 0) * 13
|
|
376
|
+
const minimumTableHeight = 18 + issueHeight + headerHeight + rowHeight
|
|
377
|
+
if (y + minimumTableHeight > bottom) startPage()
|
|
378
|
+
|
|
379
|
+
if (schedule.issues?.length) {
|
|
380
|
+
doc.setTextColor('#b45309')
|
|
381
|
+
doc.setFont('helvetica', 'normal')
|
|
382
|
+
doc.setFontSize(8)
|
|
383
|
+
for (const issue of schedule.issues) {
|
|
384
|
+
doc.text(`WARNING: ${issue}`, PAGE_MARGIN_PT, y + 9)
|
|
385
|
+
y += 13
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
drawHeader(schedule, false)
|
|
390
|
+
const widths = scheduleColumnWidths(schedule, tableWidth)
|
|
391
|
+
schedule.rows.forEach((row, rowIndex) => {
|
|
392
|
+
if (y + rowHeight > bottom) {
|
|
393
|
+
startPage()
|
|
394
|
+
drawHeader(schedule, true)
|
|
395
|
+
}
|
|
396
|
+
if (rowIndex % 2 === 1) {
|
|
397
|
+
doc.setFillColor('#f1f5f9')
|
|
398
|
+
doc.rect(PAGE_MARGIN_PT, y, tableWidth, rowHeight, 'F')
|
|
399
|
+
}
|
|
400
|
+
doc.setDrawColor('#cbd5e1')
|
|
401
|
+
doc.rect(PAGE_MARGIN_PT, y, tableWidth, rowHeight)
|
|
402
|
+
drawScheduleColumnDividers(doc, widths, y, rowHeight)
|
|
403
|
+
doc.setTextColor('#111827')
|
|
404
|
+
doc.setFont('helvetica', 'normal')
|
|
405
|
+
doc.setFontSize(8)
|
|
406
|
+
let x = PAGE_MARGIN_PT
|
|
407
|
+
schedule.columns.forEach((column, columnIndex) => {
|
|
408
|
+
const width = widths[columnIndex] ?? 0
|
|
409
|
+
const text = truncatePdfText(doc, row.cells[column.key] ?? '', Math.max(0, width - 8))
|
|
410
|
+
doc.text(text, x + 4, y + 13)
|
|
411
|
+
x += width
|
|
412
|
+
})
|
|
413
|
+
y += rowHeight
|
|
414
|
+
})
|
|
415
|
+
y += 20
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return pageCount
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function scheduleColumnWidths(schedule: FloorplanSchedule, tableWidth: number): number[] {
|
|
422
|
+
const totalWeight = schedule.columns.reduce((sum, column) => sum + (column.weight ?? 1), 0)
|
|
423
|
+
return schedule.columns.map((column) => (tableWidth * (column.weight ?? 1)) / totalWeight)
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function drawScheduleColumnDividers(
|
|
427
|
+
doc: FloorplanPdfDocument,
|
|
428
|
+
widths: readonly number[],
|
|
429
|
+
y: number,
|
|
430
|
+
height: number,
|
|
431
|
+
) {
|
|
432
|
+
let x = PAGE_MARGIN_PT
|
|
433
|
+
for (const width of widths.slice(0, -1)) {
|
|
434
|
+
x += width
|
|
435
|
+
doc.line(x, y, x, y + height)
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function truncatePdfText(doc: FloorplanPdfDocument, value: string, maxWidth: number): string {
|
|
440
|
+
if (doc.getTextWidth(value) <= maxWidth) return value
|
|
441
|
+
let truncated = value
|
|
442
|
+
while (truncated.length > 0 && doc.getTextWidth(`${truncated}...`) > maxWidth) {
|
|
443
|
+
truncated = truncated.slice(0, -1)
|
|
444
|
+
}
|
|
445
|
+
return `${truncated}...`
|
|
446
|
+
}
|
|
447
|
+
|
|
146
448
|
type MountedFloorplan = {
|
|
147
449
|
svg: SVGSVGElement
|
|
450
|
+
annotationLabelShifts: readonly FloorplanPoint[]
|
|
148
451
|
/** Padded viewBox dimensions, in meters — used for aspect-preserving fit. */
|
|
149
452
|
width: number
|
|
150
453
|
height: number
|
|
454
|
+
viewport: FloorplanExportBounds
|
|
455
|
+
setScreenUnitsPerPixel: (value: number) => Promise<void>
|
|
151
456
|
cleanup: () => void
|
|
152
457
|
}
|
|
153
458
|
|
|
459
|
+
export type FloorplanExportBounds = {
|
|
460
|
+
x: number
|
|
461
|
+
y: number
|
|
462
|
+
width: number
|
|
463
|
+
height: number
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
export function fitPlanToBox(
|
|
467
|
+
planWidth: number,
|
|
468
|
+
planHeight: number,
|
|
469
|
+
boxX: number,
|
|
470
|
+
boxY: number,
|
|
471
|
+
boxWidth: number,
|
|
472
|
+
boxHeight: number,
|
|
473
|
+
) {
|
|
474
|
+
const aspect = planWidth / planHeight
|
|
475
|
+
let width = boxWidth
|
|
476
|
+
let height = width / aspect
|
|
477
|
+
if (height > boxHeight) {
|
|
478
|
+
height = boxHeight
|
|
479
|
+
width = height * aspect
|
|
480
|
+
}
|
|
481
|
+
return {
|
|
482
|
+
x: boxX + (boxWidth - width) / 2,
|
|
483
|
+
y: boxY + (boxHeight - height) / 2,
|
|
484
|
+
width,
|
|
485
|
+
height,
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
export function resolveFloorplanExportPlacement(
|
|
490
|
+
planWidth: number,
|
|
491
|
+
planHeight: number,
|
|
492
|
+
boxX: number,
|
|
493
|
+
boxY: number,
|
|
494
|
+
boxWidth: number,
|
|
495
|
+
boxHeight: number,
|
|
496
|
+
) {
|
|
497
|
+
return fitPlanToBox(planWidth, planHeight, boxX, boxY, boxWidth, boxHeight)
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
export function resolveFloorplanExportViewport(
|
|
501
|
+
modelBounds: FloorplanExportBounds,
|
|
502
|
+
): FloorplanExportBounds {
|
|
503
|
+
const padding = Math.max(
|
|
504
|
+
MIN_PLAN_PADDING_M,
|
|
505
|
+
Math.max(modelBounds.width, modelBounds.height) * PLAN_PADDING_RATIO,
|
|
506
|
+
)
|
|
507
|
+
return {
|
|
508
|
+
x: modelBounds.x - padding,
|
|
509
|
+
y: modelBounds.y - padding,
|
|
510
|
+
width: modelBounds.width + padding * 2,
|
|
511
|
+
height: modelBounds.height + padding * 2,
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
export function rotateFloorplanExportBounds(
|
|
516
|
+
bounds: FloorplanExportBounds,
|
|
517
|
+
rotationDeg: number,
|
|
518
|
+
): FloorplanExportBounds {
|
|
519
|
+
const radians = (rotationDeg * Math.PI) / 180
|
|
520
|
+
const cosine = Math.cos(radians)
|
|
521
|
+
const sine = Math.sin(radians)
|
|
522
|
+
const corners = [
|
|
523
|
+
[bounds.x, bounds.y],
|
|
524
|
+
[bounds.x + bounds.width, bounds.y],
|
|
525
|
+
[bounds.x + bounds.width, bounds.y + bounds.height],
|
|
526
|
+
[bounds.x, bounds.y + bounds.height],
|
|
527
|
+
] as const
|
|
528
|
+
const rotated = corners.map(([x, y]) => ({
|
|
529
|
+
x: x * cosine - y * sine,
|
|
530
|
+
y: x * sine + y * cosine,
|
|
531
|
+
}))
|
|
532
|
+
const minX = Math.min(...rotated.map((point) => point.x))
|
|
533
|
+
const minY = Math.min(...rotated.map((point) => point.y))
|
|
534
|
+
const maxX = Math.max(...rotated.map((point) => point.x))
|
|
535
|
+
const maxY = Math.max(...rotated.map((point) => point.y))
|
|
536
|
+
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
export function resolveFloorplanScreenUnitsPerPixel(
|
|
540
|
+
modelWidth: number,
|
|
541
|
+
modelHeight: number,
|
|
542
|
+
boxWidth: number,
|
|
543
|
+
boxHeight: number,
|
|
544
|
+
): number {
|
|
545
|
+
return Math.max(modelWidth / boxWidth, modelHeight / boxHeight)
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
export function resolveFloorplanExportAnnotationVisibility(
|
|
549
|
+
mode: FloorplanMode,
|
|
550
|
+
liveVisibility: FloorplanAnnotationVisibility,
|
|
551
|
+
): FloorplanAnnotationVisibility {
|
|
552
|
+
return resolveFloorplanAnnotationVisibility(mode, liveVisibility, { target: 'export' })
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
export function resolveFloorplanExportRotationDeg(
|
|
556
|
+
buildingRotationY: number,
|
|
557
|
+
navigationAzimuth?: number,
|
|
558
|
+
): number {
|
|
559
|
+
const userRotationDeg =
|
|
560
|
+
navigationAzimuth === undefined
|
|
561
|
+
? 0
|
|
562
|
+
: (navigationAzimuth * 180) / Math.PI - FLOORPLAN_VIEW_ROTATION_DEG
|
|
563
|
+
return FLOORPLAN_VIEW_ROTATION_DEG + userRotationDeg - (buildingRotationY * 180) / Math.PI
|
|
564
|
+
}
|
|
565
|
+
|
|
154
566
|
async function mountFloorplanSvg(
|
|
155
567
|
parent: HTMLElement,
|
|
156
|
-
geometries:
|
|
568
|
+
geometries: ExportGeometry[],
|
|
157
569
|
rotationDeg: number,
|
|
570
|
+
annotationLayoutOverrides = useDrawingView.getState().annotationLayoutOverrides,
|
|
158
571
|
): Promise<MountedFloorplan | null> {
|
|
159
572
|
const container = document.createElement('div')
|
|
160
573
|
parent.appendChild(container)
|
|
@@ -164,101 +577,163 @@ async function mountFloorplanSvg(
|
|
|
164
577
|
container.remove()
|
|
165
578
|
}
|
|
166
579
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
flushSync(() => {
|
|
171
|
-
root.render(
|
|
172
|
-
createElement(
|
|
173
|
-
'svg',
|
|
174
|
-
{ xmlns: SVG_NS },
|
|
580
|
+
const render = (screenUnitsPerPixel?: number) => {
|
|
581
|
+
flushSync(() => {
|
|
582
|
+
root.render(
|
|
175
583
|
createElement(
|
|
176
|
-
'
|
|
177
|
-
{
|
|
584
|
+
'svg',
|
|
585
|
+
{ xmlns: SVG_NS },
|
|
178
586
|
createElement(
|
|
179
587
|
'g',
|
|
180
|
-
{
|
|
181
|
-
|
|
182
|
-
|
|
588
|
+
{ 'data-floorplan-content': '' },
|
|
589
|
+
createElement(
|
|
590
|
+
'g',
|
|
591
|
+
{ transform: `rotate(${rotationDeg})` },
|
|
592
|
+
createElement(
|
|
593
|
+
'g',
|
|
594
|
+
{ 'data-floorplan-model': '' },
|
|
595
|
+
geometries.map(({ id, model }) =>
|
|
596
|
+
model
|
|
597
|
+
? createElement(FloorplanGeometryRenderer, {
|
|
598
|
+
key: id,
|
|
599
|
+
geometry: model,
|
|
600
|
+
sceneRotationDeg: rotationDeg,
|
|
601
|
+
})
|
|
602
|
+
: null,
|
|
603
|
+
),
|
|
604
|
+
),
|
|
605
|
+
createElement(
|
|
606
|
+
'g',
|
|
607
|
+
{ 'data-floorplan-annotations': '' },
|
|
608
|
+
geometries.map(({ id, annotations }) =>
|
|
609
|
+
annotations
|
|
610
|
+
? createElement(FloorplanGeometryRenderer, {
|
|
611
|
+
key: id,
|
|
612
|
+
geometry: annotations,
|
|
613
|
+
renderMode: 'pdf',
|
|
614
|
+
sceneRotationDeg: rotationDeg,
|
|
615
|
+
screenUnitsPerPixel,
|
|
616
|
+
})
|
|
617
|
+
: null,
|
|
618
|
+
),
|
|
619
|
+
),
|
|
183
620
|
),
|
|
184
621
|
),
|
|
185
622
|
),
|
|
186
|
-
)
|
|
187
|
-
)
|
|
188
|
-
}
|
|
623
|
+
)
|
|
624
|
+
})
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
render()
|
|
189
628
|
|
|
190
629
|
// Give async asset images (item icons) a couple of frames to resolve so
|
|
191
630
|
// they're included in the measured bounds and the rendered output.
|
|
192
631
|
await nextFrames(2)
|
|
193
632
|
|
|
194
633
|
const svg = container.querySelector('svg')
|
|
195
|
-
|
|
196
|
-
const bbox = content?.getBBox()
|
|
197
|
-
if (!svg || !bbox || bbox.width === 0 || bbox.height === 0) {
|
|
634
|
+
if (!svg) {
|
|
198
635
|
cleanup()
|
|
199
636
|
return null
|
|
200
637
|
}
|
|
201
638
|
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
639
|
+
const modelBounds = measureFloorplanBounds(svg, '[data-floorplan-model]')
|
|
640
|
+
if (!modelBounds) {
|
|
641
|
+
cleanup()
|
|
642
|
+
return null
|
|
643
|
+
}
|
|
644
|
+
const viewport = resolveFloorplanExportViewport(
|
|
645
|
+
rotateFloorplanExportBounds(modelBounds, rotationDeg),
|
|
646
|
+
)
|
|
647
|
+
const mounted: MountedFloorplan = {
|
|
648
|
+
svg,
|
|
649
|
+
annotationLabelShifts: [],
|
|
650
|
+
width: viewport.width,
|
|
651
|
+
height: viewport.height,
|
|
652
|
+
viewport,
|
|
653
|
+
cleanup,
|
|
654
|
+
setScreenUnitsPerPixel: async (value) => {
|
|
655
|
+
render(value)
|
|
656
|
+
applyFloorplanViewport(mounted, viewport, value)
|
|
657
|
+
await nextFrames(1)
|
|
658
|
+
resolveSvgAnnotationCollisions(svg, { layoutOverrides: annotationLayoutOverrides })
|
|
659
|
+
mounted.annotationLabelShifts = readSvgAnnotationLabelShifts(svg)
|
|
660
|
+
},
|
|
661
|
+
}
|
|
662
|
+
applyFloorplanViewport(mounted, viewport)
|
|
663
|
+
return mounted
|
|
664
|
+
}
|
|
209
665
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
666
|
+
function readSvgAnnotationLabelShifts(svg: SVGSVGElement): FloorplanPoint[] {
|
|
667
|
+
return Array.from(svg.querySelectorAll<SVGGElement>('[data-floorplan-annotation-label]')).map(
|
|
668
|
+
(label) => {
|
|
669
|
+
const x = Number(label.dataset.floorplanAnnotationLayoutDx ?? 0)
|
|
670
|
+
const y = Number(label.dataset.floorplanAnnotationLayoutDy ?? 0)
|
|
671
|
+
return [Number.isFinite(x) ? x : 0, Number.isFinite(y) ? y : 0]
|
|
672
|
+
},
|
|
673
|
+
)
|
|
674
|
+
}
|
|
217
675
|
|
|
218
|
-
|
|
676
|
+
function measureFloorplanBounds(
|
|
677
|
+
svg: SVGSVGElement,
|
|
678
|
+
selector: string,
|
|
679
|
+
): FloorplanExportBounds | null {
|
|
680
|
+
const content = svg.querySelector(selector) as SVGGraphicsElement | null
|
|
681
|
+
const bbox = content?.getBBox()
|
|
682
|
+
if (!bbox || bbox.width === 0 || bbox.height === 0) return null
|
|
683
|
+
return { x: bbox.x, y: bbox.y, width: bbox.width, height: bbox.height }
|
|
219
684
|
}
|
|
220
685
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
* the now-misleading attribute.
|
|
229
|
-
*/
|
|
230
|
-
function inlineNonScalingStrokes(svg: SVGSVGElement, ptPerUnit: number) {
|
|
231
|
-
if (!Number.isFinite(ptPerUnit) || ptPerUnit <= 0) return
|
|
232
|
-
for (const el of svg.querySelectorAll('[vector-effect="non-scaling-stroke"]')) {
|
|
233
|
-
const sw = el.getAttribute('stroke-width')
|
|
234
|
-
if (sw) {
|
|
235
|
-
const px = Number.parseFloat(sw)
|
|
236
|
-
if (Number.isFinite(px)) el.setAttribute('stroke-width', `${px / ptPerUnit}`)
|
|
237
|
-
}
|
|
238
|
-
const dash = el.getAttribute('stroke-dasharray')
|
|
239
|
-
if (dash) {
|
|
240
|
-
const scaled = dash
|
|
241
|
-
.split(/[\s,]+/)
|
|
242
|
-
.map((v) => {
|
|
243
|
-
const n = Number.parseFloat(v)
|
|
244
|
-
return Number.isFinite(n) ? `${n / ptPerUnit}` : v
|
|
245
|
-
})
|
|
246
|
-
.join(' ')
|
|
247
|
-
el.setAttribute('stroke-dasharray', scaled)
|
|
248
|
-
}
|
|
249
|
-
el.removeAttribute('vector-effect')
|
|
686
|
+
export function resolveFloorplanMeasurementSize(
|
|
687
|
+
viewport: FloorplanExportBounds,
|
|
688
|
+
screenUnitsPerPixel: number,
|
|
689
|
+
): { width: number; height: number } {
|
|
690
|
+
return {
|
|
691
|
+
width: viewport.width / screenUnitsPerPixel,
|
|
692
|
+
height: viewport.height / screenUnitsPerPixel,
|
|
250
693
|
}
|
|
251
694
|
}
|
|
252
695
|
|
|
696
|
+
function applyFloorplanViewport(
|
|
697
|
+
mounted: MountedFloorplan,
|
|
698
|
+
viewport: FloorplanExportBounds,
|
|
699
|
+
screenUnitsPerPixel?: number,
|
|
700
|
+
): void {
|
|
701
|
+
mounted.width = viewport.width
|
|
702
|
+
mounted.height = viewport.height
|
|
703
|
+
mounted.svg.setAttribute(
|
|
704
|
+
'viewBox',
|
|
705
|
+
`${viewport.x} ${viewport.y} ${viewport.width} ${viewport.height}`,
|
|
706
|
+
)
|
|
707
|
+
const measurementSize = screenUnitsPerPixel
|
|
708
|
+
? resolveFloorplanMeasurementSize(viewport, screenUnitsPerPixel)
|
|
709
|
+
: { width: mounted.width, height: mounted.height }
|
|
710
|
+
mounted.svg.setAttribute('width', `${measurementSize.width}`)
|
|
711
|
+
mounted.svg.setAttribute('height', `${measurementSize.height}`)
|
|
712
|
+
|
|
713
|
+
mounted.svg.querySelector('[data-floorplan-background]')?.remove()
|
|
714
|
+
const background = document.createElementNS(SVG_NS, 'rect')
|
|
715
|
+
background.setAttribute('data-floorplan-background', '')
|
|
716
|
+
background.setAttribute('x', `${viewport.x}`)
|
|
717
|
+
background.setAttribute('y', `${viewport.y}`)
|
|
718
|
+
background.setAttribute('width', `${mounted.width}`)
|
|
719
|
+
background.setAttribute('height', `${mounted.height}`)
|
|
720
|
+
background.setAttribute('fill', '#ffffff')
|
|
721
|
+
mounted.svg.insertBefore(background, mounted.svg.firstChild)
|
|
722
|
+
}
|
|
723
|
+
|
|
253
724
|
function collectFloorplanGeometry(
|
|
254
725
|
nodes: Record<string, AnyNode>,
|
|
255
726
|
levelId: AnyNodeId,
|
|
256
727
|
scope: FloorplanExportScope,
|
|
257
728
|
unit: 'metric' | 'imperial',
|
|
258
|
-
|
|
729
|
+
metricNotation: FloorplanMetricNotation,
|
|
730
|
+
annotationVisibility: FloorplanAnnotationVisibility,
|
|
731
|
+
drawingType: ConstructionDrawingType,
|
|
732
|
+
wallDimensionReference: FloorplanWallDimensionReference,
|
|
733
|
+
): ExportGeometry[] {
|
|
259
734
|
const noLiveOverrides = new Map<string, LiveNodeOverrides>()
|
|
260
735
|
const levelNodeIdsByType = new Map<string, AnyNodeId[]>()
|
|
261
|
-
const entries: { id: AnyNodeId; node: AnyNode }[] = []
|
|
736
|
+
const entries: { id: AnyNodeId; node: AnyNode; parentOverride?: AnyNode }[] = []
|
|
262
737
|
|
|
263
738
|
const visit = (id: AnyNodeId) => {
|
|
264
739
|
const node = nodes[id]
|
|
@@ -274,13 +749,31 @@ function collectFloorplanGeometry(
|
|
|
274
749
|
isFloorplanNodeVisible(node) &&
|
|
275
750
|
(scope === 'full' || def.category === 'structure')
|
|
276
751
|
) {
|
|
277
|
-
|
|
752
|
+
const drawingNode = resolveNodeForDrawingType(node, nodes, drawingType)
|
|
753
|
+
if (drawingNode) entries.push({ id, node: drawingNode })
|
|
278
754
|
}
|
|
279
755
|
const childIds = (node as { children?: AnyNodeId[] }).children
|
|
280
756
|
if (Array.isArray(childIds)) for (const cid of childIds) visit(cid)
|
|
281
757
|
}
|
|
282
758
|
visit(levelId)
|
|
283
759
|
|
|
760
|
+
const activeLevelNode = nodes[levelId]
|
|
761
|
+
if (activeLevelNode) {
|
|
762
|
+
const collectedIds = new Set(entries.map((entry) => entry.id))
|
|
763
|
+
for (const linked of collectFloorplanLinkedLevelNodes(nodes, levelId, collectedIds)) {
|
|
764
|
+
const definition = nodeRegistry.get(linked.node.type)
|
|
765
|
+
if (
|
|
766
|
+
isFloorplanNodeVisible(linked.node) &&
|
|
767
|
+
(scope === 'full' || definition?.category === 'structure')
|
|
768
|
+
) {
|
|
769
|
+
const drawingNode = resolveNodeForDrawingType(linked.node, nodes, drawingType)
|
|
770
|
+
if (drawingNode) {
|
|
771
|
+
entries.push({ id: linked.id, node: drawingNode, parentOverride: activeLevelNode })
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
284
777
|
// Document order is paint order — sort the same way the live layer does so
|
|
285
778
|
// zones sit under walls/slabs/furniture rather than on top of them.
|
|
286
779
|
entries.sort((a, b) => floorplanLayerRank(a.node.type) - floorplanLayerRank(b.node.type))
|
|
@@ -288,8 +781,8 @@ function collectFloorplanGeometry(
|
|
|
288
781
|
// One-shot per-type cache for `computeFloorplanLevelData`; value type is
|
|
289
782
|
// module-private to the registry layer, so let it infer.
|
|
290
783
|
const levelDataCache = new Map()
|
|
291
|
-
const out:
|
|
292
|
-
for (const { id, node } of entries) {
|
|
784
|
+
const out: ExportGeometry[] = []
|
|
785
|
+
for (const { id, node, parentOverride } of entries) {
|
|
293
786
|
const builder = nodeRegistry.get(node.type)?.floorplan
|
|
294
787
|
if (!builder) continue
|
|
295
788
|
const levelData = getFloorplanLevelData(
|
|
@@ -299,15 +792,136 @@ function collectFloorplanGeometry(
|
|
|
299
792
|
levelNodeIdsByType,
|
|
300
793
|
levelDataCache,
|
|
301
794
|
)
|
|
302
|
-
const
|
|
795
|
+
const baseContext = buildContext(
|
|
796
|
+
node,
|
|
797
|
+
nodes,
|
|
798
|
+
resolveFloorplanExportViewState(
|
|
799
|
+
unit,
|
|
800
|
+
metricNotation,
|
|
801
|
+
wallDimensionReference,
|
|
802
|
+
annotationVisibility.automaticDimensions,
|
|
803
|
+
),
|
|
804
|
+
levelData,
|
|
805
|
+
)
|
|
806
|
+
const ctx = parentOverride ? { ...baseContext, parent: parentOverride } : baseContext
|
|
303
807
|
const geometry = builder(node, ctx)
|
|
304
808
|
if (!geometry) continue
|
|
305
|
-
const
|
|
306
|
-
if (
|
|
809
|
+
const visibleGeometry = filterFloorplanAnnotationGeometry(geometry, annotationVisibility)
|
|
810
|
+
if (!visibleGeometry) continue
|
|
811
|
+
const { base, overlay } = splitFloorplanOverlay(visibleGeometry)
|
|
812
|
+
const exportOverlay = overlay ? filterFloorplanExportOverlay(overlay) : null
|
|
813
|
+
const annotationOnly = isFloorplanExportAnnotationGeometry(visibleGeometry)
|
|
814
|
+
const { model, annotations } = resolveFloorplanExportNodeGeometry(
|
|
815
|
+
base,
|
|
816
|
+
exportOverlay,
|
|
817
|
+
annotationOnly,
|
|
818
|
+
)
|
|
819
|
+
if (model || annotations) out.push({ id, model, annotations })
|
|
307
820
|
}
|
|
308
821
|
return out
|
|
309
822
|
}
|
|
310
823
|
|
|
824
|
+
export function filterFloorplanExportOverlay(
|
|
825
|
+
geometry: FloorplanGeometry,
|
|
826
|
+
): FloorplanGeometry | null {
|
|
827
|
+
if (FLOORPLAN_EXPORT_EDITING_KINDS.has(geometry.kind)) return null
|
|
828
|
+
if (geometry.kind !== 'group') return geometry
|
|
829
|
+
|
|
830
|
+
const children = geometry.children
|
|
831
|
+
.map(filterFloorplanExportOverlay)
|
|
832
|
+
.filter((child): child is FloorplanGeometry => child !== null)
|
|
833
|
+
if (children.length === 0) return null
|
|
834
|
+
return { ...geometry, children }
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
const FLOORPLAN_EXPORT_EDITING_KINDS = new Set<FloorplanGeometry['kind']>([
|
|
838
|
+
'endpoint-handle',
|
|
839
|
+
'midpoint-handle',
|
|
840
|
+
'edge-handle',
|
|
841
|
+
'move-handle',
|
|
842
|
+
'move-arrow',
|
|
843
|
+
'rotate-arrow',
|
|
844
|
+
])
|
|
845
|
+
|
|
846
|
+
type FloorplanExportOverlayPartition = {
|
|
847
|
+
model: FloorplanGeometry | null
|
|
848
|
+
annotations: FloorplanGeometry | null
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
export function resolveFloorplanExportNodeGeometry(
|
|
852
|
+
base: FloorplanGeometry | null,
|
|
853
|
+
overlay: FloorplanGeometry | null,
|
|
854
|
+
annotationOnly: boolean,
|
|
855
|
+
): FloorplanExportOverlayPartition {
|
|
856
|
+
const combined = combineGeometry(base, overlay)
|
|
857
|
+
if (annotationOnly) return { model: null, annotations: combined }
|
|
858
|
+
return combined ? partitionFloorplanExportOverlay(combined) : { model: null, annotations: null }
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
export function partitionFloorplanExportOverlay(
|
|
862
|
+
geometry: FloorplanGeometry,
|
|
863
|
+
): FloorplanExportOverlayPartition {
|
|
864
|
+
if (FLOORPLAN_EXPORT_EDITING_KINDS.has(geometry.kind)) {
|
|
865
|
+
return { model: null, annotations: null }
|
|
866
|
+
}
|
|
867
|
+
if (isFloorplanExportAnnotationGeometry(geometry)) {
|
|
868
|
+
return { model: null, annotations: filterFloorplanExportOverlay(geometry) }
|
|
869
|
+
}
|
|
870
|
+
if (geometry.kind !== 'group') {
|
|
871
|
+
return { model: geometry, annotations: null }
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
const modelChildren: FloorplanGeometry[] = []
|
|
875
|
+
const annotationChildren: FloorplanGeometry[] = []
|
|
876
|
+
for (const child of geometry.children) {
|
|
877
|
+
const partition = partitionFloorplanExportOverlay(child)
|
|
878
|
+
if (partition.model) modelChildren.push(partition.model)
|
|
879
|
+
if (partition.annotations) annotationChildren.push(partition.annotations)
|
|
880
|
+
}
|
|
881
|
+
return {
|
|
882
|
+
model:
|
|
883
|
+
modelChildren.length > 0
|
|
884
|
+
? { kind: 'group', children: modelChildren, transform: geometry.transform }
|
|
885
|
+
: null,
|
|
886
|
+
annotations:
|
|
887
|
+
annotationChildren.length > 0
|
|
888
|
+
? { kind: 'group', children: annotationChildren, transform: geometry.transform }
|
|
889
|
+
: null,
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
export function isFloorplanExportAnnotationGeometry(geometry: FloorplanGeometry): boolean {
|
|
894
|
+
if (
|
|
895
|
+
geometry.kind === 'text' ||
|
|
896
|
+
geometry.kind === 'dimension' ||
|
|
897
|
+
geometry.kind === 'dimension-string' ||
|
|
898
|
+
geometry.kind === 'dimension-label' ||
|
|
899
|
+
geometry.kind === 'equal-spacing-badge'
|
|
900
|
+
) {
|
|
901
|
+
return true
|
|
902
|
+
}
|
|
903
|
+
if (readFloorplanGeometryMetadata(geometry).annotationRole) return true
|
|
904
|
+
return false
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function combineGeometry(
|
|
908
|
+
base: FloorplanGeometry | null,
|
|
909
|
+
overlay: FloorplanGeometry | null,
|
|
910
|
+
): FloorplanGeometry | null {
|
|
911
|
+
if (!base) return overlay
|
|
912
|
+
if (!overlay) return base
|
|
913
|
+
return { kind: 'group', children: [base, overlay] }
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
function combineGeometryList(
|
|
917
|
+
geometries: readonly (FloorplanGeometry | null)[],
|
|
918
|
+
): FloorplanGeometry | null {
|
|
919
|
+
const children = geometries.filter((geometry): geometry is FloorplanGeometry => geometry !== null)
|
|
920
|
+
if (children.length === 0) return null
|
|
921
|
+
if (children.length === 1) return children[0] ?? null
|
|
922
|
+
return { kind: 'group', children }
|
|
923
|
+
}
|
|
924
|
+
|
|
311
925
|
/**
|
|
312
926
|
* Levels to export, ordered bottom-to-top. The active building (the building
|
|
313
927
|
* owning the selected level, or the first one found) contributes all of its
|