@pascal-app/editor 0.9.1 → 0.9.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 +12 -9
- package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
- package/src/components/editor/bake-exporter.tsx +47 -0
- package/src/components/editor/custom-camera-controls.tsx +63 -25
- package/src/components/editor/editor-layout-mobile.tsx +5 -0
- package/src/components/editor/editor-layout-v2.tsx +18 -0
- package/src/components/editor/export-manager.tsx +71 -63
- package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
- package/src/components/editor/first-person/build-collider-world.test.ts +36 -3
- package/src/components/editor/first-person/build-collider-world.ts +32 -5
- package/src/components/editor/first-person-controls.tsx +118 -7
- package/src/components/editor/floating-action-menu.tsx +410 -33
- package/src/components/editor/floating-building-action-menu.tsx +1 -1
- package/src/components/editor/floorplan-background-selection.test.ts +58 -0
- package/src/components/editor/floorplan-background-selection.ts +3 -2
- package/src/components/editor/floorplan-panel.tsx +1804 -849
- package/src/components/editor/grid.tsx +166 -39
- package/src/components/editor/group-actions.ts +457 -0
- package/src/components/editor/group-floating-action-menu.tsx +130 -0
- package/src/components/editor/group-move-3d.ts +397 -0
- package/src/components/editor/group-rotate-handle.tsx +102 -64
- package/src/components/editor/group-selection-box-3d.tsx +173 -0
- package/src/components/editor/group-transform-shared.test.ts +192 -1
- package/src/components/editor/group-transform-shared.ts +240 -7
- package/src/components/editor/handles/handle-arrow.tsx +81 -23
- package/src/components/editor/handles/use-handle-drag.ts +37 -4
- package/src/components/editor/index.tsx +248 -27
- package/src/components/editor/measurement-pill.tsx +62 -22
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/node-arrow-handles.tsx +366 -173
- package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
- package/src/components/editor/quick-measurement-card.tsx +70 -0
- package/src/components/editor/quick-measurement-hud.tsx +28 -0
- package/src/components/editor/riser-diagram-panel.tsx +137 -0
- package/src/components/editor/selection-manager.tsx +812 -466
- package/src/components/editor/site-edge-labels.tsx +4 -4
- package/src/components/editor/slab-hole-highlights.tsx +13 -5
- package/src/components/editor/snapshot-capture-overlay.tsx +255 -115
- package/src/components/editor/three-context-bridge.ts +22 -0
- package/src/components/editor/thumbnail-generator.tsx +136 -43
- package/src/components/editor/use-floorplan-background-placement.ts +113 -45
- package/src/components/editor/use-floorplan-scene-data.ts +5 -5
- package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
- package/src/components/editor/wall-measurement-label.tsx +3 -13
- package/src/components/editor/wall-move-side-handles.tsx +36 -21
- package/src/components/editor/wall-snap-beacon-layer.tsx +160 -6
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +1 -1
- package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
- package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
- package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
- package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
- package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +169 -4
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +300 -59
- package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
- package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
- package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +4 -1
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1704 -342
- package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +419 -60
- package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
- package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
- package/src/components/systems/selection-affordance-manager.tsx +38 -0
- package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
- package/src/components/systems/zone/zone-system.tsx +42 -13
- package/src/components/tools/elevator/elevator-tool.tsx +34 -8
- package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
- package/src/components/tools/fence/fence-drafting.ts +80 -8
- package/src/components/tools/item/move-tool.tsx +17 -13
- package/src/components/tools/item/placement-math.test.ts +13 -1
- package/src/components/tools/item/placement-math.ts +28 -2
- package/src/components/tools/item/placement-strategies.ts +246 -5
- package/src/components/tools/item/placement-types.ts +13 -1
- package/src/components/tools/item/use-draft-node.ts +31 -1
- package/src/components/tools/item/use-placement-coordinator.tsx +761 -121
- package/src/components/tools/registry/move-registry-node-tool.tsx +561 -99
- package/src/components/tools/roof/roof-tool.tsx +320 -94
- package/src/components/tools/select/box-select-state.ts +18 -7
- package/src/components/tools/select/box-select-tool.tsx +176 -40
- package/src/components/tools/select/marquee-geometry.test.ts +161 -0
- package/src/components/tools/select/marquee-geometry.ts +155 -0
- package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
- package/src/components/tools/select/select-candidates.ts +1 -1
- package/src/components/tools/shared/cursor-sphere.tsx +62 -26
- package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
- package/src/components/tools/shared/facing-indicator.tsx +87 -0
- package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
- package/src/components/tools/shared/placement-box.tsx +183 -1
- package/src/components/tools/shared/polygon-editor.tsx +276 -35
- package/src/components/tools/site/site-boundary-editor.tsx +88 -36
- package/src/components/tools/stair/stair-defaults.ts +1 -0
- package/src/components/tools/stair/stair-tool.tsx +82 -11
- package/src/components/tools/tool-manager.tsx +86 -25
- package/src/components/tools/wall/wall-drafting.test.ts +330 -0
- package/src/components/tools/wall/wall-drafting.ts +180 -103
- package/src/components/tools/wall/wall-snap-geometry.test.ts +44 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +67 -6
- package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
- package/src/components/tools/zone/zone-tool.tsx +82 -88
- package/src/components/ui/action-menu/camera-actions.tsx +24 -17
- package/src/components/ui/action-menu/control-modes.tsx +40 -93
- package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
- package/src/components/ui/action-menu/index.tsx +2 -4
- package/src/components/ui/action-menu/measurement-control.tsx +188 -0
- package/src/components/ui/action-menu/structure-tools.tsx +22 -13
- package/src/components/ui/action-menu/view-toggles.tsx +42 -90
- package/src/components/ui/command-palette/editor-commands.tsx +2 -2
- package/src/components/ui/command-palette/index.tsx +4 -3
- package/src/components/ui/controls/material-paint-panel.tsx +86 -17
- package/src/components/ui/controls/material-picker.tsx +83 -152
- package/src/components/ui/controls/material-properties-editor.tsx +108 -0
- package/src/components/ui/controls/metric-control.tsx +118 -44
- package/src/components/ui/controls/scene-material-list.tsx +247 -0
- package/src/components/ui/controls/slider-control.tsx +88 -38
- package/src/components/ui/floating-level-selector.tsx +1 -1
- package/src/components/ui/helpers/building-helper.tsx +10 -23
- package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
- package/src/components/ui/helpers/helper-manager.tsx +227 -14
- package/src/components/ui/helpers/item-helper.tsx +28 -32
- package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
- package/src/components/ui/helpers/roof-helper.tsx +10 -12
- package/src/components/ui/icon-ref.tsx +47 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
- package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
- package/src/components/ui/level-duplicate-dialog.tsx +1 -1
- package/src/components/ui/panels/node-display.ts +17 -17
- package/src/components/ui/panels/panel-manager.tsx +22 -13
- package/src/components/ui/panels/panel-wrapper.tsx +24 -3
- package/src/components/ui/panels/parametric-inspector.tsx +15 -2
- package/src/components/ui/panels/reference-panel.tsx +54 -19
- package/src/components/ui/primitives/shortcut-token.tsx +39 -3
- package/src/components/ui/primitives/sidebar.tsx +1 -0
- package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
- package/src/components/ui/sidebar/icon-rail.tsx +50 -31
- package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +71 -40
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +60 -6
- package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
- package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +40 -17
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
- package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
- package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
- package/src/components/ui/sidebar/tab-bar.tsx +42 -28
- package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
- package/src/components/ui/snap-target-badge.test.tsx +40 -0
- package/src/components/ui/snap-target-badge.tsx +88 -0
- package/src/components/viewer-overlay.tsx +85 -10
- package/src/components/viewer-zone-system.tsx +6 -1
- package/src/hooks/use-auto-save.test.ts +14 -0
- package/src/hooks/use-auto-save.ts +54 -7
- package/src/hooks/use-ceiling-events.ts +3 -2
- package/src/hooks/use-drag-action.ts +4 -1
- package/src/hooks/use-keyboard.ts +385 -40
- package/src/index.tsx +219 -6
- package/src/lib/active-placement-surface.ts +35 -0
- package/src/lib/ceiling-plan-snap.ts +24 -0
- package/src/lib/contextual-help.test.ts +112 -0
- package/src/lib/contextual-help.ts +144 -0
- package/src/lib/continuation.ts +64 -0
- package/src/lib/direct-manipulation.test.ts +192 -0
- package/src/lib/direct-manipulation.ts +109 -0
- package/src/lib/editor-api.ts +23 -35
- package/src/lib/floorplan/apply-alignment.test.ts +25 -0
- package/src/lib/floorplan/apply-alignment.ts +18 -12
- package/src/lib/floorplan/floorplan-export.tsx +364 -0
- package/src/lib/floorplan/geometry.ts +53 -0
- package/src/lib/floorplan/index.ts +3 -0
- package/src/lib/floorplan/items.ts +5 -2
- package/src/lib/floorplan/plan-coords.ts +21 -0
- package/src/lib/fresh-planar-placement.test.ts +240 -3
- package/src/lib/fresh-planar-placement.ts +68 -1
- package/src/lib/glb-export.test.ts +441 -0
- package/src/lib/glb-export.ts +874 -0
- package/src/lib/history.ts +9 -0
- package/src/lib/interaction/hot-set.test.ts +133 -0
- package/src/lib/interaction/hot-set.ts +67 -0
- package/src/lib/interaction/overlay-policy.test.ts +51 -0
- package/src/lib/interaction/overlay-policy.ts +59 -0
- package/src/lib/interaction/scope.ts +211 -0
- package/src/lib/level-selection.ts +2 -2
- package/src/lib/material-paint.ts +36 -49
- package/src/lib/measurement-kind.test.ts +30 -0
- package/src/lib/measurement-kind.ts +19 -0
- package/src/lib/measurement-label.test.ts +47 -0
- package/src/lib/measurement-label.ts +68 -0
- package/src/lib/measurement-parser.ts +116 -0
- package/src/lib/measurements.test.ts +183 -0
- package/src/lib/measurements.ts +199 -0
- package/src/lib/paint-scope.test.ts +454 -0
- package/src/lib/paint-scope.ts +461 -0
- package/src/lib/placement-drag-release.ts +23 -0
- package/src/lib/planar-cursor-placement.test.ts +57 -0
- package/src/lib/plugin-panels.test.ts +19 -0
- package/src/lib/plugin-panels.ts +91 -0
- package/src/lib/quick-measurement.test.ts +77 -0
- package/src/lib/quick-measurement.ts +109 -0
- package/src/lib/roof-duplication.ts +6 -6
- package/src/lib/roof-hover-outline-proxy.ts +22 -0
- package/src/lib/roof-wall-hit.ts +164 -0
- package/src/lib/scene-clipboard.test.ts +196 -0
- package/src/lib/scene-clipboard.ts +73 -8
- package/src/lib/scene.ts +28 -5
- package/src/lib/selection-routing.test.ts +298 -0
- package/src/lib/selection-routing.ts +175 -0
- package/src/lib/sfx/index.ts +1 -0
- package/src/lib/sfx/movement-tick.test.ts +65 -0
- package/src/lib/sfx/movement-tick.ts +18 -0
- package/src/lib/sfx-bus.ts +50 -13
- package/src/lib/sfx-player.test.ts +124 -0
- package/src/lib/sfx-player.ts +111 -66
- package/src/lib/slab-plan-snap.test.ts +93 -0
- package/src/lib/slab-plan-snap.ts +108 -0
- package/src/lib/snapping-mode.test.ts +138 -0
- package/src/lib/snapping-mode.ts +179 -0
- package/src/lib/stair-duplication.ts +4 -4
- package/src/lib/surface-plan-snap.test.ts +71 -0
- package/src/lib/surface-plan-snap.ts +256 -0
- package/src/lib/use-linear-display.ts +40 -0
- package/src/lib/world-grid-snap.ts +37 -5
- package/src/store/use-direct-manipulation-feedback.ts +20 -0
- package/src/store/use-editor.tsx +484 -114
- package/src/store/use-facing-pose.ts +44 -0
- package/src/store/use-fence-curve-draft.ts +21 -0
- package/src/store/use-floorplan-draft-preview.ts +101 -0
- package/src/store/use-floorplan-marquee.ts +50 -0
- package/src/store/use-interaction-scope.test.ts +186 -0
- package/src/store/use-interaction-scope.ts +132 -0
- package/src/store/use-measurement-draft.test.ts +530 -0
- package/src/store/use-measurement-draft.ts +543 -0
- package/src/store/use-opening-guides.ts +41 -0
- package/src/store/use-placement-preview.ts +11 -3
- package/src/store/use-quick-measurement-hud.test.ts +53 -0
- package/src/store/use-quick-measurement-hud.ts +77 -0
- package/src/store/use-segment-draft-chain.ts +28 -0
- package/src/store/use-stair-build-preview.ts +43 -0
- package/src/store/use-wall-snap-indicator.ts +2 -0
- package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
- package/src/components/editor/group-move-handle.tsx +0 -316
- package/src/components/ui/panels/paint-panel.tsx +0 -163
- package/src/lib/level-name.ts +0 -11
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type AnyNode,
|
|
5
|
+
type AnyNodeId,
|
|
6
|
+
type FloorplanGeometry,
|
|
7
|
+
type LiveNodeOverrides,
|
|
8
|
+
nodeRegistry,
|
|
9
|
+
resolveBuildingForLevel,
|
|
10
|
+
useScene,
|
|
11
|
+
} from '@pascal-app/core'
|
|
12
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
13
|
+
import { createElement } from 'react'
|
|
14
|
+
import { flushSync } from 'react-dom'
|
|
15
|
+
import { createRoot } from 'react-dom/client'
|
|
16
|
+
import { FloorplanGeometryRenderer } from '../../components/editor-2d/renderers/floorplan-geometry-renderer'
|
|
17
|
+
import {
|
|
18
|
+
buildContext,
|
|
19
|
+
floorplanLayerRank,
|
|
20
|
+
getFloorplanLevelData,
|
|
21
|
+
isFloorplanNodeVisible,
|
|
22
|
+
splitFloorplanOverlay,
|
|
23
|
+
} from '../../components/editor-2d/renderers/floorplan-registry-layer'
|
|
24
|
+
import { FLOORPLAN_VIEW_ROTATION_DEG } from './geometry'
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Floorplan PDF export.
|
|
28
|
+
*
|
|
29
|
+
* Re-runs the same registry-driven geometry pipeline the live 2D layer uses
|
|
30
|
+
* (`def.floorplan(node, ctx)` → `FloorplanGeometryRenderer`) headlessly, with
|
|
31
|
+
* a neutral `viewState` so nodes render in their default, unselected form.
|
|
32
|
+
* Every level of the active building becomes its own page, titled with the
|
|
33
|
+
* level's label, with the plan fit to the page (independent of the live
|
|
34
|
+
* pan/zoom). jsPDF + svg2pdf are dynamically imported so they only load when
|
|
35
|
+
* an export actually runs.
|
|
36
|
+
*
|
|
37
|
+
* `scope: 'structure'` keeps only `category === 'structure'` nodes (walls,
|
|
38
|
+
* slabs, ceilings, doors, windows, stairs, columns, roofs…); `'full'` keeps
|
|
39
|
+
* every node that has a floorplan builder and is visible.
|
|
40
|
+
*/
|
|
41
|
+
export type FloorplanExportScope = 'full' | 'structure'
|
|
42
|
+
|
|
43
|
+
const SVG_NS = 'http://www.w3.org/2000/svg'
|
|
44
|
+
/** Meters of margin around the plan bounds. */
|
|
45
|
+
const PADDING_M = 1
|
|
46
|
+
/** PDF page margin + title band, in pt. */
|
|
47
|
+
const PAGE_MARGIN_PT = 36
|
|
48
|
+
const TITLE_BAND_PT = 28
|
|
49
|
+
|
|
50
|
+
// Neutral view state — no selection / hover / palette, so builders emit their
|
|
51
|
+
// default appearance (the core palette only carries selection/handle colors).
|
|
52
|
+
const NEUTRAL_VIEW_STATE = {
|
|
53
|
+
selected: false,
|
|
54
|
+
highlighted: false,
|
|
55
|
+
hovered: false,
|
|
56
|
+
moving: false,
|
|
57
|
+
palette: undefined,
|
|
58
|
+
} as const
|
|
59
|
+
|
|
60
|
+
type ExportLevel = { id: AnyNodeId; label: string }
|
|
61
|
+
|
|
62
|
+
export async function exportFloorplanPdf(scope: FloorplanExportScope): Promise<void> {
|
|
63
|
+
const nodes = useScene.getState().nodes
|
|
64
|
+
const unit = useViewer.getState().unit
|
|
65
|
+
const levels = resolveExportLevels(nodes)
|
|
66
|
+
if (levels.length === 0) {
|
|
67
|
+
console.warn('[floorplan-export] no level to export')
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const [{ jsPDF }, { svg2pdf }] = await Promise.all([import('jspdf'), import('svg2pdf.js')])
|
|
72
|
+
const doc = new jsPDF({ orientation: 'landscape', unit: 'pt', format: 'a4' })
|
|
73
|
+
const pageW = doc.internal.pageSize.getWidth()
|
|
74
|
+
const pageH = doc.internal.pageSize.getHeight()
|
|
75
|
+
|
|
76
|
+
const host = document.createElement('div')
|
|
77
|
+
host.style.cssText =
|
|
78
|
+
'position:fixed;left:-10000px;top:0;width:1px;height:1px;overflow:hidden;pointer-events:none;'
|
|
79
|
+
document.body.appendChild(host)
|
|
80
|
+
|
|
81
|
+
let pageCount = 0
|
|
82
|
+
try {
|
|
83
|
+
for (const level of levels) {
|
|
84
|
+
const geometries = collectFloorplanGeometry(nodes, level.id, scope, unit)
|
|
85
|
+
if (geometries.length === 0) continue
|
|
86
|
+
|
|
87
|
+
// Rotate the exported plan to the same north-up orientation the on-screen
|
|
88
|
+
// 2D view uses when aligned to north (user rotation offset = 0), so a PDF
|
|
89
|
+
// points north instead of drawing raw plan-local axes.
|
|
90
|
+
const buildingId = resolveBuildingForLevel(level.id, nodes as Record<AnyNodeId, AnyNode>)
|
|
91
|
+
const building = buildingId ? nodes[buildingId] : undefined
|
|
92
|
+
const buildingRotationY = building?.type === 'building' ? (building.rotation[1] ?? 0) : 0
|
|
93
|
+
const rotationDeg = FLOORPLAN_VIEW_ROTATION_DEG - (buildingRotationY * 180) / Math.PI
|
|
94
|
+
|
|
95
|
+
const mounted = await mountFloorplanSvg(host, geometries, rotationDeg)
|
|
96
|
+
if (!mounted) continue
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
if (pageCount > 0) doc.addPage()
|
|
100
|
+
pageCount++
|
|
101
|
+
|
|
102
|
+
doc.setFontSize(14)
|
|
103
|
+
doc.text(level.label, PAGE_MARGIN_PT, PAGE_MARGIN_PT + 12)
|
|
104
|
+
|
|
105
|
+
// Fit the plan into the page below the title band, preserving aspect.
|
|
106
|
+
const boxX = PAGE_MARGIN_PT
|
|
107
|
+
const boxY = PAGE_MARGIN_PT + TITLE_BAND_PT
|
|
108
|
+
const boxW = pageW - PAGE_MARGIN_PT * 2
|
|
109
|
+
const boxH = pageH - PAGE_MARGIN_PT * 2 - TITLE_BAND_PT
|
|
110
|
+
const aspect = mounted.width / mounted.height
|
|
111
|
+
let w = boxW
|
|
112
|
+
let h = w / aspect
|
|
113
|
+
if (h > boxH) {
|
|
114
|
+
h = boxH
|
|
115
|
+
w = h * aspect
|
|
116
|
+
}
|
|
117
|
+
const x = boxX + (boxW - w) / 2
|
|
118
|
+
const y = boxY + (boxH - h) / 2
|
|
119
|
+
|
|
120
|
+
// svg2pdf doesn't honour `vector-effect: non-scaling-stroke` (which
|
|
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()
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (pageCount === 0) {
|
|
135
|
+
console.warn(`[floorplan-export] nothing to export for scope "${scope}"`)
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const date = new Date().toISOString().split('T')[0]
|
|
140
|
+
doc.save(`floorplan_${scope}_${date}.pdf`)
|
|
141
|
+
} finally {
|
|
142
|
+
host.remove()
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
type MountedFloorplan = {
|
|
147
|
+
svg: SVGSVGElement
|
|
148
|
+
/** Padded viewBox dimensions, in meters — used for aspect-preserving fit. */
|
|
149
|
+
width: number
|
|
150
|
+
height: number
|
|
151
|
+
cleanup: () => void
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function mountFloorplanSvg(
|
|
155
|
+
parent: HTMLElement,
|
|
156
|
+
geometries: { id: AnyNodeId; base: FloorplanGeometry }[],
|
|
157
|
+
rotationDeg: number,
|
|
158
|
+
): Promise<MountedFloorplan | null> {
|
|
159
|
+
const container = document.createElement('div')
|
|
160
|
+
parent.appendChild(container)
|
|
161
|
+
const root = createRoot(container)
|
|
162
|
+
const cleanup = () => {
|
|
163
|
+
root.unmount()
|
|
164
|
+
container.remove()
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Render a full `<svg>` as the React root child so React enters the SVG
|
|
168
|
+
// namespace at the `<svg>` tag, then mutate the DOM node afterwards —
|
|
169
|
+
// viewBox/background depend on the post-mount measured bounds.
|
|
170
|
+
flushSync(() => {
|
|
171
|
+
root.render(
|
|
172
|
+
createElement(
|
|
173
|
+
'svg',
|
|
174
|
+
{ xmlns: SVG_NS },
|
|
175
|
+
createElement(
|
|
176
|
+
'g',
|
|
177
|
+
{ 'data-floorplan-content': '' },
|
|
178
|
+
createElement(
|
|
179
|
+
'g',
|
|
180
|
+
{ transform: `rotate(${rotationDeg})` },
|
|
181
|
+
geometries.map(({ id, base }) =>
|
|
182
|
+
createElement(FloorplanGeometryRenderer, { key: id, geometry: base }),
|
|
183
|
+
),
|
|
184
|
+
),
|
|
185
|
+
),
|
|
186
|
+
),
|
|
187
|
+
)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
// Give async asset images (item icons) a couple of frames to resolve so
|
|
191
|
+
// they're included in the measured bounds and the rendered output.
|
|
192
|
+
await nextFrames(2)
|
|
193
|
+
|
|
194
|
+
const svg = container.querySelector('svg')
|
|
195
|
+
const content = svg?.querySelector('[data-floorplan-content]') as SVGGraphicsElement | null
|
|
196
|
+
const bbox = content?.getBBox()
|
|
197
|
+
if (!svg || !bbox || bbox.width === 0 || bbox.height === 0) {
|
|
198
|
+
cleanup()
|
|
199
|
+
return null
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const minX = bbox.x - PADDING_M
|
|
203
|
+
const minY = bbox.y - PADDING_M
|
|
204
|
+
const width = bbox.width + PADDING_M * 2
|
|
205
|
+
const height = bbox.height + PADDING_M * 2
|
|
206
|
+
svg.setAttribute('viewBox', `${minX} ${minY} ${width} ${height}`)
|
|
207
|
+
svg.setAttribute('width', `${width}`)
|
|
208
|
+
svg.setAttribute('height', `${height}`)
|
|
209
|
+
|
|
210
|
+
const background = document.createElementNS(SVG_NS, 'rect')
|
|
211
|
+
background.setAttribute('x', `${minX}`)
|
|
212
|
+
background.setAttribute('y', `${minY}`)
|
|
213
|
+
background.setAttribute('width', `${width}`)
|
|
214
|
+
background.setAttribute('height', `${height}`)
|
|
215
|
+
background.setAttribute('fill', '#ffffff')
|
|
216
|
+
svg.insertBefore(background, svg.firstChild)
|
|
217
|
+
|
|
218
|
+
return { svg, width, height, cleanup }
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Bake `vector-effect: non-scaling-stroke` widths into real user units.
|
|
223
|
+
*
|
|
224
|
+
* svg2pdf ignores the non-scaling hint, so a `stroke-width="1.25"` meant as
|
|
225
|
+
* "1.25 screen px" would otherwise render as 1.25 metres on the page. We
|
|
226
|
+
* rewrite each such width (and any dash pattern) to `px / ptPerUnit` so it
|
|
227
|
+
* lands at ~`px` points once svg2pdf scales the plan by `ptPerUnit`, then drop
|
|
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')
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function collectFloorplanGeometry(
|
|
254
|
+
nodes: Record<string, AnyNode>,
|
|
255
|
+
levelId: AnyNodeId,
|
|
256
|
+
scope: FloorplanExportScope,
|
|
257
|
+
unit: 'metric' | 'imperial',
|
|
258
|
+
): { id: AnyNodeId; base: FloorplanGeometry }[] {
|
|
259
|
+
const noLiveOverrides = new Map<string, LiveNodeOverrides>()
|
|
260
|
+
const levelNodeIdsByType = new Map<string, AnyNodeId[]>()
|
|
261
|
+
const entries: { id: AnyNodeId; node: AnyNode }[] = []
|
|
262
|
+
|
|
263
|
+
const visit = (id: AnyNodeId) => {
|
|
264
|
+
const node = nodes[id]
|
|
265
|
+
if (!node) return
|
|
266
|
+
const def = nodeRegistry.get(node.type)
|
|
267
|
+
if (def?.computeFloorplanLevelData) {
|
|
268
|
+
const ids = levelNodeIdsByType.get(node.type)
|
|
269
|
+
if (ids) ids.push(id)
|
|
270
|
+
else levelNodeIdsByType.set(node.type, [id])
|
|
271
|
+
}
|
|
272
|
+
if (
|
|
273
|
+
def?.floorplan &&
|
|
274
|
+
isFloorplanNodeVisible(node) &&
|
|
275
|
+
(scope === 'full' || def.category === 'structure')
|
|
276
|
+
) {
|
|
277
|
+
entries.push({ id, node })
|
|
278
|
+
}
|
|
279
|
+
const childIds = (node as { children?: AnyNodeId[] }).children
|
|
280
|
+
if (Array.isArray(childIds)) for (const cid of childIds) visit(cid)
|
|
281
|
+
}
|
|
282
|
+
visit(levelId)
|
|
283
|
+
|
|
284
|
+
// Document order is paint order — sort the same way the live layer does so
|
|
285
|
+
// zones sit under walls/slabs/furniture rather than on top of them.
|
|
286
|
+
entries.sort((a, b) => floorplanLayerRank(a.node.type) - floorplanLayerRank(b.node.type))
|
|
287
|
+
|
|
288
|
+
// One-shot per-type cache for `computeFloorplanLevelData`; value type is
|
|
289
|
+
// module-private to the registry layer, so let it infer.
|
|
290
|
+
const levelDataCache = new Map()
|
|
291
|
+
const out: { id: AnyNodeId; base: FloorplanGeometry }[] = []
|
|
292
|
+
for (const { id, node } of entries) {
|
|
293
|
+
const builder = nodeRegistry.get(node.type)?.floorplan
|
|
294
|
+
if (!builder) continue
|
|
295
|
+
const levelData = getFloorplanLevelData(
|
|
296
|
+
node.type,
|
|
297
|
+
nodes,
|
|
298
|
+
noLiveOverrides,
|
|
299
|
+
levelNodeIdsByType,
|
|
300
|
+
levelDataCache,
|
|
301
|
+
)
|
|
302
|
+
const ctx = buildContext(node, nodes, { ...NEUTRAL_VIEW_STATE, unit }, levelData)
|
|
303
|
+
const geometry = builder(node, ctx)
|
|
304
|
+
if (!geometry) continue
|
|
305
|
+
const { base } = splitFloorplanOverlay(geometry)
|
|
306
|
+
if (base) out.push({ id, base })
|
|
307
|
+
}
|
|
308
|
+
return out
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Levels to export, ordered bottom-to-top. The active building (the building
|
|
313
|
+
* owning the selected level, or the first one found) contributes all of its
|
|
314
|
+
* level children; if there is no building wrapper we fall back to the single
|
|
315
|
+
* resolved level.
|
|
316
|
+
*/
|
|
317
|
+
function resolveExportLevels(nodes: Record<string, AnyNode>): ExportLevel[] {
|
|
318
|
+
const selected = useViewer.getState().selection.levelId as AnyNodeId | null | undefined
|
|
319
|
+
const activeLevelId = selected && nodes[selected] ? selected : firstLevelId(nodes)
|
|
320
|
+
if (!activeLevelId) return []
|
|
321
|
+
|
|
322
|
+
const buildingId = resolveBuildingForLevel(activeLevelId, nodes as Record<AnyNodeId, AnyNode>)
|
|
323
|
+
let levelNodes: AnyNode[]
|
|
324
|
+
if (buildingId) {
|
|
325
|
+
const childIds = (nodes[buildingId] as { children?: AnyNodeId[] }).children ?? []
|
|
326
|
+
levelNodes = childIds.map((id) => nodes[id]).filter((n): n is AnyNode => n?.type === 'level')
|
|
327
|
+
} else {
|
|
328
|
+
const node = nodes[activeLevelId]
|
|
329
|
+
levelNodes = node ? [node] : []
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
levelNodes.sort((a, b) => levelIndexOf(a) - levelIndexOf(b))
|
|
333
|
+
return levelNodes.map((n) => ({ id: n.id as AnyNodeId, label: levelLabelOf(n) }))
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function firstLevelId(nodes: Record<string, AnyNode>): AnyNodeId | null {
|
|
337
|
+
for (const node of Object.values(nodes)) {
|
|
338
|
+
if (node.type === 'level') return node.id as AnyNodeId
|
|
339
|
+
}
|
|
340
|
+
return null
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function levelIndexOf(node: AnyNode): number {
|
|
344
|
+
return (node as { level?: number }).level ?? 0
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function levelLabelOf(node: AnyNode): string {
|
|
348
|
+
const name = node.name?.trim()
|
|
349
|
+
if (name) return name
|
|
350
|
+
return `Level ${levelIndexOf(node)}`
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function nextFrames(count: number): Promise<void> {
|
|
354
|
+
return new Promise((resolve) => {
|
|
355
|
+
const tick = (remaining: number) => {
|
|
356
|
+
if (remaining <= 0) {
|
|
357
|
+
resolve()
|
|
358
|
+
return
|
|
359
|
+
}
|
|
360
|
+
requestAnimationFrame(() => tick(remaining - 1))
|
|
361
|
+
}
|
|
362
|
+
tick(count)
|
|
363
|
+
})
|
|
364
|
+
}
|
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
import type { Point2D } from '@pascal-app/core'
|
|
2
2
|
import type { FloorplanLineSegment, FloorplanSelectionBounds } from './types'
|
|
3
3
|
|
|
4
|
+
// Baseline rotation (deg) that orients the plan-local scene "north up" on
|
|
5
|
+
// screen. The on-screen floor-plan scene `<g>` is rotated by
|
|
6
|
+
// `FLOORPLAN_VIEW_ROTATION_DEG + userRotation - buildingRotation`; the PDF
|
|
7
|
+
// export mirrors the aligned-to-north case (user offset 0) so an export points
|
|
8
|
+
// the same way as the app's north-aligned view.
|
|
9
|
+
//
|
|
10
|
+
// North is world −Z: with a 0 baseline a rotation-0 reference image (top =
|
|
11
|
+
// −Z) reads upright in the north-aligned view, and "align north" maps to a
|
|
12
|
+
// 3D camera azimuth of 0. The old 90° baseline made north world −X, so
|
|
13
|
+
// north-up floorplan scans displayed sideways whenever the compass claimed
|
|
14
|
+
// the view was aligned.
|
|
15
|
+
export const FLOORPLAN_VIEW_ROTATION_DEG = 0
|
|
16
|
+
|
|
4
17
|
export function clampPlanValue(value: number, min: number, max: number) {
|
|
5
18
|
return Math.min(Math.max(value, min), max)
|
|
6
19
|
}
|
|
@@ -11,6 +24,46 @@ export function rotatePlanVector(x: number, y: number, rotation: number): [numbe
|
|
|
11
24
|
return [x * cos + y * sin, -x * sin + y * cos]
|
|
12
25
|
}
|
|
13
26
|
|
|
27
|
+
// Converts a world X/Z point into the floor-plan-local (building-local)
|
|
28
|
+
// frame used by the SVG scene `<g>` and every stored node position. The
|
|
29
|
+
// inverse of `floorplanLocalToWorldPoint`. Shared so the floor-plan panel
|
|
30
|
+
// and the 2D move overlay resolve the same frame — feeding a world-space
|
|
31
|
+
// `original` into a local-space cursor solver lands the drop off by the
|
|
32
|
+
// building's world X/Z (worse for an off-origin building).
|
|
33
|
+
export function worldToFloorplanLocalPoint(
|
|
34
|
+
worldX: number,
|
|
35
|
+
worldZ: number,
|
|
36
|
+
buildingPosition: readonly [number, number, number],
|
|
37
|
+
buildingRotationY: number,
|
|
38
|
+
): Point2D {
|
|
39
|
+
const dx = worldX - buildingPosition[0]
|
|
40
|
+
const dz = worldZ - buildingPosition[2]
|
|
41
|
+
const cos = Math.cos(buildingRotationY)
|
|
42
|
+
const sin = Math.sin(buildingRotationY)
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
x: dx * cos - dz * sin,
|
|
46
|
+
y: dx * sin + dz * cos,
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Inverse of `worldToFloorplanLocalPoint`: floor-plan-local X/Y → world X/Z.
|
|
51
|
+
export function floorplanLocalToWorldPoint(
|
|
52
|
+
point: Point2D | [number, number],
|
|
53
|
+
buildingPosition: readonly [number, number, number],
|
|
54
|
+
buildingRotationY: number,
|
|
55
|
+
): { x: number; z: number } {
|
|
56
|
+
const localX = Array.isArray(point) ? point[0] : point.x
|
|
57
|
+
const localY = Array.isArray(point) ? point[1] : point.y
|
|
58
|
+
const cos = Math.cos(buildingRotationY)
|
|
59
|
+
const sin = Math.sin(buildingRotationY)
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
x: buildingPosition[0] + localX * cos + localY * sin,
|
|
63
|
+
z: buildingPosition[2] - localX * sin + localY * cos,
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
14
67
|
export function getRotatedRectanglePolygon(
|
|
15
68
|
center: Point2D,
|
|
16
69
|
width: number,
|
|
@@ -8,6 +8,8 @@ export {
|
|
|
8
8
|
export {
|
|
9
9
|
clampPlanValue,
|
|
10
10
|
doesPolygonIntersectSelectionBounds,
|
|
11
|
+
FLOORPLAN_VIEW_ROTATION_DEG,
|
|
12
|
+
floorplanLocalToWorldPoint,
|
|
11
13
|
getDistanceToWallSegment,
|
|
12
14
|
getFloorplanSelectionBounds,
|
|
13
15
|
getPlanPointDistance,
|
|
@@ -20,6 +22,7 @@ export {
|
|
|
20
22
|
movePlanPointTowards,
|
|
21
23
|
pointMatchesWallPlanPoint,
|
|
22
24
|
rotatePlanVector,
|
|
25
|
+
worldToFloorplanLocalPoint,
|
|
23
26
|
} from './geometry'
|
|
24
27
|
export {
|
|
25
28
|
buildFloorplanItemEntry,
|
|
@@ -82,7 +82,7 @@ export function getItemFloorplanTransform(
|
|
|
82
82
|
)
|
|
83
83
|
const wallLocalZ =
|
|
84
84
|
item.asset.attachTo === 'wall-side'
|
|
85
|
-
? ((parentNode.thickness ?? 0.1) / 2) * (item.side === '
|
|
85
|
+
? ((parentNode.thickness ?? 0.1) / 2) * (item.side === 'front' ? 1 : -1)
|
|
86
86
|
: item.position[2]
|
|
87
87
|
const [offsetX, offsetY] = rotatePlanVector(item.position[0], wallLocalZ, wallRotation)
|
|
88
88
|
|
|
@@ -164,7 +164,10 @@ type Point = {
|
|
|
164
164
|
|
|
165
165
|
function getItemDimensionPolygon(item: ItemNode, transform: FloorplanNodeTransform): Point[] {
|
|
166
166
|
const [width, , depth] = getScaledDimensions(item)
|
|
167
|
-
|
|
167
|
+
// Wall-side items extend depth-ward away from the wall (into the room); push
|
|
168
|
+
// the footprint centre a half-depth out along local +Z. A negative offset
|
|
169
|
+
// would lay the box across the wall onto the far side (mirrored from 3D).
|
|
170
|
+
const centerLocalZ = item.asset.attachTo === 'wall-side' ? depth / 2 : 0
|
|
168
171
|
const [offsetX, offsetY] = rotatePlanVector(0, centerLocalZ, transform.rotation)
|
|
169
172
|
|
|
170
173
|
return getRotatedRectanglePolygon(
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { FloorplanAffordancePoint } from '@pascal-app/core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Convert client (screen) coordinates into floor-plan plan coordinates via
|
|
5
|
+
* the mounted floor-plan scene `<g>`'s screen CTM. The scene `<g>` maps plan
|
|
6
|
+
* X/Z directly to SVG x/y (Z stored as the Y axis on screen — same convention
|
|
7
|
+
* as `toSvgPlanPoint`), so the returned point is in the same level-frame
|
|
8
|
+
* meters node placements use. Null when no floor plan is mounted.
|
|
9
|
+
*/
|
|
10
|
+
export function clientToPlan(clientX: number, clientY: number): FloorplanAffordancePoint | null {
|
|
11
|
+
const target = document.querySelector('g[data-floorplan-scene]') as SVGGElement | null
|
|
12
|
+
const svg = target?.ownerSVGElement
|
|
13
|
+
if (!(svg && target)) return null
|
|
14
|
+
const ctm = target.getScreenCTM()
|
|
15
|
+
if (!ctm) return null
|
|
16
|
+
const point = svg.createSVGPoint()
|
|
17
|
+
point.x = clientX
|
|
18
|
+
point.y = clientY
|
|
19
|
+
const transformed = point.matrixTransform(ctm.inverse())
|
|
20
|
+
return [transformed.x, transformed.y]
|
|
21
|
+
}
|