@pascal-app/editor 0.8.0 → 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 +15 -13
- package/src/components/editor/alignment-3d-guide-layer.tsx +168 -0
- package/src/components/editor/bake-exporter.tsx +47 -0
- package/src/components/editor/custom-camera-controls.tsx +795 -28
- package/src/components/editor/editor-layout-mobile.tsx +8 -3
- package/src/components/editor/editor-layout-v2.tsx +76 -51
- package/src/components/editor/export-manager.tsx +69 -32
- package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
- package/src/components/editor/first-person/build-collider-world.test.ts +182 -0
- package/src/components/editor/first-person/build-collider-world.ts +228 -83
- package/src/components/editor/first-person-controls.tsx +943 -27
- package/src/components/editor/floating-action-menu.tsx +676 -208
- package/src/components/editor/floating-building-action-menu.tsx +2 -2
- 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 +6916 -12137
- package/src/components/editor/grid.tsx +197 -36
- 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 +420 -0
- package/src/components/editor/group-selection-box-3d.tsx +173 -0
- package/src/components/editor/group-transform-shared.test.ts +396 -0
- package/src/components/editor/group-transform-shared.ts +489 -0
- package/src/components/editor/handles/handle-arrow.tsx +581 -0
- package/src/components/editor/handles/use-handle-drag.ts +246 -0
- package/src/components/editor/index.tsx +412 -162
- package/src/components/editor/measurement-pill.tsx +115 -0
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/node-arrow-handles.tsx +1646 -0
- 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 +1106 -483
- package/src/components/editor/site-edge-labels.tsx +45 -21
- package/src/components/editor/slab-hole-highlights.tsx +474 -0
- package/src/components/editor/snapshot-capture-overlay.tsx +369 -155
- package/src/components/editor/three-context-bridge.ts +22 -0
- package/src/components/editor/thumbnail-generator.tsx +140 -41
- package/src/components/editor/use-floorplan-background-placement.ts +190 -35
- package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
- package/src/components/editor/use-floorplan-scene-data.ts +19 -7
- package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
- package/src/components/editor/wall-measurement-label.tsx +14 -19
- package/src/components/editor/wall-move-side-handles.tsx +870 -0
- package/src/components/editor/wall-opening-highlights.tsx +181 -0
- package/src/components/editor/wall-snap-beacon-layer.tsx +304 -0
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +15 -6
- package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
- 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 +392 -0
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +857 -0
- package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
- package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +77 -0
- package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
- 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-measurements-layer.tsx +108 -66
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +62 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +3325 -0
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +24 -9
- package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
- package/src/components/editor-2d/svg-paths.ts +10 -2
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +439 -57
- package/src/components/systems/ceiling/ceiling-system.tsx +109 -12
- package/src/components/systems/roof/roof-edit-system.tsx +1851 -22
- package/src/components/systems/selection-affordance-manager.tsx +38 -0
- package/src/components/systems/stair/stair-edit-system.tsx +1 -1
- 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-defaults.ts +8 -0
- package/src/components/tools/elevator/elevator-tool.tsx +375 -0
- package/src/components/tools/elevator/move-elevator-tool.tsx +263 -0
- package/src/components/tools/fence/fence-drafting.ts +141 -27
- package/src/components/tools/item/move-tool.tsx +45 -105
- package/src/components/tools/item/placement-math.test.ts +22 -0
- package/src/components/tools/item/placement-math.ts +59 -7
- package/src/components/tools/item/placement-strategies.ts +432 -16
- package/src/components/tools/item/placement-types.ts +22 -1
- package/src/components/tools/item/use-draft-node.ts +51 -2
- package/src/components/tools/item/use-placement-coordinator.tsx +1399 -256
- package/src/components/tools/registry/move-registry-node-tool.tsx +1064 -0
- package/src/components/tools/roof/roof-tool.tsx +350 -28
- package/src/components/tools/select/box-select-state.ts +88 -0
- package/src/components/tools/select/box-select-tool.tsx +442 -520
- 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 +570 -0
- package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
- package/src/components/tools/select/select-candidates.test.ts +109 -0
- package/src/components/tools/select/select-candidates.ts +85 -0
- package/src/components/tools/shared/affordance-dispatch.ts +30 -0
- package/src/components/tools/shared/cursor-sphere.tsx +62 -26
- package/src/components/tools/shared/drag-bounding-box.tsx +195 -0
- 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/floor-stack-preview.ts +25 -0
- package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
- package/src/components/tools/shared/placement-box-geometry.ts +123 -0
- package/src/components/tools/shared/placement-box.tsx +305 -0
- package/src/components/tools/shared/polygon-editor.tsx +866 -175
- package/src/components/tools/shared/segment-angle.ts +56 -0
- package/src/components/tools/site/site-boundary-editor.tsx +520 -16
- package/src/components/tools/site/site-flag-model.ts +18 -0
- package/src/components/tools/stair/stair-defaults.ts +2 -1
- package/src/components/tools/stair/stair-tool.tsx +342 -37
- package/src/components/tools/tool-manager.tsx +239 -62
- package/src/components/tools/wall/wall-drafting.test.ts +330 -0
- package/src/components/tools/wall/wall-drafting.ts +248 -171
- package/src/components/tools/wall/wall-snap-geometry.test.ts +123 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +331 -0
- package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
- package/src/components/tools/zone/zone-tool.tsx +86 -82
- package/src/components/ui/action-menu/action-button.tsx +21 -1
- package/src/components/ui/action-menu/camera-actions.tsx +24 -17
- package/src/components/ui/action-menu/control-modes.tsx +40 -170
- package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
- package/src/components/ui/action-menu/index.tsx +3 -117
- package/src/components/ui/action-menu/measurement-control.tsx +188 -0
- package/src/components/ui/action-menu/structure-tools.tsx +27 -93
- package/src/components/ui/action-menu/view-toggles.tsx +279 -90
- package/src/components/ui/command-palette/editor-commands.tsx +20 -15
- package/src/components/ui/command-palette/index.tsx +5 -3
- package/src/components/ui/controls/material-paint-panel.tsx +152 -0
- package/src/components/ui/controls/material-picker.tsx +87 -177
- package/src/components/ui/controls/material-properties-editor.tsx +108 -0
- package/src/components/ui/controls/metric-control.tsx +142 -46
- 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 +47 -10
- 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 +235 -23
- package/src/components/ui/helpers/item-helper.tsx +28 -32
- package/src/components/ui/helpers/registered-tool-helper.tsx +59 -0
- 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 +40 -1
- package/src/components/ui/item-catalog/item-catalog.tsx +16 -36
- package/src/components/ui/level-duplicate-dialog.tsx +2 -1
- package/src/components/ui/panels/node-display.ts +17 -16
- package/src/components/ui/panels/panel-manager.tsx +55 -58
- package/src/components/ui/panels/panel-wrapper.tsx +238 -12
- package/src/components/ui/panels/parametric-inspector.tsx +482 -0
- package/src/components/ui/panels/reference-panel.tsx +54 -19
- package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
- package/src/components/ui/primitives/shortcut-token.tsx +39 -3
- package/src/components/ui/primitives/sidebar.tsx +1 -0
- package/src/components/ui/scene-loader.tsx +1 -3
- 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/items-panel/function-tree-panel.tsx +263 -0
- package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
- package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +122 -41
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +66 -7
- package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +267 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +9 -5
- package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +86 -0
- 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 +92 -0
- package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +3 -3
- package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +86 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +42 -21
- 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 +2 -1
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +140 -0
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +48 -8
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +132 -0
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +9 -5
- package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +86 -0
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +7 -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 +112 -39
- 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/panels/zone-panel/index.tsx +51 -2
- package/src/components/ui/sidebar/tab-bar.tsx +75 -1
- 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 +253 -61
- package/src/components/viewer-zone-system.tsx +7 -2
- package/src/hooks/use-auto-save.test.ts +14 -0
- package/src/hooks/use-auto-save.ts +64 -10
- package/src/hooks/use-ceiling-events.ts +176 -0
- package/src/hooks/use-drag-action.ts +127 -0
- package/src/hooks/use-keyboard.ts +493 -95
- package/src/hooks/use-selection.ts +64 -0
- package/src/index.tsx +451 -6
- package/src/lib/active-placement-surface.ts +35 -0
- package/src/lib/ceiling-plan-snap.ts +24 -0
- package/src/lib/constants.ts +6 -2
- 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 +50 -0
- package/src/lib/elevator-support.ts +96 -0
- package/src/lib/floorplan/apply-alignment.test.ts +25 -0
- package/src/lib/floorplan/apply-alignment.ts +117 -0
- package/src/lib/floorplan/floorplan-export.tsx +364 -0
- package/src/lib/floorplan/geometry.ts +53 -0
- package/src/lib/floorplan/index.ts +10 -0
- package/src/lib/floorplan/items.ts +5 -2
- package/src/lib/floorplan/plan-coords.ts +21 -0
- package/src/lib/floorplan/selection-tool.ts +40 -0
- package/src/lib/floorplan/stairs.ts +12 -1
- package/src/lib/fresh-planar-placement.test.ts +339 -0
- package/src/lib/fresh-planar-placement.ts +130 -0
- package/src/lib/glb-export.test.ts +441 -0
- package/src/lib/glb-export.ts +874 -0
- package/src/lib/history.ts +11 -1
- 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-duplication.ts +54 -42
- package/src/lib/level-selection.ts +2 -2
- package/src/lib/material-paint.ts +227 -48
- 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/placement-metadata.ts +29 -0
- package/src/lib/planar-cursor-placement.test.ts +100 -0
- package/src/lib/planar-cursor-placement.ts +42 -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 +7 -7
- 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 +332 -0
- package/src/lib/scene.ts +40 -8
- 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 +69 -10
- package/src/lib/sfx-player.test.ts +124 -0
- package/src/lib/sfx-player.ts +148 -55
- package/src/lib/site-boundary.ts +1 -0
- 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/stair-levels.test.ts +146 -0
- package/src/lib/stair-levels.ts +177 -0
- 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 +231 -0
- package/src/lib/zone-content.ts +130 -0
- package/src/store/use-alignment-guides.ts +21 -0
- package/src/store/use-direct-manipulation-feedback.ts +20 -0
- package/src/store/use-editor.tsx +767 -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 +39 -0
- 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-move-ghosts.ts +36 -0
- package/src/store/use-wall-snap-indicator.ts +34 -0
- package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
- package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
- package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
- package/src/components/tools/building/move-building-tool.tsx +0 -157
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
- package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -264
- package/src/components/tools/column/column-tool.tsx +0 -97
- package/src/components/tools/column/move-column-tool.tsx +0 -105
- package/src/components/tools/door/door-math.ts +0 -110
- package/src/components/tools/door/door-tool.tsx +0 -324
- package/src/components/tools/door/move-door-tool.tsx +0 -412
- package/src/components/tools/fence/curve-fence-tool.tsx +0 -178
- package/src/components/tools/fence/fence-tool.tsx +0 -346
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
- package/src/components/tools/fence/move-fence-tool.tsx +0 -302
- package/src/components/tools/item/item-tool.tsx +0 -26
- package/src/components/tools/roof/move-roof-tool.tsx +0 -364
- package/src/components/tools/slab/move-slab-tool.tsx +0 -182
- package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
- package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
- package/src/components/tools/slab/slab-tool.tsx +0 -322
- package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
- package/src/components/tools/spawn/spawn-tool.tsx +0 -130
- package/src/components/tools/wall/curve-wall-tool.tsx +0 -178
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
- package/src/components/tools/wall/move-wall-tool.tsx +0 -358
- package/src/components/tools/wall/wall-tool.tsx +0 -332
- package/src/components/tools/window/move-window-tool.tsx +0 -447
- package/src/components/tools/window/window-math.ts +0 -117
- package/src/components/tools/window/window-tool.tsx +0 -332
- package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
- package/src/components/ui/helpers/slab-helper.tsx +0 -20
- package/src/components/ui/helpers/wall-helper.tsx +0 -20
- package/src/components/ui/panels/ceiling-panel.tsx +0 -249
- package/src/components/ui/panels/column-panel.tsx +0 -759
- package/src/components/ui/panels/door-panel.tsx +0 -1299
- package/src/components/ui/panels/fence-panel.tsx +0 -222
- package/src/components/ui/panels/item-panel.tsx +0 -306
- package/src/components/ui/panels/paint-panel.tsx +0 -163
- package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
- package/src/components/ui/panels/roof-panel.tsx +0 -280
- package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
- package/src/components/ui/panels/slab-panel.tsx +0 -250
- package/src/components/ui/panels/spawn-panel.tsx +0 -161
- package/src/components/ui/panels/stair-panel.tsx +0 -577
- package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
- package/src/components/ui/panels/wall-panel.tsx +0 -273
- package/src/components/ui/panels/window-panel.tsx +0 -968
- package/src/contexts/presets-context.tsx +0 -121
- package/src/hooks/use-contextual-tools.ts +0 -61
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AnyNode,
|
|
3
|
+
type AnyNodeId,
|
|
4
|
+
type BuildingNode,
|
|
5
|
+
type ElevatorNode,
|
|
6
|
+
type LevelNode,
|
|
7
|
+
spatialGridManager,
|
|
8
|
+
useScene,
|
|
9
|
+
} from '@pascal-app/core'
|
|
10
|
+
|
|
11
|
+
function getBuildingLevels(
|
|
12
|
+
buildingId: string | null | undefined,
|
|
13
|
+
nodes: Record<string, AnyNode>,
|
|
14
|
+
): LevelNode[] {
|
|
15
|
+
if (!buildingId) return []
|
|
16
|
+
const building = nodes[buildingId as AnyNodeId]
|
|
17
|
+
if (building?.type !== 'building') return []
|
|
18
|
+
|
|
19
|
+
return building.children
|
|
20
|
+
.map((childId) => nodes[childId as AnyNodeId])
|
|
21
|
+
.filter((entry): entry is LevelNode => entry?.type === 'level')
|
|
22
|
+
.sort((left, right) => left.level - right.level)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function resolveCurrentBuildingId({
|
|
26
|
+
buildingId,
|
|
27
|
+
levelId,
|
|
28
|
+
nodes,
|
|
29
|
+
}: {
|
|
30
|
+
buildingId: BuildingNode['id'] | null
|
|
31
|
+
levelId: LevelNode['id'] | null
|
|
32
|
+
nodes: Record<string, AnyNode>
|
|
33
|
+
}): BuildingNode['id'] | null {
|
|
34
|
+
if (buildingId) return buildingId
|
|
35
|
+
if (!levelId) return null
|
|
36
|
+
|
|
37
|
+
const level = nodes[levelId as AnyNodeId]
|
|
38
|
+
if (
|
|
39
|
+
level?.type === 'level' &&
|
|
40
|
+
level.parentId &&
|
|
41
|
+
nodes[level.parentId as AnyNodeId]?.type === 'building'
|
|
42
|
+
) {
|
|
43
|
+
return level.parentId as BuildingNode['id']
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function resolveElevatorSupportLevelId({
|
|
50
|
+
buildingId,
|
|
51
|
+
preferredLevelId,
|
|
52
|
+
}: {
|
|
53
|
+
buildingId: string | null | undefined
|
|
54
|
+
preferredLevelId?: string | null
|
|
55
|
+
}): LevelNode['id'] | null {
|
|
56
|
+
const nodes = useScene.getState().nodes
|
|
57
|
+
const preferred = preferredLevelId ? nodes[preferredLevelId as AnyNodeId] : undefined
|
|
58
|
+
const levels = getBuildingLevels(buildingId, nodes)
|
|
59
|
+
const preferredInBuilding = preferredLevelId
|
|
60
|
+
? levels.find((level) => level.id === preferredLevelId)
|
|
61
|
+
: undefined
|
|
62
|
+
|
|
63
|
+
if (preferredInBuilding) return preferredInBuilding.id
|
|
64
|
+
if (levels.length === 0) return preferred?.type === 'level' ? preferred.id : null
|
|
65
|
+
|
|
66
|
+
return levels[0]?.id ?? null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function resolveElevatorSupportY({
|
|
70
|
+
buildingId,
|
|
71
|
+
preferredLevelId,
|
|
72
|
+
x,
|
|
73
|
+
z,
|
|
74
|
+
}: {
|
|
75
|
+
buildingId: string | null | undefined
|
|
76
|
+
preferredLevelId?: string | null
|
|
77
|
+
x: number
|
|
78
|
+
z: number
|
|
79
|
+
}): number {
|
|
80
|
+
const levelId = resolveElevatorSupportLevelId({ buildingId, preferredLevelId })
|
|
81
|
+
if (!levelId) return 0
|
|
82
|
+
|
|
83
|
+
return Math.max(0, spatialGridManager.getSlabElevationAt(levelId, x, z))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function resolveElevatorNodeSupportY(
|
|
87
|
+
node: ElevatorNode,
|
|
88
|
+
position: [number, number, number] = node.position,
|
|
89
|
+
): number {
|
|
90
|
+
return resolveElevatorSupportY({
|
|
91
|
+
buildingId: node.parentId,
|
|
92
|
+
preferredLevelId: node.fromLevelId ?? node.defaultLevelId,
|
|
93
|
+
x: position[0],
|
|
94
|
+
z: position[2],
|
|
95
|
+
})
|
|
96
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import useAlignmentGuides from '../../store/use-alignment-guides'
|
|
3
|
+
import { applyFloorplanAlignment } from './apply-alignment'
|
|
4
|
+
|
|
5
|
+
describe('applyFloorplanAlignment', () => {
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
useAlignmentGuides.getState().clear()
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
test('can publish passive guides without applying snap', () => {
|
|
11
|
+
useAlignmentGuides.getState().clear()
|
|
12
|
+
|
|
13
|
+
const result = applyFloorplanAlignment(
|
|
14
|
+
[0.04, 2],
|
|
15
|
+
[{ nodeId: 'draft', kind: 'corner', x: 0.04, z: 2 }],
|
|
16
|
+
[{ nodeId: 'wall_a', kind: 'corner', x: 0, z: 0 }],
|
|
17
|
+
{ applySnap: false },
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
expect(result.point).toEqual([0.04, 2])
|
|
21
|
+
expect(result.snapped).toBe(false)
|
|
22
|
+
expect(result.guides).toHaveLength(1)
|
|
23
|
+
expect(useAlignmentGuides.getState().guides).toHaveLength(1)
|
|
24
|
+
})
|
|
25
|
+
})
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AlignmentAnchor,
|
|
3
|
+
type AlignmentGuide,
|
|
4
|
+
collectAlignmentAnchors,
|
|
5
|
+
useScene,
|
|
6
|
+
} from '@pascal-app/core'
|
|
7
|
+
import useAlignmentGuides from '../../store/use-alignment-guides'
|
|
8
|
+
import { resolveAlignmentForFloorplanView } from '../world-grid-snap'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Fixed Figma-style alignment threshold (meters) for floor-plan placement /
|
|
12
|
+
* move — parity with the 3D tools' `ALIGNMENT_THRESHOLD_M`. Pure-2D drafting
|
|
13
|
+
* can pass a zoom-scaled `threshold` instead so the magnetic pull stays
|
|
14
|
+
* constant in screen pixels across zoom levels.
|
|
15
|
+
*/
|
|
16
|
+
export const FLOORPLAN_ALIGNMENT_THRESHOLD_M = 0.08
|
|
17
|
+
|
|
18
|
+
export type FloorplanAlignmentResult = {
|
|
19
|
+
/** Plan point with the alignment snap delta applied (grid snap should
|
|
20
|
+
* already be baked into the input point). */
|
|
21
|
+
point: [number, number]
|
|
22
|
+
snapped: boolean
|
|
23
|
+
guides: AlignmentGuide[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Layer Figma-style alignment on top of an already grid-snapped plan point,
|
|
28
|
+
* shared by the 2D move sessions (`*FloorplanMoveTarget`) and the structural
|
|
29
|
+
* drafting branches in `floorplan-panel`.
|
|
30
|
+
*
|
|
31
|
+
* Publishes guides to the `useAlignmentGuides` store as a side effect — set
|
|
32
|
+
* on a match, cleared otherwise — so the mounted `FloorplanAlignmentGuideLayer`
|
|
33
|
+
* renders them. Returns the adjusted point. When `bypass` is true (Alt for
|
|
34
|
+
* alignment-only bypass, or Shift for the full guided-constraint bypass) the
|
|
35
|
+
* point is returned unchanged and guides are cleared.
|
|
36
|
+
*
|
|
37
|
+
* `candidates` should be gathered ONCE per drag (`collectAlignmentAnchors`);
|
|
38
|
+
* the scene is stable during a single drag, so re-collecting per pointer-move
|
|
39
|
+
* is wasted work.
|
|
40
|
+
*/
|
|
41
|
+
export function applyFloorplanAlignment(
|
|
42
|
+
point: readonly [number, number],
|
|
43
|
+
movingAnchors: AlignmentAnchor[],
|
|
44
|
+
candidates: AlignmentAnchor[],
|
|
45
|
+
opts?: { applySnap?: boolean; bypass?: boolean; threshold?: number },
|
|
46
|
+
): FloorplanAlignmentResult {
|
|
47
|
+
if (opts?.bypass) {
|
|
48
|
+
useAlignmentGuides.getState().clear()
|
|
49
|
+
return { point: [point[0], point[1]], snapped: false, guides: [] }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const result = resolveAlignmentForFloorplanView({
|
|
53
|
+
moving: movingAnchors,
|
|
54
|
+
candidates,
|
|
55
|
+
threshold: opts?.threshold ?? FLOORPLAN_ALIGNMENT_THRESHOLD_M,
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
useAlignmentGuides.getState().set(result.guides)
|
|
59
|
+
|
|
60
|
+
if (!result.snap || opts?.applySnap === false) {
|
|
61
|
+
return { point: [point[0], point[1]], snapped: false, guides: result.guides }
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
point: [point[0] + result.snap.dx, point[1] + result.snap.dz],
|
|
65
|
+
snapped: true,
|
|
66
|
+
guides: result.guides,
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Synthetic node id for the in-progress structural-draft vertex. Never
|
|
71
|
+
* collides with a real scene node, so `collectAlignmentAnchors` excludes
|
|
72
|
+
* nothing real. */
|
|
73
|
+
export const FLOORPLAN_DRAFT_ALIGN_ID = '__floorplan_draft__'
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Align a single grid-snapped structural-draft vertex (wall / fence / polygon
|
|
77
|
+
* / roof endpoint) against every other node's anchors (incl. wall faces from
|
|
78
|
+
* the wall-face anchor work). Treats the vertex as one corner anchor, gathers
|
|
79
|
+
* candidates from the live scene, publishes guides (cleared on `bypass`), and
|
|
80
|
+
* returns the possibly-snapped point.
|
|
81
|
+
*
|
|
82
|
+
* Used by BOTH the move-preview branch and the click-commit handler so the
|
|
83
|
+
* committed vertex lands exactly where the preview showed it. Caller owns the
|
|
84
|
+
* per-kind precedence: existing-wall endpoint/join snap can still win, while
|
|
85
|
+
* angle-locked segments can pass `applySnap: false` to publish passive guide
|
|
86
|
+
* feedback without pulling the endpoint off its constrained ray.
|
|
87
|
+
*
|
|
88
|
+
* `excludeIds` drops those nodes' anchors from the candidate pool — used when
|
|
89
|
+
* dragging a wall / fence endpoint so the moving endpoint doesn't try to
|
|
90
|
+
* align to its own (and its linked siblings') geometry that moves with it.
|
|
91
|
+
*/
|
|
92
|
+
export function alignFloorplanDraftPoint(
|
|
93
|
+
point: readonly [number, number],
|
|
94
|
+
opts?: {
|
|
95
|
+
applySnap?: boolean
|
|
96
|
+
bypass?: boolean
|
|
97
|
+
threshold?: number
|
|
98
|
+
excludeIds?: readonly string[]
|
|
99
|
+
},
|
|
100
|
+
): [number, number] {
|
|
101
|
+
if (opts?.bypass) {
|
|
102
|
+
useAlignmentGuides.getState().clear()
|
|
103
|
+
return [point[0], point[1]]
|
|
104
|
+
}
|
|
105
|
+
let candidates = collectAlignmentAnchors(useScene.getState().nodes, FLOORPLAN_DRAFT_ALIGN_ID)
|
|
106
|
+
if (opts?.excludeIds?.length) {
|
|
107
|
+
const excluded = new Set(opts.excludeIds)
|
|
108
|
+
candidates = candidates.filter((anchor) => !excluded.has(anchor.nodeId))
|
|
109
|
+
}
|
|
110
|
+
const { point: snapped } = applyFloorplanAlignment(
|
|
111
|
+
point,
|
|
112
|
+
[{ nodeId: FLOORPLAN_DRAFT_ALIGN_ID, kind: 'corner', x: point[0], z: point[1] }],
|
|
113
|
+
candidates,
|
|
114
|
+
{ applySnap: opts?.applySnap, threshold: opts?.threshold },
|
|
115
|
+
)
|
|
116
|
+
return snapped
|
|
117
|
+
}
|
|
@@ -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,
|
|
@@ -1,6 +1,15 @@
|
|
|
1
|
+
export {
|
|
2
|
+
alignFloorplanDraftPoint,
|
|
3
|
+
applyFloorplanAlignment,
|
|
4
|
+
FLOORPLAN_ALIGNMENT_THRESHOLD_M,
|
|
5
|
+
FLOORPLAN_DRAFT_ALIGN_ID,
|
|
6
|
+
type FloorplanAlignmentResult,
|
|
7
|
+
} from './apply-alignment'
|
|
1
8
|
export {
|
|
2
9
|
clampPlanValue,
|
|
3
10
|
doesPolygonIntersectSelectionBounds,
|
|
11
|
+
FLOORPLAN_VIEW_ROTATION_DEG,
|
|
12
|
+
floorplanLocalToWorldPoint,
|
|
4
13
|
getDistanceToWallSegment,
|
|
5
14
|
getFloorplanSelectionBounds,
|
|
6
15
|
getPlanPointDistance,
|
|
@@ -13,6 +22,7 @@ export {
|
|
|
13
22
|
movePlanPointTowards,
|
|
14
23
|
pointMatchesWallPlanPoint,
|
|
15
24
|
rotatePlanVector,
|
|
25
|
+
worldToFloorplanLocalPoint,
|
|
16
26
|
} from './geometry'
|
|
17
27
|
export {
|
|
18
28
|
buildFloorplanItemEntry,
|