@pascal-app/editor 1.0.6 → 1.1.0
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 +18 -15
- package/src/components/editor/action-menu-button.tsx +4 -1
- package/src/components/editor/alignment-3d-guide-layer.tsx +102 -11
- package/src/components/editor/bake-thumbnail.tsx +3 -2
- package/src/components/editor/custom-camera-controls.tsx +110 -42
- package/src/components/editor/duplicate-node.ts +2 -1
- package/src/components/editor/elevation-3d-guide-layer.tsx +1 -1
- package/src/components/editor/first-person-controls.tsx +77 -68
- package/src/components/editor/fit-scene-framing.test.ts +318 -0
- package/src/components/editor/fit-scene-framing.ts +142 -0
- package/src/components/editor/floating-action-menu.tsx +31 -11
- package/src/components/editor/floating-building-action-menu.tsx +1 -1
- package/src/components/editor/floor-openings-overlay.tsx +1 -1
- package/src/components/editor/floor-region-controls.tsx +2 -2
- package/src/components/editor/floorplan-panel.test.ts +220 -0
- package/src/components/editor/floorplan-panel.tsx +458 -140
- package/src/components/editor/floorplan-wall-typing-keys.test.tsx +152 -0
- package/src/components/editor/floorplan-wall-typing-keys.ts +79 -0
- package/src/components/editor/footprint-height-handle.tsx +2 -2
- package/src/components/editor/grid.tsx +24 -15
- package/src/components/editor/group-actions.ts +19 -1
- package/src/components/editor/group-floating-action-menu.tsx +1 -1
- package/src/components/editor/group-move-3d.ts +27 -2
- package/src/components/editor/group-selection-box-3d.tsx +4 -1
- package/src/components/editor/group-transform-shared.ts +23 -1
- package/src/components/editor/index.tsx +150 -20
- package/src/components/editor/inspector-extension-actions.tsx +36 -0
- package/src/components/editor/localized-html.tsx +22 -0
- package/src/components/editor/measurement-pill.tsx +1 -1
- package/src/components/editor/node-action-menu.tsx +46 -12
- package/src/components/editor/node-arrow-handles.tsx +2 -3
- package/src/components/editor/open-wall-ends-3d-layer.tsx +2 -3
- package/src/components/editor/open-wall-ends-hint.tsx +1 -1
- package/src/components/editor/opening-guides-3d-layer.tsx +1 -1
- package/src/components/editor/quick-measurement-card.tsx +1 -1
- package/src/components/editor/registry-action-contributions.test.tsx +67 -6
- package/src/components/editor/registry-action-contributions.tsx +115 -15
- package/src/components/editor/riser-diagram-panel.tsx +59 -9
- package/src/components/editor/room-controls.tsx +4 -3
- package/src/components/editor/room-handles-2d.tsx +158 -7
- package/src/components/editor/room-handles.tsx +244 -54
- package/src/components/editor/room-highlight.tsx +2 -0
- package/src/components/editor/room-labels.tsx +33 -17
- package/src/components/editor/selection-manager.tsx +15 -102
- package/src/components/editor/separator-end-grip.tsx +74 -0
- package/src/components/editor/site-edge-labels.tsx +7 -3
- package/src/components/editor/thumbnail-generator.test.ts +15 -6
- package/src/components/editor/thumbnail-generator.tsx +26 -1
- package/src/components/editor/use-floorplan-background-placement.ts +55 -52
- package/src/components/editor/views/view-bar.tsx +111 -26
- package/src/components/editor/wall-measurement-label.tsx +31 -15
- package/src/components/editor/wall-move-side-handles.tsx +15 -7
- package/src/components/editor/wall-pull-grip.tsx +193 -0
- package/src/components/editor/wall-region-handles.tsx +2 -2
- package/src/components/editor/wall-snap-beacon-layer.tsx +134 -33
- package/src/components/editor-2d/floorplan-adjacent-walls-layer.test.ts +76 -0
- package/src/components/editor-2d/floorplan-adjacent-walls-layer.tsx +186 -0
- package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +63 -3
- package/src/components/editor-2d/floorplan-group-move.tsx +32 -4
- package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +6 -0
- package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +33 -24
- package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +12 -5
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +2 -1
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +29 -5
- package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +96 -4
- package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +52 -12
- package/src/components/editor-2d/renderers/floorplan-draft-measurement.tsx +7 -1
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +13 -1
- package/src/components/editor-2d/renderers/floorplan-placement-typing-hud.test.tsx +35 -0
- package/src/components/editor-2d/renderers/floorplan-placement-typing-hud.tsx +65 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +34 -1
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +211 -39
- package/src/components/procedural-items/inspector.tsx +59 -12
- package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +2 -0
- package/src/components/systems/ceiling/ceiling-system.test.ts +141 -0
- package/src/components/systems/ceiling/ceiling-system.tsx +53 -18
- package/src/components/systems/roof/roof-edit-system.test.tsx +15 -1
- package/src/components/systems/roof/roof-edit-system.tsx +30 -19
- package/src/components/tools/distribution/distribution-run-tool.test.tsx +371 -3
- package/src/components/tools/distribution/distribution-run-tool.tsx +200 -58
- package/src/components/tools/item/move-tool.tsx +1 -6
- package/src/components/tools/item/placement-history.test.tsx +1 -1
- package/src/components/tools/item/placement-strategies.test.ts +64 -1
- package/src/components/tools/item/placement-strategies.ts +5 -9
- package/src/components/tools/item/use-placement-coordinator.tsx +31 -35
- package/src/components/tools/paint-region/wall-paint-region-tool.tsx +1 -1
- package/src/components/tools/registry/item-surface-move.test.ts +11 -0
- package/src/components/tools/registry/move-registry-node-tool.test.ts +42 -1
- package/src/components/tools/registry/move-registry-node-tool.tsx +38 -14
- package/src/components/tools/select/select-candidates.test.ts +45 -0
- package/src/components/tools/select/select-candidates.ts +13 -2
- package/src/components/tools/shared/cursor-sphere.tsx +10 -3
- package/src/components/tools/shared/draft-measurement-label.tsx +1 -1
- package/src/components/tools/shared/placement-box.tsx +2 -6
- package/src/components/tools/shared/placement-coordinate-input.tsx +94 -0
- package/src/components/tools/shared/placement-dimension-guides.tsx +1 -1
- package/src/components/tools/shared/segment-angle.ts +0 -4
- package/src/components/tools/site/terrain-brush-cursor.tsx +1 -1
- package/src/components/tools/tool-manager.tsx +1 -24
- package/src/components/tools/wall/wall-drafting.test.ts +299 -23
- package/src/components/tools/wall/wall-drafting.ts +132 -24
- package/src/components/tools/wall/wall-pull-snap.test.ts +274 -0
- package/src/components/tools/wall/wall-pull-snap.ts +563 -0
- package/src/components/tools/wall/wall-snap-geometry.test.ts +113 -1
- package/src/components/tools/wall/wall-snap-geometry.ts +137 -0
- package/src/components/tools/wall/wall-typed-entry.test.ts +99 -0
- package/src/components/tools/wall/wall-typed-entry.ts +70 -0
- package/src/components/tools/zone/zone-tool.tsx +3 -3
- package/src/components/ui/action-menu/action-button.tsx +2 -0
- package/src/components/ui/action-menu/camera-actions.tsx +8 -7
- package/src/components/ui/action-menu/control-modes.tsx +9 -5
- package/src/components/ui/action-menu/follow-pascal-pill.tsx +3 -1
- package/src/components/ui/action-menu/follow-pascal-toggle.tsx +7 -1
- package/src/components/ui/action-menu/furnish-tools.tsx +7 -6
- package/src/components/ui/action-menu/index.tsx +1 -0
- package/src/components/ui/action-menu/measurement-control.tsx +35 -29
- package/src/components/ui/action-menu/point-ask-button.tsx +4 -3
- package/src/components/ui/action-menu/structure-tools.tsx +25 -23
- package/src/components/ui/action-menu/view-toggles.test.ts +18 -0
- package/src/components/ui/action-menu/view-toggles.tsx +256 -51
- package/src/components/ui/build-panel/build-panel.tsx +163 -25
- package/src/components/ui/command-palette/editor-commands.tsx +38 -11
- package/src/components/ui/controls/finish-swatch.tsx +45 -19
- package/src/components/ui/controls/material-picker.tsx +24 -17
- package/src/components/ui/controls/metric-control.test.ts +52 -0
- package/src/components/ui/controls/metric-control.tsx +62 -24
- package/src/components/ui/controls/segmented-control.tsx +5 -2
- package/src/components/ui/controls/slider-control.test.tsx +23 -0
- package/src/components/ui/controls/slider-control.tsx +47 -19
- package/src/components/ui/controls/tool-options-panel.tsx +74 -44
- package/src/components/ui/floating-level-selector.tsx +100 -48
- package/src/components/ui/helpers/building-helper.tsx +5 -3
- package/src/components/ui/helpers/contextual-helper-panel.tsx +97 -37
- package/src/components/ui/helpers/helper-manager.test.tsx +106 -3
- package/src/components/ui/helpers/helper-manager.tsx +136 -57
- package/src/components/ui/helpers/item-helper.tsx +6 -4
- package/src/components/ui/helpers/registered-tool-helper.tsx +3 -1
- package/src/components/ui/level-duplicate-dialog.tsx +32 -23
- package/src/components/ui/level-wall-copy-prompt.tsx +124 -0
- package/src/components/ui/panels/collections/collections-popover.tsx +11 -11
- package/src/components/ui/panels/deck-boards-fields.tsx +136 -0
- package/src/components/ui/panels/floor-foundation-panel.tsx +114 -39
- package/src/components/ui/panels/floor-opening-panel.tsx +74 -26
- package/src/components/ui/panels/inspector-extensions.tsx +66 -0
- package/src/components/ui/panels/mobile-panel-sheet.tsx +3 -1
- package/src/components/ui/panels/mobile-selection-bar.tsx +34 -28
- package/src/components/ui/panels/multi-field-value.test.ts +27 -0
- package/src/components/ui/panels/multi-field-value.ts +20 -1
- package/src/components/ui/panels/multi-height-mode.tsx +11 -10
- package/src/components/ui/panels/multi-parametric-inspector.tsx +122 -34
- package/src/components/ui/panels/multi-selection-panel.tsx +76 -36
- package/src/components/ui/panels/node-display.ts +35 -24
- package/src/components/ui/panels/panel-manager.tsx +9 -5
- package/src/components/ui/panels/panel-wrapper.tsx +63 -127
- package/src/components/ui/panels/parametric-field-control.tsx +13 -10
- package/src/components/ui/panels/parametric-inspector.tsx +18 -12
- package/src/components/ui/panels/reference-panel.tsx +51 -35
- package/src/components/ui/panels/room-construction-rows.tsx +465 -66
- package/src/components/ui/panels/room-name-options.test.ts +36 -3
- package/src/components/ui/panels/room-name-options.ts +110 -11
- package/src/components/ui/panels/room-openings-field.tsx +22 -13
- package/src/components/ui/panels/room-panel.tsx +207 -100
- package/src/components/ui/panels/shape-choice.tsx +7 -5
- package/src/components/ui/panels/wall-region-list.tsx +7 -7
- package/src/components/ui/primitives/shortcut-token.tsx +10 -6
- package/src/components/ui/right-stack.test.ts +13 -6
- package/src/components/ui/right-stack.tsx +21 -10
- package/src/components/ui/sidebar/icon-rail.tsx +11 -9
- package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +10 -10
- package/src/components/ui/sidebar/panels/items-panel/index.tsx +10 -9
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +3 -0
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +102 -32
- package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +1 -5
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +4 -3
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -3
- package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +4 -2
- package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +7 -5
- package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +4 -2
- package/src/components/ui/sidebar/panels/site-panel/default-names.ts +75 -0
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +4 -2
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +4 -2
- package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +4 -2
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +4 -2
- package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +4 -2
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +118 -72
- package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +36 -3
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +4 -2
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +7 -4
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +8 -2
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +8 -5
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +4 -2
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +9 -4
- package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +4 -2
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +4 -2
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +8 -7
- package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +7 -6
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +16 -1
- package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +15 -8
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +4 -2
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +4 -2
- package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +3 -1
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +19 -9
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +25 -14
- package/src/components/ui/sidebar/tab-bar.tsx +4 -1
- package/src/components/ui/sidebar/use-plugin-panels.tsx +19 -10
- package/src/components/ui/snap-target-badge.tsx +7 -6
- package/src/components/ui/wall-type/wall-type-editor-dialog.tsx +721 -0
- package/src/components/ui/wall-type/wall-type-menu.tsx +167 -0
- package/src/components/viewer/floorplan-compass-button.tsx +12 -3
- package/src/components/viewer/floorplan-preview.tsx +27 -19
- package/src/components/viewer/render-settings-menu.tsx +294 -0
- package/src/components/viewer/sky-control.tsx +710 -0
- package/src/components/viewer/use-viewer-ui-sounds.test.ts +15 -0
- package/src/components/viewer/use-viewer-ui-sounds.ts +46 -0
- package/src/components/viewer/viewer-controls-bar.tsx +278 -260
- package/src/components/viewer/viewer-hover-label.tsx +12 -4
- package/src/components/viewer/viewer-rooms.tsx +62 -5
- package/src/components/viewer/viewer-scene-header.tsx +28 -17
- package/src/components/viewer/viewer-stage-switcher.tsx +4 -2
- package/src/components/viewer-overlay.tsx +8 -0
- package/src/components/walkthrough-hud.tsx +75 -2
- package/src/hooks/use-keyboard.test.ts +85 -4
- package/src/hooks/use-keyboard.ts +65 -7
- package/src/hooks/use-selected-room.ts +12 -1
- package/src/i18n/floor-footprint-name.ts +30 -0
- package/src/i18n/level-name.ts +39 -0
- package/src/i18n/locales/en/messages.po +15849 -0
- package/src/i18n/locales/en/messages.ts +1 -0
- package/src/i18n/locales/en-XA/messages.ts +1 -0
- package/src/i18n/locales/es/messages.po +39331 -0
- package/src/i18n/locales/es/messages.ts +1 -0
- package/src/i18n/locales/fr/messages.po +39331 -0
- package/src/i18n/locales/fr/messages.ts +1 -0
- package/src/i18n/locales/ja/messages.po +39331 -0
- package/src/i18n/locales/ja/messages.ts +1 -0
- package/src/i18n/locales/zh-CN/messages.po +39331 -0
- package/src/i18n/locales/zh-CN/messages.ts +1 -0
- package/src/i18n/locales.test.ts +63 -0
- package/src/i18n/locales.ts +32 -0
- package/src/i18n/message-slot.tsx +25 -0
- package/src/i18n/node-messages.ts +169 -0
- package/src/i18n/pseudo-locale.ts +17 -0
- package/src/i18n/published-entry.test.ts +17 -0
- package/src/i18n/scene-theme-name.ts +16 -0
- package/src/index.tsx +224 -45
- package/src/lib/add-storey.test.ts +3 -3
- package/src/lib/contextual-help.test.ts +20 -1
- package/src/lib/contextual-help.ts +101 -19
- package/src/lib/continuation.ts +38 -11
- package/src/lib/elevation-guides.ts +21 -6
- package/src/lib/elevator-support.ts +1 -72
- package/src/lib/floor-footprints.test.ts +3 -2
- package/src/lib/floor-footprints.ts +1 -9
- package/src/lib/floor-foundation-support.test.ts +15 -6
- package/src/lib/floor-opening-draft.ts +1 -1
- package/src/lib/floor-region-session.ts +1 -1
- package/src/lib/floorplan/dimension-drive.test.ts +36 -2
- package/src/lib/floorplan/dimension-drive.ts +68 -3
- package/src/lib/floorplan/floorplan-export.test.ts +16 -5
- package/src/lib/floorplan/floorplan-export.tsx +13 -16
- package/src/lib/floorplan/floorplan-extension.test.ts +11 -12
- package/src/lib/floorplan/floorplan-extension.ts +37 -18
- package/src/lib/floorplan/floorplan-mode.test.ts +13 -6
- package/src/lib/floorplan/floorplan-mode.ts +14 -12
- package/src/lib/floorplan/floorplan-plugin-install.test.tsx +1 -1
- package/src/lib/floorplan/floorplan-readonly.ts +3 -3
- package/src/lib/floorplan/open-wall-ends.ts +2 -1
- package/src/lib/floorplan/schedules.ts +10 -6
- package/src/lib/floorplan/site-plan/site-annotations.test.ts +46 -1
- package/src/lib/floorplan/site-plan/site-annotations.ts +3 -0
- package/src/lib/floorplan/site-plan/site-parts.ts +6 -3
- package/src/lib/geometry-script/author.test.ts +114 -0
- package/src/lib/geometry-script/author.ts +55 -23
- package/src/lib/geometry-script/client.ts +3 -0
- package/src/lib/geometry-script/finish-scripts.ts +7 -7
- package/src/lib/gesture-lifecycle.ts +3 -1
- package/src/lib/glb-export.test.ts +327 -0
- package/src/lib/glb-export.ts +70 -2
- package/src/lib/grid-frame.test.ts +56 -3
- package/src/lib/grid-frame.ts +28 -1
- package/src/lib/history.test.ts +2 -1
- package/src/lib/hud-title.ts +48 -24
- package/src/lib/ifc-export.test.ts +41 -1
- package/src/lib/inspector-card-mode.test.ts +3 -31
- package/src/lib/inspector-card-mode.ts +5 -10
- package/src/lib/inspector-expanded.test.ts +13 -0
- package/src/lib/inspector-expanded.ts +13 -1
- package/src/lib/interaction/scope.ts +1 -0
- package/src/lib/keyboard-pan.test.ts +7 -0
- package/src/lib/keyboard-pan.ts +9 -2
- package/src/lib/layer-thickness.test.ts +22 -0
- package/src/lib/layer-thickness.ts +79 -0
- package/src/lib/level-selection.test.ts +18 -2
- package/src/lib/level-selection.ts +31 -8
- package/src/lib/linear-display.ts +1 -1
- package/src/lib/live-gesture-round-trip.test.ts +1 -1
- package/src/lib/lot/drop-in.test.ts +59 -1
- package/src/lib/lot/drop-in.ts +42 -50
- package/src/lib/material-swatch-style.ts +24 -0
- package/src/lib/measurement-label.test.ts +1 -1
- package/src/lib/measurement-parser.ts +7 -3
- package/src/lib/measurements.test.ts +24 -6
- package/src/lib/measurements.ts +1 -203
- package/src/lib/mezzanine-selection.test.ts +3 -1
- package/src/lib/node-action-movement.test.tsx +115 -0
- package/src/lib/node-action-movement.ts +2 -0
- package/src/lib/orbit-near-plane.test.ts +12 -0
- package/src/lib/orbit-near-plane.ts +13 -0
- package/src/lib/paint-regions.test.ts +8 -5
- package/src/lib/paint-scope.test.ts +53 -0
- package/src/lib/paint-scope.ts +18 -4
- package/src/lib/panel-rows.ts +17 -8
- package/src/lib/placement-metadata.ts +4 -0
- package/src/lib/point-ask/describe-target.ts +6 -8
- package/src/lib/point-ask/region.ts +1 -1
- package/src/lib/print-shell-compiler.ts +12 -1
- package/src/lib/print-wall-solids.test.ts +11 -0
- package/src/lib/print-wall-solids.ts +9 -0
- package/src/lib/reference-scale.ts +83 -0
- package/src/lib/room-assembly-overlay.ts +1 -1
- package/src/lib/room-built-on.test.ts +28 -17
- package/src/lib/room-built-on.ts +21 -17
- package/src/lib/room-construction-commands.ts +3 -2
- package/src/lib/room-construction.test.ts +28 -1
- package/src/lib/room-construction.ts +14 -0
- package/src/lib/room-controls-phase6.test.ts +141 -1
- package/src/lib/room-handle-drag.ts +554 -58
- package/src/lib/room-name-catalog.test.ts +8 -0
- package/src/lib/room-name-catalog.ts +8 -1
- package/src/lib/room-name-messages.test.ts +132 -0
- package/src/lib/room-name-messages.ts +147 -0
- package/src/lib/room-name-storage.test.ts +334 -0
- package/src/lib/room-push-dimensions.test.ts +38 -1
- package/src/lib/room-push-dimensions.ts +226 -2
- package/src/lib/room-selection.test.ts +23 -6
- package/src/lib/scene-bounds.test.ts +144 -0
- package/src/lib/scene-bounds.ts +79 -15
- package/src/lib/scene-clipboard.test.ts +70 -49
- package/src/lib/scene-clipboard.ts +11 -5
- package/src/lib/selection-cycle.ts +63 -0
- package/src/lib/separator-edit.test.ts +431 -0
- package/src/lib/separator-edit.ts +179 -0
- package/src/lib/separator-lines.ts +38 -0
- package/src/lib/sfx-bus.ts +27 -1
- package/src/lib/sfx-player.ts +16 -0
- package/src/lib/snapping-mode.test.ts +23 -1
- package/src/lib/tool-cursor-icon.ts +5 -1
- package/src/lib/tool-shortcuts.ts +130 -0
- package/src/lib/viewer-selection.test.ts +28 -24
- package/src/lib/viewer-selection.ts +21 -6
- package/src/lib/wall-draw-variant.test.ts +7 -6
- package/src/lib/wall-draw-variant.ts +107 -6
- package/src/lib/wall-nudge.test.ts +44 -0
- package/src/lib/wall-nudge.ts +131 -0
- package/src/lib/wall-parallel-snap.test.ts +83 -0
- package/src/lib/wall-parallel-snap.ts +176 -0
- package/src/lib/wall-placement-pointer.test.ts +23 -0
- package/src/lib/wall-placement-pointer.ts +18 -0
- package/src/lib/wall-pull.test.ts +245 -0
- package/src/lib/wall-pull.ts +209 -0
- package/src/lib/wall-push-line-up.test.ts +132 -0
- package/src/lib/wall-region-list.ts +17 -6
- package/src/lib/wall-run-snap.test.ts +188 -0
- package/src/lib/wall-run-snap.ts +83 -0
- package/src/lib/wall-type.test.ts +125 -0
- package/src/lib/wall-type.ts +119 -0
- package/src/lib/window-interaction.test.ts +44 -0
- package/src/lib/world-grid-snap.test.ts +139 -2
- package/src/lib/world-grid-snap.ts +102 -105
- package/src/store/use-alignment-guides.ts +27 -1
- package/src/store/use-editor.tsx +53 -0
- package/src/store/use-floorplan-annotation-visibility.test.ts +64 -0
- package/src/store/use-floorplan-annotation-visibility.ts +47 -22
- package/src/store/use-placement-typing.test.ts +165 -0
- package/src/store/use-placement-typing.ts +186 -0
- package/src/store/use-wall-snap-indicator.ts +15 -2
- package/src/components/tools/elevator/elevator-defaults.ts +0 -8
- package/src/components/tools/elevator/elevator-tool.tsx +0 -372
- package/src/components/tools/elevator/move-elevator-tool.tsx +0 -262
- package/src/lib/door-interaction.ts +0 -102
- package/src/lib/measurement-label.ts +0 -68
- package/src/lib/window-interaction.ts +0 -100
|
@@ -2,17 +2,19 @@ import { describe, expect, test } from 'bun:test'
|
|
|
2
2
|
import {
|
|
3
3
|
type AnyNode,
|
|
4
4
|
createRoomTopologyIndex,
|
|
5
|
+
formatLinearMeasurement,
|
|
5
6
|
LevelNode,
|
|
6
7
|
type Point,
|
|
7
8
|
WallNode,
|
|
8
9
|
} from '@pascal-app/core'
|
|
9
|
-
import { formatLinearMeasurement } from './measurements'
|
|
10
10
|
import {
|
|
11
11
|
DIMENSION_FLOOR_LIFT,
|
|
12
12
|
mezzanineEdgeDimensions,
|
|
13
13
|
type RoomDimension,
|
|
14
|
+
type WallDimensionFaces,
|
|
14
15
|
wallPushDimensionRooms,
|
|
15
16
|
wallPushDimensions,
|
|
17
|
+
wallSelectionDimensions,
|
|
16
18
|
} from './room-push-dimensions'
|
|
17
19
|
|
|
18
20
|
// Rooms are real: the walls go through core's room topology, so the faces the
|
|
@@ -66,6 +68,20 @@ function measure(
|
|
|
66
68
|
})
|
|
67
69
|
}
|
|
68
70
|
|
|
71
|
+
function selection({ nodes, walls }: Scene, wall: WallNode, faces: WallDimensionFaces) {
|
|
72
|
+
const index = createRoomTopologyIndex()
|
|
73
|
+
index.rebuildLevel(LEVEL, nodes)
|
|
74
|
+
const rooms = index.getLevelTopology(LEVEL)?.rooms ?? []
|
|
75
|
+
return wallSelectionDimensions({
|
|
76
|
+
rooms: wallPushDimensionRooms(rooms, { wallId: wall.id, t0: 0, t1: 1 }, () => 0),
|
|
77
|
+
wall,
|
|
78
|
+
walls,
|
|
79
|
+
faces,
|
|
80
|
+
resolve: (id) => nodes[id],
|
|
81
|
+
baseElevation: 0,
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
69
85
|
const distances = (dimensions: RoomDimension[]) =>
|
|
70
86
|
dimensions.map((dimension) => Number(dimension.distance.toFixed(4)))
|
|
71
87
|
|
|
@@ -206,6 +222,27 @@ describe('two rooms sharing the moving wall', () => {
|
|
|
206
222
|
expect(west.key).not.toBe(east.key)
|
|
207
223
|
})
|
|
208
224
|
|
|
225
|
+
test('selected, a shared wall shows both room widths and no outside size, whatever the faces', () => {
|
|
226
|
+
for (const faces of ['both', 'interior', 'exterior'] as const) {
|
|
227
|
+
const dimensions = selection(rooms, middle, faces)
|
|
228
|
+
expect(dimensions.map((dimension) => dimension.faces)).toEqual(['interior', 'interior'])
|
|
229
|
+
expect(distances(dimensions).sort()).toEqual([3.8, 3.8])
|
|
230
|
+
}
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
test('selected, an exterior wall adds the outside size across the building', () => {
|
|
234
|
+
const east = wallAt(rooms, [8, 0], [8, 4])
|
|
235
|
+
const dimensions = selection(rooms, east, 'both')
|
|
236
|
+
const outside = dimensions.find((dimension) => dimension.faces === 'exterior')!
|
|
237
|
+
// Outer face to outer face, past the middle wall, clear of the room width's line.
|
|
238
|
+
expect(outside.distance).toBeCloseTo(8 + THICKNESS)
|
|
239
|
+
expect(outside.from[0]).toBeCloseTo(8 + THICKNESS / 2)
|
|
240
|
+
expect(outside.to[0]).toBeCloseTo(-THICKNESS / 2)
|
|
241
|
+
expect(distances(dimensions.filter((dimension) => dimension.faces === 'interior'))).toEqual([
|
|
242
|
+
3.8,
|
|
243
|
+
])
|
|
244
|
+
})
|
|
245
|
+
|
|
209
246
|
test('a push over part of the wall measures only the rooms along that part', () => {
|
|
210
247
|
const dimensions = measure(rooms, middle, [1, 0], 0, { t0: 0, t1: 0.5 })
|
|
211
248
|
expect(dimensions).toHaveLength(2)
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
getWallFaceOffsets,
|
|
5
5
|
isCurvedWall,
|
|
6
6
|
type Point,
|
|
7
|
+
type SeparatorNode,
|
|
7
8
|
type WallNode,
|
|
8
9
|
} from '@pascal-app/core'
|
|
9
10
|
|
|
@@ -153,12 +154,62 @@ export function wallPushDimensionRooms(
|
|
|
153
154
|
)
|
|
154
155
|
}
|
|
155
156
|
|
|
157
|
+
/**
|
|
158
|
+
* The rooms along a pushed edge of invisible walls, as `wallPushDimensionRooms`
|
|
159
|
+
* reads them along a wall: each room's stretch of the edge's separators, put
|
|
160
|
+
* on the edge's one `line` (`[0, 1]` its length), faced as that line runs.
|
|
161
|
+
*/
|
|
162
|
+
export function separatorEdgeDimensionRooms(
|
|
163
|
+
rooms: ReadonlyArray<{ id: string; spans: readonly BoundarySpan[] }>,
|
|
164
|
+
edge: {
|
|
165
|
+
line: Pick<SeparatorNode, 'start' | 'end'>
|
|
166
|
+
separators: ReadonlyArray<{ separatorId: string; t0: number; t1: number }>
|
|
167
|
+
},
|
|
168
|
+
resolve: (id: string) => AnyNode | undefined,
|
|
169
|
+
elevationOf: (roomId: string) => number,
|
|
170
|
+
): PushDimensionRoom[] {
|
|
171
|
+
const { start, end } = edge.line
|
|
172
|
+
const dx = end[0] - start[0]
|
|
173
|
+
const dz = end[1] - start[1]
|
|
174
|
+
const lengthSq = dx * dx + dz * dz
|
|
175
|
+
if (lengthSq < 1e-12) return []
|
|
176
|
+
const members = new Set(edge.separators.map((member) => member.separatorId))
|
|
177
|
+
const along = ([x, z]: Point) => ((x - start[0]) * dx + (z - start[1]) * dz) / lengthSq
|
|
178
|
+
return rooms.flatMap((room) =>
|
|
179
|
+
room.spans.flatMap((own): PushDimensionRoom[] => {
|
|
180
|
+
const member = edge.separators.find((entry) => entry.separatorId === own.boundaryId)
|
|
181
|
+
const separator = resolve(own.boundaryId)
|
|
182
|
+
if (!member || separator?.type !== 'separator') return []
|
|
183
|
+
const t0 = Math.max(member.t0, own.t0)
|
|
184
|
+
const t1 = Math.min(member.t1, own.t1)
|
|
185
|
+
if (t1 - t0 < 1e-4) return []
|
|
186
|
+
const sx = separator.end[0] - separator.start[0]
|
|
187
|
+
const sz = separator.end[1] - separator.start[1]
|
|
188
|
+
const point = (t: number): Point => [separator.start[0] + sx * t, separator.start[1] + sz * t]
|
|
189
|
+
const [u0, u1] = [along(point(t0)), along(point(t1))]
|
|
190
|
+
// A separator drawn against the edge's line has its faces the other way round.
|
|
191
|
+
const same = sx * dx + sz * dz > 0
|
|
192
|
+
return [
|
|
193
|
+
{
|
|
194
|
+
id: room.id,
|
|
195
|
+
face: same ? own.face : own.face === 'a' ? 'b' : 'a',
|
|
196
|
+
t0: Math.min(u0, u1),
|
|
197
|
+
t1: Math.max(u0, u1),
|
|
198
|
+
others: room.spans.filter((other) => !members.has(other.boundaryId)),
|
|
199
|
+
elevation: elevationOf(room.id),
|
|
200
|
+
},
|
|
201
|
+
]
|
|
202
|
+
}),
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
|
|
156
206
|
/**
|
|
157
207
|
* The live dimensions of a wall push: for every room along the pushed stretch,
|
|
158
208
|
* the moving face (the resting face moved `distance` along `outward` — a push
|
|
159
209
|
* is a pure translation) to each facing parallel boundary of that room, read
|
|
160
210
|
* through `resolve` so the preview's geometry counts. Per side of the wall:
|
|
161
|
-
* nearest first, one per distinct distance, at most three.
|
|
211
|
+
* nearest first, one per distinct distance, at most three. An edge of
|
|
212
|
+
* invisible walls is measured as its one line.
|
|
162
213
|
*/
|
|
163
214
|
export function wallPushDimensions({
|
|
164
215
|
rooms,
|
|
@@ -168,7 +219,7 @@ export function wallPushDimensions({
|
|
|
168
219
|
resolve,
|
|
169
220
|
}: {
|
|
170
221
|
rooms: readonly PushDimensionRoom[]
|
|
171
|
-
wall: WallNode
|
|
222
|
+
wall: WallNode | SeparatorNode
|
|
172
223
|
outward: Point
|
|
173
224
|
distance: number
|
|
174
225
|
resolve: (id: string) => AnyNode | undefined
|
|
@@ -195,6 +246,179 @@ export function wallPushDimensions({
|
|
|
195
246
|
)
|
|
196
247
|
}
|
|
197
248
|
|
|
249
|
+
/** Whether two dimensions measure the same span, either way round. */
|
|
250
|
+
export function sameRoomDimension(a: RoomDimension, b: RoomDimension): boolean {
|
|
251
|
+
const near = (p: Point, q: Point) => Math.hypot(p[0] - q[0], p[1] - q[1]) < 1e-3
|
|
252
|
+
return (near(a.from, b.from) && near(a.to, b.to)) || (near(a.from, b.to) && near(a.to, b.from))
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** A selected wall's temporary dimension: a room width (ID) or the outside size (OD). */
|
|
256
|
+
export type WallSelectionDimension = RoomDimension & { faces: 'interior' | 'exterior' }
|
|
257
|
+
|
|
258
|
+
/** A selection's temporary dimension with the node it runs from: typing it moves that node. */
|
|
259
|
+
export type OwnedSelectionDimension = WallSelectionDimension & { ownerId: string }
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Several selected nodes' temporary dimensions as one list, keys unique across
|
|
263
|
+
* owners. Two selected walls across one room both measure it: the width shows
|
|
264
|
+
* once, owned by the first.
|
|
265
|
+
*/
|
|
266
|
+
export function mergeSelectionDimensions(
|
|
267
|
+
owners: ReadonlyArray<{ ownerId: string; dimensions: readonly WallSelectionDimension[] }>,
|
|
268
|
+
): OwnedSelectionDimension[] {
|
|
269
|
+
const kept: OwnedSelectionDimension[] = []
|
|
270
|
+
for (const { ownerId, dimensions } of owners)
|
|
271
|
+
for (const dimension of dimensions)
|
|
272
|
+
if (!kept.some((other) => sameRoomDimension(other, dimension)))
|
|
273
|
+
kept.push({ ...dimension, key: `${ownerId}:${dimension.key}`, ownerId })
|
|
274
|
+
return kept
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Which faces a selected wall's temporary dimensions measure to. */
|
|
278
|
+
export type WallDimensionFaces = 'interior' | 'exterior' | 'both'
|
|
279
|
+
|
|
280
|
+
const CONNECTION_TOLERANCE = 0.03
|
|
281
|
+
/** Where along the shared stretch the outside size is drawn, clear of the room widths at its middle. */
|
|
282
|
+
const EXTERIOR_DIMENSION_AT = 0.25
|
|
283
|
+
|
|
284
|
+
function pointSegmentDistance(p: Point, a: Point, b: Point): number {
|
|
285
|
+
const dx = b[0] - a[0]
|
|
286
|
+
const dz = b[1] - a[1]
|
|
287
|
+
const lengthSq = dx * dx + dz * dz
|
|
288
|
+
const t =
|
|
289
|
+
lengthSq < 1e-12
|
|
290
|
+
? 0
|
|
291
|
+
: Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dz) / lengthSq))
|
|
292
|
+
return Math.hypot(p[0] - (a[0] + dx * t), p[1] - (a[1] + dz * t))
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function wallsTouch(left: WallNode, right: WallNode): boolean {
|
|
296
|
+
return (
|
|
297
|
+
[left.start, left.end].some(
|
|
298
|
+
(p) => pointSegmentDistance(p, right.start, right.end) <= CONNECTION_TOLERANCE,
|
|
299
|
+
) ||
|
|
300
|
+
[right.start, right.end].some(
|
|
301
|
+
(p) => pointSegmentDistance(p, left.start, left.end) <= CONNECTION_TOLERANCE,
|
|
302
|
+
)
|
|
303
|
+
)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** The walls joined to `wall` through shared corners and T junctions, itself included. */
|
|
307
|
+
function connectedWalls(wall: WallNode, walls: readonly WallNode[]): WallNode[] {
|
|
308
|
+
const found = new Map<string, WallNode>([[wall.id, wall]])
|
|
309
|
+
const queue = [wall]
|
|
310
|
+
for (let current = queue.pop(); current; current = queue.pop()) {
|
|
311
|
+
for (const candidate of walls) {
|
|
312
|
+
if (found.has(candidate.id) || !wallsTouch(current, candidate)) continue
|
|
313
|
+
found.set(candidate.id, candidate)
|
|
314
|
+
queue.push(candidate)
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return [...found.values()]
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* The outside size across the building from a wall's exterior face: to the
|
|
322
|
+
* far face of the farthest parallel wall of the same connected building that
|
|
323
|
+
* shares some of its length — the opposite exterior wall. Null when nothing
|
|
324
|
+
* is across (a lone wall, a curved one).
|
|
325
|
+
*/
|
|
326
|
+
function overallExteriorDimension(
|
|
327
|
+
wall: WallNode,
|
|
328
|
+
exteriorFace: 'a' | 'b',
|
|
329
|
+
walls: readonly WallNode[],
|
|
330
|
+
): Measured | null {
|
|
331
|
+
const own = boundaryFace(wall, 0, 1, exteriorFace)
|
|
332
|
+
if (!own) return null
|
|
333
|
+
const dx = own.end[0] - own.start[0]
|
|
334
|
+
const dz = own.end[1] - own.start[1]
|
|
335
|
+
const length = Math.hypot(dx, dz)
|
|
336
|
+
const dir: Point = [dx / length, dz / length]
|
|
337
|
+
// The face's room-ward normal points out of the building here: across is its opposite.
|
|
338
|
+
const across: Point = [-own.inward[0], -own.inward[1]]
|
|
339
|
+
const along = (p: Point) => (p[0] - own.start[0]) * dir[0] + (p[1] - own.start[1]) * dir[1]
|
|
340
|
+
let best: { distance: number; lo: number; hi: number } | null = null
|
|
341
|
+
for (const other of connectedWalls(wall, walls)) {
|
|
342
|
+
if (other.id === wall.id) continue
|
|
343
|
+
for (const face of ['a', 'b'] as const) {
|
|
344
|
+
const candidate = boundaryFace(other, 0, 1, face)
|
|
345
|
+
if (!candidate) continue
|
|
346
|
+
const fx = candidate.end[0] - candidate.start[0]
|
|
347
|
+
const fz = candidate.end[1] - candidate.start[1]
|
|
348
|
+
const faceLength = Math.hypot(fx, fz)
|
|
349
|
+
if (Math.abs(dir[0] * fz - dir[1] * fx) / faceLength > PARALLEL_SIN) continue
|
|
350
|
+
const distance =
|
|
351
|
+
(candidate.start[0] - own.start[0]) * across[0] +
|
|
352
|
+
(candidate.start[1] - own.start[1]) * across[1]
|
|
353
|
+
if (distance <= MIN_GAP) continue
|
|
354
|
+
const a = along(candidate.start)
|
|
355
|
+
const b = along(candidate.end)
|
|
356
|
+
const lo = Math.max(0, Math.min(a, b))
|
|
357
|
+
const hi = Math.min(length, Math.max(a, b))
|
|
358
|
+
if (hi - lo < MIN_OVERLAP) continue
|
|
359
|
+
if (!best || distance > best.distance + SAME_DISTANCE) best = { distance, lo, hi }
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
if (!best) return null
|
|
363
|
+
const at = (u: number): Point => [own.start[0] + dir[0] * u, own.start[1] + dir[1] * u]
|
|
364
|
+
const [lo, hi] = [at(best.lo), at(best.hi)]
|
|
365
|
+
// Measured from the same end of the stretch whichever way the wall was drawn,
|
|
366
|
+
// so the walls on either side of the building put their outside size in one place.
|
|
367
|
+
const fromLo = Math.abs(lo[0] - hi[0]) > 1e-6 ? lo[0] < hi[0] : lo[1] <= hi[1]
|
|
368
|
+
const offset = (best.hi - best.lo) * EXTERIOR_DIMENSION_AT
|
|
369
|
+
const from = at(fromLo ? best.lo + offset : best.hi - offset)
|
|
370
|
+
return {
|
|
371
|
+
from,
|
|
372
|
+
to: [from[0] + across[0] * best.distance, from[1] + across[1] * best.distance],
|
|
373
|
+
distance: best.distance,
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* The temporary dimensions of a selected wall at rest, in 2D and 3D: the room
|
|
379
|
+
* widths a push of it shows (its face to each facing wall, both sides — ID),
|
|
380
|
+
* and for an exterior wall the outside size across the building (OD). `faces`
|
|
381
|
+
* picks which; a wall with no outside (rooms on both faces) shows its room
|
|
382
|
+
* widths whatever the choice. Each dimension runs `from` the selected wall,
|
|
383
|
+
* so typing a value moves that wall (`commitDimensionValue`).
|
|
384
|
+
*/
|
|
385
|
+
export function wallSelectionDimensions({
|
|
386
|
+
rooms,
|
|
387
|
+
wall,
|
|
388
|
+
walls,
|
|
389
|
+
faces,
|
|
390
|
+
resolve,
|
|
391
|
+
baseElevation,
|
|
392
|
+
}: {
|
|
393
|
+
rooms: readonly PushDimensionRoom[]
|
|
394
|
+
wall: WallNode
|
|
395
|
+
/** The level's walls, for the opposite exterior wall. */
|
|
396
|
+
walls: readonly WallNode[]
|
|
397
|
+
faces: WallDimensionFaces
|
|
398
|
+
resolve: (id: string) => AnyNode | undefined
|
|
399
|
+
/** The wall's base, for a dimension with no room floor to stand on. */
|
|
400
|
+
baseElevation: number
|
|
401
|
+
}): WallSelectionDimension[] {
|
|
402
|
+
const interior = wallPushDimensions({ rooms, wall, outward: [0, 0], distance: 0, resolve }).map(
|
|
403
|
+
(dimension): WallSelectionDimension => ({ ...dimension, faces: 'interior' }),
|
|
404
|
+
)
|
|
405
|
+
const roomFaces = new Set(rooms.map((room) => room.face))
|
|
406
|
+
const exteriorFace = roomFaces.size === 1 ? (roomFaces.has('a') ? 'b' : 'a') : null
|
|
407
|
+
if (!exteriorFace || faces === 'interior') return interior
|
|
408
|
+
const overall = overallExteriorDimension(wall, exteriorFace, walls)
|
|
409
|
+
const exterior: WallSelectionDimension[] = overall
|
|
410
|
+
? [
|
|
411
|
+
{
|
|
412
|
+
...overall,
|
|
413
|
+
key: 'exterior',
|
|
414
|
+
faces: 'exterior',
|
|
415
|
+
elevation: (rooms[0]?.elevation ?? baseElevation) + DIMENSION_FLOOR_LIFT,
|
|
416
|
+
},
|
|
417
|
+
]
|
|
418
|
+
: []
|
|
419
|
+
return faces === 'exterior' ? exterior : [...interior, ...exterior]
|
|
420
|
+
}
|
|
421
|
+
|
|
198
422
|
/**
|
|
199
423
|
* The live dimensions of a mezzanine edge push: the resting edge moved
|
|
200
424
|
* `distance` along `outward`, to each facing parallel edge of the outline the
|
|
@@ -868,17 +868,34 @@ describe('room drill-down state through the mounted selection manager', () => {
|
|
|
868
868
|
expect(useViewer.getState().selection.selectedIds).toEqual(['wall_free'])
|
|
869
869
|
})
|
|
870
870
|
})
|
|
871
|
-
test('the 2D plan
|
|
871
|
+
test('the 2D plan gives a wall hit to the wall and a room interior to the room', async () => {
|
|
872
872
|
await withRooms(async ({ left, right }) => {
|
|
873
|
-
|
|
873
|
+
// Without the kind's `planHitSelectsNode`, a band hit stays room-first.
|
|
874
874
|
expect(resolvePlanRoomHit('wall_shared', [3.95, 2])).toEqual(left)
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
const
|
|
875
|
+
// The wall definition declares it (its floor-plan extension).
|
|
876
|
+
const get = nodeRegistry.get.bind(nodeRegistry)
|
|
877
|
+
const spy = spyOn(nodeRegistry, 'get').mockImplementation(((kind: string) =>
|
|
878
|
+
kind === 'wall'
|
|
879
|
+
? ({
|
|
880
|
+
...get(kind),
|
|
881
|
+
extensions: { 'pascal:editor/floorplan': { planHitSelectsNode: true } },
|
|
882
|
+
} as AnyNodeDefinition)
|
|
883
|
+
: get(kind)) as typeof nodeRegistry.get)
|
|
884
|
+
try {
|
|
885
|
+
// Either half of a shared wall's band is the wall, never the room beside it.
|
|
886
|
+
expect(resolvePlanRoomHit('wall_west', [-0.05, 2])).toBeNull()
|
|
887
|
+
expect(resolvePlanRoomHit('wall_shared', [3.95, 2])).toBeNull()
|
|
888
|
+
expect(resolvePlanRoomHit('wall_shared', [4.05, 2])).toBeNull()
|
|
889
|
+
expect(resolvePlanRoomHit('wall_free', [9.95, 1])).toBeNull()
|
|
890
|
+
} finally {
|
|
891
|
+
spy.mockRestore()
|
|
892
|
+
}
|
|
893
|
+
expect(resolvePlanRoomHit(null, [2, 2])).toEqual(left)
|
|
894
|
+
expect(resolvePlanRoomHit(null, [6, 2])).toEqual(right)
|
|
878
895
|
const select = async (modifiers = plain) => {
|
|
879
896
|
let selected = false
|
|
880
897
|
await act(async () => {
|
|
881
|
-
selected = selectRoomFromHit(
|
|
898
|
+
selected = selectRoomFromHit(resolvePlanRoomHit(null, [2, 2]), modifiers, null)
|
|
882
899
|
})
|
|
883
900
|
return selected
|
|
884
901
|
}
|
|
@@ -172,6 +172,150 @@ describe('computeSceneBoundsXZ', () => {
|
|
|
172
172
|
expect(bounds!.center).toEqual([2, 2])
|
|
173
173
|
})
|
|
174
174
|
|
|
175
|
+
describe('ancestor transforms', () => {
|
|
176
|
+
function makeBuilding(
|
|
177
|
+
id: string,
|
|
178
|
+
position: [number, number, number],
|
|
179
|
+
rotationY = 0,
|
|
180
|
+
parentId: string | null = null,
|
|
181
|
+
): AnyNode {
|
|
182
|
+
return {
|
|
183
|
+
object: 'node',
|
|
184
|
+
id,
|
|
185
|
+
type: 'building',
|
|
186
|
+
parentId,
|
|
187
|
+
visible: true,
|
|
188
|
+
metadata: {},
|
|
189
|
+
children: [],
|
|
190
|
+
position,
|
|
191
|
+
rotation: [0, rotationY, 0],
|
|
192
|
+
} as unknown as AnyNode
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function makeLevel(id: string, parentId: string): AnyNode {
|
|
196
|
+
return {
|
|
197
|
+
object: 'node',
|
|
198
|
+
id,
|
|
199
|
+
type: 'level',
|
|
200
|
+
parentId,
|
|
201
|
+
visible: true,
|
|
202
|
+
metadata: {},
|
|
203
|
+
children: [],
|
|
204
|
+
level: 0,
|
|
205
|
+
} as unknown as AnyNode
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function child(node: AnyNode, parentId: string): AnyNode {
|
|
209
|
+
return { ...node, parentId } as AnyNode
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function expectClose(actual: [number, number], expected: [number, number]) {
|
|
213
|
+
expect(actual[0]).toBeCloseTo(expected[0], 9)
|
|
214
|
+
expect(actual[1]).toBeCloseTo(expected[1], 9)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
test('carries building-local walls and zones through a translated building', () => {
|
|
218
|
+
const nodes = [
|
|
219
|
+
makeBuilding('building_a', [100, 0, 100]),
|
|
220
|
+
makeLevel('level_a', 'building_a'),
|
|
221
|
+
child(makeWall([-5, -4], [5, -4]), 'level_a'),
|
|
222
|
+
child(
|
|
223
|
+
makeZone([
|
|
224
|
+
[-5, -4],
|
|
225
|
+
[5, -4],
|
|
226
|
+
[5, 4],
|
|
227
|
+
[-5, 4],
|
|
228
|
+
]),
|
|
229
|
+
'level_a',
|
|
230
|
+
),
|
|
231
|
+
]
|
|
232
|
+
const bounds = computeSceneBoundsXZ(nodes)!
|
|
233
|
+
expectClose(bounds.min, [95, 96])
|
|
234
|
+
expectClose(bounds.max, [105, 104])
|
|
235
|
+
expectClose(bounds.center, [100, 100])
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
test('turns building-local geometry by the building yaw', () => {
|
|
239
|
+
// three.js Y rotation by +90°: local (x, z) → (z, −x).
|
|
240
|
+
const nodes = [
|
|
241
|
+
makeBuilding('building_r', [10, 0, 0], Math.PI / 2),
|
|
242
|
+
makeLevel('level_r', 'building_r'),
|
|
243
|
+
child(makeWall([0, 0], [8, 0]), 'level_r'),
|
|
244
|
+
child(makeWall([8, 0], [8, 2]), 'level_r'),
|
|
245
|
+
]
|
|
246
|
+
const bounds = computeSceneBoundsXZ(nodes)!
|
|
247
|
+
expectClose(bounds.min, [10, -8])
|
|
248
|
+
expectClose(bounds.max, [12, 0])
|
|
249
|
+
expectClose(bounds.size, [2, 8])
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
test('composes nested positioned and rotated ancestors', () => {
|
|
253
|
+
const item = {
|
|
254
|
+
object: 'node',
|
|
255
|
+
id: 'item_host',
|
|
256
|
+
type: 'item',
|
|
257
|
+
parentId: 'level_n',
|
|
258
|
+
visible: true,
|
|
259
|
+
metadata: {},
|
|
260
|
+
children: [],
|
|
261
|
+
position: [2, 0, 0],
|
|
262
|
+
rotation: [0, Math.PI / 2, 0],
|
|
263
|
+
} as unknown as AnyNode
|
|
264
|
+
const hosted = {
|
|
265
|
+
object: 'node',
|
|
266
|
+
id: 'item_hosted',
|
|
267
|
+
type: 'item',
|
|
268
|
+
parentId: 'item_host',
|
|
269
|
+
visible: true,
|
|
270
|
+
metadata: {},
|
|
271
|
+
children: [],
|
|
272
|
+
position: [1, 0, 0],
|
|
273
|
+
rotation: [0, 0, 0],
|
|
274
|
+
} as unknown as AnyNode
|
|
275
|
+
// Building at (50, 50) turned 90°: the host lands at (50, 48), and the
|
|
276
|
+
// hosted item (1, 0) under a total 180° yaw lands one metre further −x.
|
|
277
|
+
const nodes = [
|
|
278
|
+
makeBuilding('building_n', [50, 0, 50], Math.PI / 2),
|
|
279
|
+
makeLevel('level_n', 'building_n'),
|
|
280
|
+
item,
|
|
281
|
+
hosted,
|
|
282
|
+
]
|
|
283
|
+
const bounds = computeSceneBoundsXZ(nodes)!
|
|
284
|
+
expectClose(bounds.min, [49, 48])
|
|
285
|
+
expectClose(bounds.max, [50, 50])
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
test('leaves geometry under an untransformed building unchanged', () => {
|
|
289
|
+
const nodes = [
|
|
290
|
+
makeBuilding('building_0', [0, 0, 0]),
|
|
291
|
+
makeLevel('level_0', 'building_0'),
|
|
292
|
+
child(makeWall([0, 0], [4, 0]), 'level_0'),
|
|
293
|
+
child(makeWall([4, 0], [4, 3]), 'level_0'),
|
|
294
|
+
]
|
|
295
|
+
const bounds = computeSceneBoundsXZ(nodes)!
|
|
296
|
+
expect(bounds.min).toEqual([0, 0])
|
|
297
|
+
expect(bounds.max).toEqual([4, 3])
|
|
298
|
+
})
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
test('a door on a wall is bounded by the wall, not its wall-local position', () => {
|
|
302
|
+
const wall = makeWall([50, 50], [60, 50])
|
|
303
|
+
const door = {
|
|
304
|
+
object: 'node',
|
|
305
|
+
id: 'door_1',
|
|
306
|
+
type: 'door',
|
|
307
|
+
parentId: wall.id,
|
|
308
|
+
visible: true,
|
|
309
|
+
metadata: {},
|
|
310
|
+
position: [2, 1, 0],
|
|
311
|
+
} as unknown as AnyNode
|
|
312
|
+
const bounds = computeSceneBoundsXZ([wall, door])
|
|
313
|
+
expect(bounds).not.toBeNull()
|
|
314
|
+
expect(bounds!.min[0]).toBe(50)
|
|
315
|
+
expect(bounds!.max[0]).toBe(60)
|
|
316
|
+
expect(bounds!.center).toEqual([55, 50])
|
|
317
|
+
})
|
|
318
|
+
|
|
175
319
|
test('skips non-finite coordinates', () => {
|
|
176
320
|
const nodes: AnyNode[] = [makeWall([Number.NaN, 0], [4, 2]), makeWall([0, 0], [1, 1])]
|
|
177
321
|
const bounds = computeSceneBoundsXZ(nodes)
|
package/src/lib/scene-bounds.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* tree and derives an axis-aligned bounding box on the XZ (plan) plane.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import { turnPlanPoint, yawOf } from '@pascal-app/core'
|
|
14
15
|
import type { AnyNode } from '@pascal-app/core/schema'
|
|
15
16
|
|
|
16
17
|
export type SceneBoundsXZ = {
|
|
@@ -27,13 +28,19 @@ export type SceneBoundsXZ = {
|
|
|
27
28
|
// A very small guard against degenerate bounds (e.g. a single wall of zero length).
|
|
28
29
|
const MIN_BOUNDS_EXTENT = 0.0001
|
|
29
30
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
31
|
+
/** A node's plan frame in world XZ: local (x, z) → origin + turnPlanPoint(x, z, yaw). */
|
|
32
|
+
type PlanFrame = { x: number; z: number; yaw: number }
|
|
33
|
+
|
|
34
|
+
const WORLD_FRAME: PlanFrame = { x: 0, z: 0, yaw: 0 }
|
|
35
|
+
|
|
36
|
+
type BoundsAcc = { minX: number; minZ: number; maxX: number; maxZ: number; hasPoint: boolean }
|
|
37
|
+
|
|
38
|
+
function extendPoint(acc: BoundsAcc, frame: PlanFrame, localX: unknown, localZ: unknown): void {
|
|
39
|
+
if (typeof localX !== 'number' || typeof localZ !== 'number') return
|
|
40
|
+
if (!(Number.isFinite(localX) && Number.isFinite(localZ))) return
|
|
41
|
+
const [tx, tz] = frame.yaw === 0 ? [localX, localZ] : turnPlanPoint(localX, localZ, frame.yaw)
|
|
42
|
+
const x = frame.x + tx
|
|
43
|
+
const z = frame.z + tz
|
|
37
44
|
if (x < acc.minX) acc.minX = x
|
|
38
45
|
if (x > acc.maxX) acc.maxX = x
|
|
39
46
|
if (z < acc.minZ) acc.minZ = z
|
|
@@ -50,6 +57,9 @@ function extendPoint(
|
|
|
50
57
|
* - `polygon` → zone, slab, site-boundary polygons.
|
|
51
58
|
* - `position` → building/item/door/window position; uses [x, z] only.
|
|
52
59
|
*
|
|
60
|
+
* Each point is in its parent's frame, so it is carried to world XZ through
|
|
61
|
+
* the ancestors' `position` and `rotation` yaw (a moved or turned building).
|
|
62
|
+
*
|
|
53
63
|
* Site-node polygons are intentionally excluded when they are the default
|
|
54
64
|
* 30×30 bootstrap polygon — otherwise a brand-new empty scene would frame
|
|
55
65
|
* an empty square around the origin. We still include site polygons that
|
|
@@ -63,7 +73,7 @@ export function computeSceneBoundsXZ(
|
|
|
63
73
|
const list: AnyNode[] = Array.isArray(nodes) ? nodes : Object.values(nodes)
|
|
64
74
|
if (list.length === 0) return null
|
|
65
75
|
|
|
66
|
-
const acc = {
|
|
76
|
+
const acc: BoundsAcc = {
|
|
67
77
|
minX: Number.POSITIVE_INFINITY,
|
|
68
78
|
minZ: Number.POSITIVE_INFINITY,
|
|
69
79
|
maxX: Number.NEGATIVE_INFINITY,
|
|
@@ -71,15 +81,24 @@ export function computeSceneBoundsXZ(
|
|
|
71
81
|
hasPoint: false,
|
|
72
82
|
}
|
|
73
83
|
|
|
84
|
+
const byId = new Map<string, AnyNode>()
|
|
85
|
+
for (const node of list) {
|
|
86
|
+
if (node && typeof node === 'object' && typeof node.id === 'string') byId.set(node.id, node)
|
|
87
|
+
}
|
|
88
|
+
const frameOf = createFrameResolver(byId)
|
|
89
|
+
|
|
74
90
|
for (const node of list) {
|
|
75
91
|
if (!node || typeof node !== 'object') continue
|
|
76
92
|
const anyNode = node as unknown as Record<string, unknown>
|
|
93
|
+
// A node's own fields live in its parent's frame (a building's walls are
|
|
94
|
+
// building-local), so every point goes through the ancestors' transforms.
|
|
95
|
+
const frame = frameOf(node.parentId ?? null)
|
|
77
96
|
|
|
78
97
|
// Wall / fence endpoints in level coordinates.
|
|
79
98
|
const start = anyNode.start as unknown
|
|
80
99
|
const end = anyNode.end as unknown
|
|
81
|
-
if (Array.isArray(start) && start.length >= 2) extendPoint(acc, start[0], start[1])
|
|
82
|
-
if (Array.isArray(end) && end.length >= 2) extendPoint(acc, end[0], end[1])
|
|
100
|
+
if (Array.isArray(start) && start.length >= 2) extendPoint(acc, frame, start[0], start[1])
|
|
101
|
+
if (Array.isArray(end) && end.length >= 2) extendPoint(acc, frame, end[0], end[1])
|
|
83
102
|
|
|
84
103
|
// Zone / slab polygons (and explicit polygon-shaped site boundaries).
|
|
85
104
|
const polygon = anyNode.polygon as unknown
|
|
@@ -88,7 +107,7 @@ export function computeSceneBoundsXZ(
|
|
|
88
107
|
// points under `polygon.points` (a discriminated PropertyLineData shape).
|
|
89
108
|
for (const point of polygon) {
|
|
90
109
|
if (Array.isArray(point) && point.length >= 2) {
|
|
91
|
-
extendPoint(acc, point[0], point[1])
|
|
110
|
+
extendPoint(acc, frame, point[0], point[1])
|
|
92
111
|
}
|
|
93
112
|
}
|
|
94
113
|
} else if (
|
|
@@ -105,16 +124,18 @@ export function computeSceneBoundsXZ(
|
|
|
105
124
|
} else {
|
|
106
125
|
for (const point of points) {
|
|
107
126
|
if (Array.isArray(point) && point.length >= 2) {
|
|
108
|
-
extendPoint(acc, point[0], point[1])
|
|
127
|
+
extendPoint(acc, frame, point[0], point[1])
|
|
109
128
|
}
|
|
110
129
|
}
|
|
111
130
|
}
|
|
112
131
|
}
|
|
113
132
|
|
|
114
|
-
// Position on the XZ plane (3D position = [x, y, z]).
|
|
133
|
+
// Position on the XZ plane (3D position = [x, y, z]). A node hosted on a
|
|
134
|
+
// wall (door, window, wall item) has a wall-local position, and the
|
|
135
|
+
// wall's endpoints already bound it.
|
|
115
136
|
const position = anyNode.position as unknown
|
|
116
|
-
if (Array.isArray(position) && position.length >= 3) {
|
|
117
|
-
extendPoint(acc, position[0], position[2])
|
|
137
|
+
if (Array.isArray(position) && position.length >= 3 && !isWallHosted(node, byId)) {
|
|
138
|
+
extendPoint(acc, frame, position[0], position[2])
|
|
118
139
|
}
|
|
119
140
|
}
|
|
120
141
|
|
|
@@ -146,6 +167,49 @@ export function computeSceneBoundsXZ(
|
|
|
146
167
|
}
|
|
147
168
|
}
|
|
148
169
|
|
|
170
|
+
/**
|
|
171
|
+
* World plan frame of each node, composed down the parent chain the way the
|
|
172
|
+
* renderers nest groups: a node with a `position` (building, item) translates
|
|
173
|
+
* its children and turns them by its `rotation` yaw; nodes without one (site,
|
|
174
|
+
* level) pass their parent's frame through.
|
|
175
|
+
*/
|
|
176
|
+
function createFrameResolver(byId: Map<string, AnyNode>): (id: string | null) => PlanFrame {
|
|
177
|
+
const cache = new Map<string, PlanFrame>()
|
|
178
|
+
const resolving = new Set<string>()
|
|
179
|
+
|
|
180
|
+
const resolve = (id: string | null): PlanFrame => {
|
|
181
|
+
if (!id) return WORLD_FRAME
|
|
182
|
+
const cached = cache.get(id)
|
|
183
|
+
if (cached) return cached
|
|
184
|
+
const node = byId.get(id)
|
|
185
|
+
if (!node || resolving.has(id)) return WORLD_FRAME
|
|
186
|
+
resolving.add(id)
|
|
187
|
+
const parent = resolve(node.parentId ?? null)
|
|
188
|
+
resolving.delete(id)
|
|
189
|
+
|
|
190
|
+
const anyNode = node as unknown as Record<string, unknown>
|
|
191
|
+
const position = anyNode.position
|
|
192
|
+
let frame = parent
|
|
193
|
+
if (Array.isArray(position) && position.length >= 3) {
|
|
194
|
+
const px = Number.isFinite(position[0]) ? (position[0] as number) : 0
|
|
195
|
+
const pz = Number.isFinite(position[2]) ? (position[2] as number) : 0
|
|
196
|
+
const [tx, tz] = turnPlanPoint(px, pz, parent.yaw)
|
|
197
|
+
frame = { x: parent.x + tx, z: parent.z + tz, yaw: parent.yaw + yawOf(anyNode.rotation) }
|
|
198
|
+
}
|
|
199
|
+
cache.set(id, frame)
|
|
200
|
+
return frame
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return resolve
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function isWallHosted(node: AnyNode, byId: Map<string, AnyNode>): boolean {
|
|
207
|
+
const parent = node.parentId ? byId.get(node.parentId) : undefined
|
|
208
|
+
if (!parent) return false
|
|
209
|
+
const anyParent = parent as unknown as Record<string, unknown>
|
|
210
|
+
return Array.isArray(anyParent.start) && Array.isArray(anyParent.end)
|
|
211
|
+
}
|
|
212
|
+
|
|
149
213
|
/**
|
|
150
214
|
* Matches the `SiteNode` bootstrap polygon defined in
|
|
151
215
|
* `packages/core/src/schema/nodes/site.ts` (a 30×30 square at the origin).
|