@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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { afterEach, describe, expect, test } from 'bun:test'
|
|
2
2
|
import { emitter } from '@pascal-app/core'
|
|
3
3
|
import useEditor from '../store/use-editor'
|
|
4
|
+
import { CONTINUATION_PROFILES } from './continuation'
|
|
4
5
|
import { toolHudTitle } from './hud-title'
|
|
5
6
|
import {
|
|
6
7
|
getWallDrawVariant,
|
|
@@ -23,9 +24,9 @@ describe('Rooms variants', () => {
|
|
|
23
24
|
])
|
|
24
25
|
})
|
|
25
26
|
|
|
26
|
-
test('default to
|
|
27
|
-
expect(
|
|
28
|
-
expect(wallDrawVariantOf('unknown')).toBe('
|
|
27
|
+
test('default to Rectangle', () => {
|
|
28
|
+
expect(wallDrawVariantOf(CONTINUATION_PROFILES.wall.default)).toBe('rectangle')
|
|
29
|
+
expect(wallDrawVariantOf('unknown')).toBe('rectangle')
|
|
29
30
|
})
|
|
30
31
|
|
|
31
32
|
test('picking one sets the wall tool drawing mode, and it sticks', () => {
|
|
@@ -61,11 +62,11 @@ describe('Rooms variants', () => {
|
|
|
61
62
|
|
|
62
63
|
test('name the HUD after the variant in hand', () => {
|
|
63
64
|
expect(toolHudTitle('wall', 'rectangle')).toEqual({
|
|
64
|
-
label: 'Rectangle room',
|
|
65
|
+
label: expect.objectContaining({ message: 'Rectangle room' }),
|
|
65
66
|
icon: { kind: 'url', src: '/icons/room.webp' },
|
|
66
67
|
shortcut: 'B',
|
|
67
68
|
})
|
|
68
|
-
expect(toolHudTitle('wall', 'room')?.label).toBe('Polygon room')
|
|
69
|
-
expect(toolHudTitle('wall', 'single')?.label).toBe('Walls')
|
|
69
|
+
expect(toolHudTitle('wall', 'room')?.label.message).toBe('Polygon room')
|
|
70
|
+
expect(toolHudTitle('wall', 'single')?.label.message).toBe('Walls')
|
|
70
71
|
})
|
|
71
72
|
})
|
|
@@ -1,6 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
emitter,
|
|
3
|
+
type MessageDescriptor,
|
|
4
|
+
railingWallDefaults,
|
|
5
|
+
useScene,
|
|
6
|
+
type WallNode,
|
|
7
|
+
} from '@pascal-app/core'
|
|
8
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
9
|
+
import { create } from 'zustand'
|
|
2
10
|
import useEditor from '../store/use-editor'
|
|
3
11
|
import type { ContinuationMode } from './continuation'
|
|
12
|
+
import { levelSeparatorLines } from './separator-lines'
|
|
4
13
|
|
|
5
14
|
/**
|
|
6
15
|
* How the wall tool draws, picked from the Build panel's Rooms group before
|
|
@@ -16,7 +25,7 @@ export type WallDrawVariantInfo = {
|
|
|
16
25
|
/** Tile label. */
|
|
17
26
|
label: string
|
|
18
27
|
/** The name the HUD shows while the variant is armed. */
|
|
19
|
-
title:
|
|
28
|
+
title: MessageDescriptor
|
|
20
29
|
iconSrc: string
|
|
21
30
|
/** Caption under the Rooms tiles: a bold lead, then the rest. */
|
|
22
31
|
caption: { lead: string; rest: string }
|
|
@@ -27,7 +36,7 @@ export const WALL_DRAW_VARIANTS: readonly WallDrawVariantInfo[] = [
|
|
|
27
36
|
id: 'rectangle',
|
|
28
37
|
mode: 'rectangle',
|
|
29
38
|
label: 'Rectangle',
|
|
30
|
-
title: 'Rectangle room',
|
|
39
|
+
title: /* i18n */ { id: 'editor.lib.wallDrawVariant.rectangleRoom', message: 'Rectangle room' },
|
|
31
40
|
iconSrc: '/icons/room.webp',
|
|
32
41
|
caption: {
|
|
33
42
|
lead: 'Click two corners',
|
|
@@ -38,7 +47,7 @@ export const WALL_DRAW_VARIANTS: readonly WallDrawVariantInfo[] = [
|
|
|
38
47
|
id: 'polygon',
|
|
39
48
|
mode: 'room',
|
|
40
49
|
label: 'Polygon',
|
|
41
|
-
title: 'Polygon room',
|
|
50
|
+
title: /* i18n */ { id: 'editor.lib.wallDrawVariant.polygonRoom', message: 'Polygon room' },
|
|
42
51
|
iconSrc: '/icons/polygon-room.webp',
|
|
43
52
|
caption: {
|
|
44
53
|
lead: 'Click each corner,',
|
|
@@ -49,7 +58,7 @@ export const WALL_DRAW_VARIANTS: readonly WallDrawVariantInfo[] = [
|
|
|
49
58
|
id: 'walls',
|
|
50
59
|
mode: 'single',
|
|
51
60
|
label: 'Walls',
|
|
52
|
-
title: 'Walls',
|
|
61
|
+
title: /* i18n */ { id: 'editor.lib.wallDrawVariant.walls', message: 'Walls' },
|
|
53
62
|
iconSrc: '/icons/wall.webp',
|
|
54
63
|
caption: {
|
|
55
64
|
lead: 'Draw one wall at a time.',
|
|
@@ -66,7 +75,7 @@ export function getWallDrawVariantInfo(variant: WallDrawVariant): WallDrawVarian
|
|
|
66
75
|
}
|
|
67
76
|
|
|
68
77
|
export function wallDrawVariantOf(mode: ContinuationMode): WallDrawVariant {
|
|
69
|
-
return WALL_DRAW_VARIANTS.find((info) => info.mode === mode)?.id ?? '
|
|
78
|
+
return WALL_DRAW_VARIANTS.find((info) => info.mode === mode)?.id ?? 'rectangle'
|
|
70
79
|
}
|
|
71
80
|
|
|
72
81
|
export function getWallDrawVariant(): WallDrawVariant {
|
|
@@ -89,3 +98,95 @@ export function selectWallDrawVariant(variant: WallDrawVariant): void {
|
|
|
89
98
|
if (editor.mode === 'build' && editor.tool === 'wall') emitter.emit('tool:cancel')
|
|
90
99
|
editor.setContinuation('wall', mode)
|
|
91
100
|
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* What the wall tool draws in place of plain walls: invisible walls (room separators no wall
|
|
104
|
+
* stands on) or railings.
|
|
105
|
+
*/
|
|
106
|
+
export type WallLineKind = 'invisible' | 'railing'
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The Walls variant's kinds, its sub-row in the Build panel: a plain wall (room detection
|
|
110
|
+
* sides it exterior or interior), an invisible wall, or a railing.
|
|
111
|
+
*/
|
|
112
|
+
export type WallKind = 'standard' | WallLineKind
|
|
113
|
+
|
|
114
|
+
export const WALL_KINDS: ReadonlyArray<{ id: WallKind; label: MessageDescriptor }> = [
|
|
115
|
+
{
|
|
116
|
+
id: 'standard',
|
|
117
|
+
label: /* i18n */ { id: 'editor.lib.wallDrawVariant.kind.standard', message: 'Standard' },
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
id: 'invisible',
|
|
121
|
+
label: /* i18n */ { id: 'editor.lib.wallDrawVariant.kind.invisible', message: 'Invisible' },
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
id: 'railing',
|
|
125
|
+
label: /* i18n */ { id: 'editor.lib.wallDrawVariant.kind.railing', message: 'Railing' },
|
|
126
|
+
},
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
/** The line an invisible wall's draft is drawn at, metres. */
|
|
130
|
+
export const INVISIBLE_WALL_DRAFT_THICKNESS = 0.01
|
|
131
|
+
|
|
132
|
+
// Session-only, so it lives beside the persisted editor store, not in it.
|
|
133
|
+
const useWallLineKindStore = create<{ lineKind: WallLineKind | null }>(() => ({
|
|
134
|
+
lineKind: null,
|
|
135
|
+
}))
|
|
136
|
+
|
|
137
|
+
export function getWallLineKind(): WallLineKind | null {
|
|
138
|
+
return useWallLineKindStore.getState().lineKind
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The kind the Walls variant draws, for the Build panel's sub-row. */
|
|
142
|
+
export function useWallKind(): WallKind {
|
|
143
|
+
return useWallLineKindStore((state) => state.lineKind ?? 'standard')
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Pick what the Walls variant draws, for this session: it lasts while the wall tool draws
|
|
148
|
+
* Walls (`subscribeWallKindReset`). Switching while the tool is armed drops the in-flight
|
|
149
|
+
* draft, so a half-drawn wall never lands as another kind.
|
|
150
|
+
*/
|
|
151
|
+
export function selectWallKind(kind: WallKind): void {
|
|
152
|
+
const lineKind = kind === 'standard' ? null : kind
|
|
153
|
+
if (getWallLineKind() === lineKind) return
|
|
154
|
+
const editor = useEditor.getState()
|
|
155
|
+
if (editor.mode === 'build' && editor.tool === 'wall') emitter.emit('tool:cancel')
|
|
156
|
+
useWallLineKindStore.setState({ lineKind })
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Back to plain walls once the wall tool is left or draws another variant. */
|
|
160
|
+
export function subscribeWallKindReset(): () => void {
|
|
161
|
+
return useEditor.subscribe((state) => {
|
|
162
|
+
if (
|
|
163
|
+
state.mode === 'build' &&
|
|
164
|
+
state.tool === 'wall' &&
|
|
165
|
+
state.getContinuation('wall') === 'single'
|
|
166
|
+
)
|
|
167
|
+
return
|
|
168
|
+
if (getWallLineKind() !== null) useWallLineKindStore.setState({ lineKind: null })
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* What an invisible-wall draft snaps to besides walls: the current level's
|
|
174
|
+
* invisible walls, as lines (`separatorWallLine`), so a new one closes onto
|
|
175
|
+
* their ends and bodies — its old corner. Nothing while walls or railings draw.
|
|
176
|
+
*/
|
|
177
|
+
export function invisibleWallDraftSnapLines(): WallNode[] {
|
|
178
|
+
if (getWallLineKind() !== 'invisible') return []
|
|
179
|
+
return levelSeparatorLines(useScene.getState().nodes, useViewer.getState().selection.levelId)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The thickness the wall tool's drafts draw at: an invisible wall's line, a railing's, a
|
|
184
|
+
* placed preset's (`toolDefaults.wall`), else `fallback` (a plain wall).
|
|
185
|
+
*/
|
|
186
|
+
export function useWallDraftThickness(fallback: number): number {
|
|
187
|
+
const lineKind = useWallLineKindStore((state) => state.lineKind)
|
|
188
|
+
const seeded = useEditor((state) => state.toolDefaults.wall?.thickness)
|
|
189
|
+
if (lineKind === 'invisible') return INVISIBLE_WALL_DRAFT_THICKNESS
|
|
190
|
+
if (lineKind === 'railing') return railingWallDefaults().thickness
|
|
191
|
+
return typeof seeded === 'number' ? seeded : fallback
|
|
192
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { WallNode } from '@pascal-app/core'
|
|
3
|
+
import { isWallNudgeKey, WALL_NUDGE_IMPERIAL, wallNudgeDistance } from './wall-nudge'
|
|
4
|
+
|
|
5
|
+
const outside = { frontSide: 'exterior', backSide: 'interior' } as const
|
|
6
|
+
const wall = (id: string, start: [number, number], end: [number, number]) =>
|
|
7
|
+
WallNode.parse({ id: `wall_${id}`, start, end, thickness: 0.15, ...outside })
|
|
8
|
+
|
|
9
|
+
describe('wallNudgeDistance', () => {
|
|
10
|
+
const moving = wall('moving', [5, 0.3], [8, 0.3])
|
|
11
|
+
|
|
12
|
+
test('an arrow across the wall moves it one inch that way', () => {
|
|
13
|
+
const down = wallNudgeDistance(moving, [0, 1], [moving], WALL_NUDGE_IMPERIAL)
|
|
14
|
+
const up = wallNudgeDistance(moving, [0, -1], [moving], WALL_NUDGE_IMPERIAL)
|
|
15
|
+
expect(down).not.toBeNull()
|
|
16
|
+
expect(up).not.toBeNull()
|
|
17
|
+
// Signed along the move axis: both land 1 in from where the wall was, opposite ways.
|
|
18
|
+
const move = (nudge: NonNullable<typeof down>) => nudge.axis[1] * nudge.distance
|
|
19
|
+
expect(move(down!)).toBeCloseTo(0.0254, 10)
|
|
20
|
+
expect(move(up!)).toBeCloseTo(-0.0254, 10)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('an arrow along the wall does nothing', () => {
|
|
24
|
+
expect(wallNudgeDistance(moving, [1, 0], [moving], WALL_NUDGE_IMPERIAL)).toBeNull()
|
|
25
|
+
expect(wallNudgeDistance(moving, [-1, 0], [moving], WALL_NUDGE_IMPERIAL)).toBeNull()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('stops flush with a parallel outside wall it would pass', () => {
|
|
29
|
+
const top = wall('top', [0, 0.29], [4, 0.29])
|
|
30
|
+
const nudge = wallNudgeDistance(moving, [0, -1], [moving, top], WALL_NUDGE_IMPERIAL)!
|
|
31
|
+
expect(nudge.axis[1] * nudge.distance).toBeCloseTo(-0.01, 10)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('a turned plan still picks the arrow nearest across the wall', () => {
|
|
35
|
+
const turned = [Math.SQRT1_2, Math.SQRT1_2] as [number, number]
|
|
36
|
+
const nudge = wallNudgeDistance(moving, turned, [moving], WALL_NUDGE_IMPERIAL)!
|
|
37
|
+
expect(nudge.axis[1] * nudge.distance).toBeCloseTo(0.0254, 10)
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('only the four bare arrows nudge', () => {
|
|
42
|
+
expect(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].every(isWallNudgeKey)).toBe(true)
|
|
43
|
+
expect(isWallNudgeKey('w')).toBe(false)
|
|
44
|
+
})
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Arrow keys nudge the selected wall square to itself in the floor plan: one
|
|
3
|
+
* inch a press (a centimetre in metric), or less when a parallel wall it lines
|
|
4
|
+
* up with comes first (`wall-parallel-snap`), so it stops flush. The arrow's
|
|
5
|
+
* direction on screen picks the way; an arrow along the wall does nothing.
|
|
6
|
+
* The nudge is the side arrows' push (`commitRoomPush`), so joined walls
|
|
7
|
+
* follow, jogs and overlaps come out as when the wall is pushed, and each
|
|
8
|
+
* press is one undo step.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
type AnyNodeId,
|
|
13
|
+
getPerpendicularWallMoveAxis,
|
|
14
|
+
useScene,
|
|
15
|
+
type WallNode,
|
|
16
|
+
type WallPlanPoint,
|
|
17
|
+
} from '@pascal-app/core'
|
|
18
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
19
|
+
import { commitRoomPush } from './room-handle-drag'
|
|
20
|
+
import { levelSeparatorLines, separatorWallLine } from './separator-lines'
|
|
21
|
+
import { nudgeWallPosition, wallParallelTargets } from './wall-parallel-snap'
|
|
22
|
+
|
|
23
|
+
/** One nudge in feet and inches, in metres. */
|
|
24
|
+
export const WALL_NUDGE_IMPERIAL = 0.0254
|
|
25
|
+
/** One nudge in metric, in metres. */
|
|
26
|
+
export const WALL_NUDGE_METRIC = 0.01
|
|
27
|
+
|
|
28
|
+
const ARROW_SCREEN_DIRECTIONS: Record<string, [number, number]> = {
|
|
29
|
+
ArrowUp: [0, -1],
|
|
30
|
+
ArrowDown: [0, 1],
|
|
31
|
+
ArrowLeft: [-1, 0],
|
|
32
|
+
ArrowRight: [1, 0],
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// An arrow within about 67° of the wall's own direction runs along it.
|
|
36
|
+
const MIN_ACROSS = 0.4
|
|
37
|
+
|
|
38
|
+
/** True for the four bare arrow keys. */
|
|
39
|
+
export function isWallNudgeKey(key: string): boolean {
|
|
40
|
+
return key in ARROW_SCREEN_DIRECTIONS
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The plan direction an arrow points on screen: the shown floor plan's scene
|
|
45
|
+
* transform, turned or not, maps it back. Null when no floor plan is shown.
|
|
46
|
+
*/
|
|
47
|
+
export function floorplanArrowDirection(key: string): WallPlanPoint | null {
|
|
48
|
+
const screen = ARROW_SCREEN_DIRECTIONS[key]
|
|
49
|
+
if (!screen || typeof document === 'undefined') return null
|
|
50
|
+
const scenes = document.querySelectorAll<SVGGraphicsElement>('[data-floorplan-scene]')
|
|
51
|
+
for (const scene of scenes) {
|
|
52
|
+
const rect = scene.ownerSVGElement?.getBoundingClientRect()
|
|
53
|
+
const ctm = scene.getScreenCTM()
|
|
54
|
+
if (!ctm || !rect || rect.width === 0 || rect.height === 0) continue
|
|
55
|
+
const inverse = ctm.inverse()
|
|
56
|
+
const origin = new DOMPoint(0, 0).matrixTransform(inverse)
|
|
57
|
+
const tip = new DOMPoint(screen[0], screen[1]).matrixTransform(inverse)
|
|
58
|
+
const dx = tip.x - origin.x
|
|
59
|
+
const dz = tip.y - origin.y
|
|
60
|
+
const length = Math.hypot(dx, dz)
|
|
61
|
+
if (length > 1e-12) return [dx / length, dz / length]
|
|
62
|
+
}
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* How far (signed, along the wall's move axis) one nudge of `wall` in plan
|
|
68
|
+
* direction `direction` goes; null when the direction runs along the wall.
|
|
69
|
+
*/
|
|
70
|
+
export function wallNudgeDistance(
|
|
71
|
+
wall: WallNode,
|
|
72
|
+
direction: WallPlanPoint,
|
|
73
|
+
walls: readonly WallNode[],
|
|
74
|
+
step: number,
|
|
75
|
+
): { axis: WallPlanPoint; distance: number } | null {
|
|
76
|
+
const axis = getPerpendicularWallMoveAxis(wall.start, wall.end)
|
|
77
|
+
if (!axis) return null
|
|
78
|
+
const across = direction[0] * axis[0] + direction[1] * axis[1]
|
|
79
|
+
if (Math.abs(across) < MIN_ACROSS) return null
|
|
80
|
+
const position = wall.start[0] * axis[0] + wall.start[1] * axis[1]
|
|
81
|
+
const end = nudgeWallPosition(
|
|
82
|
+
wallParallelTargets(wall, axis, walls),
|
|
83
|
+
position,
|
|
84
|
+
Math.sign(across) * step,
|
|
85
|
+
)
|
|
86
|
+
return { axis, distance: end - position }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Nudge the one selected wall for an arrow key while the floor plan is shown.
|
|
91
|
+
* An invisible wall nudges the same way, as a line: it stops flush with the
|
|
92
|
+
* walls and the other invisible walls, and those joined to it follow.
|
|
93
|
+
* Returns true when the key was used.
|
|
94
|
+
*/
|
|
95
|
+
export function nudgeSelectedWall(key: string): boolean {
|
|
96
|
+
const { selectedIds } = useViewer.getState().selection
|
|
97
|
+
if (selectedIds.length !== 1) return false
|
|
98
|
+
const nodes = useScene.getState().nodes
|
|
99
|
+
const selected = nodes[selectedIds[0] as AnyNodeId]
|
|
100
|
+
if (selected?.type !== 'wall' && selected?.type !== 'separator') return false
|
|
101
|
+
const direction = floorplanArrowDirection(key)
|
|
102
|
+
if (!direction) return false
|
|
103
|
+
|
|
104
|
+
const walls = Object.values(nodes).filter(
|
|
105
|
+
(entry): entry is WallNode => entry?.type === 'wall' && entry.parentId === selected.parentId,
|
|
106
|
+
)
|
|
107
|
+
const step = useViewer.getState().unit === 'imperial' ? WALL_NUDGE_IMPERIAL : WALL_NUDGE_METRIC
|
|
108
|
+
const nudge =
|
|
109
|
+
selected.type === 'wall'
|
|
110
|
+
? wallNudgeDistance(selected, direction, walls, step)
|
|
111
|
+
: wallNudgeDistance(
|
|
112
|
+
separatorWallLine(selected),
|
|
113
|
+
direction,
|
|
114
|
+
[...walls, ...levelSeparatorLines(nodes, selected.parentId, new Set([selected.id]))],
|
|
115
|
+
step,
|
|
116
|
+
)
|
|
117
|
+
if (!nudge) return false
|
|
118
|
+
commitRoomPush(
|
|
119
|
+
selected.type === 'wall'
|
|
120
|
+
? { wallId: selected.id, t0: 0, t1: 1 }
|
|
121
|
+
: {
|
|
122
|
+
wallId: selected.id,
|
|
123
|
+
t0: 0,
|
|
124
|
+
t1: 1,
|
|
125
|
+
separators: [{ separatorId: selected.id, t0: 0, t1: 1 }],
|
|
126
|
+
},
|
|
127
|
+
nudge.axis,
|
|
128
|
+
nudge.distance,
|
|
129
|
+
)
|
|
130
|
+
return true
|
|
131
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { WallNode } from '@pascal-app/core'
|
|
3
|
+
import {
|
|
4
|
+
nearestWallParallelTarget,
|
|
5
|
+
nudgeWallPosition,
|
|
6
|
+
WALL_PARALLEL_SNAP_RADIUS,
|
|
7
|
+
wallParallelTargets,
|
|
8
|
+
} from './wall-parallel-snap'
|
|
9
|
+
|
|
10
|
+
const wall = (id: string, start: [number, number], end: [number, number], extra = {}) =>
|
|
11
|
+
WallNode.parse({ id: `wall_${id}`, start, end, thickness: 0.15, ...extra })
|
|
12
|
+
const outside = { frontSide: 'exterior', backSide: 'interior' } as const
|
|
13
|
+
const DOWN: [number, number] = [0, 1]
|
|
14
|
+
|
|
15
|
+
describe('wallParallelTargets', () => {
|
|
16
|
+
test('an outside wall lines up with a parallel outside wall, not a partition', () => {
|
|
17
|
+
const moving = wall('moving', [5, 0.3], [8, 0.3], outside)
|
|
18
|
+
const top = wall('top', [0, 0.07], [4, 0.07], outside)
|
|
19
|
+
const partition = wall('partition', [1, 0.2], [3, 0.2])
|
|
20
|
+
const side = wall('side', [4, 0.07], [4, 4], outside)
|
|
21
|
+
|
|
22
|
+
const targets = wallParallelTargets(moving, DOWN, [moving, top, partition, side])
|
|
23
|
+
|
|
24
|
+
expect(targets.map((target) => target.wallId)).toEqual(['wall_top'])
|
|
25
|
+
expect(targets).toHaveLength(1)
|
|
26
|
+
expect(targets[0]!.position).toBeCloseTo(0.07)
|
|
27
|
+
// The guide runs along both walls, on the top wall's outdoor face: frontSide
|
|
28
|
+
// is the left-normal side, +z for a wall running +x.
|
|
29
|
+
const [from, to] = [targets[0]!.from, targets[0]!.to]
|
|
30
|
+
expect([from[0], to[0]]).toEqual([0, 8])
|
|
31
|
+
expect(from[1]).toBeCloseTo(0.07 + 0.075)
|
|
32
|
+
expect(to[1]).toBeCloseTo(0.07 + 0.075)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('a partition lines up with every parallel wall', () => {
|
|
36
|
+
const moving = wall('moving', [1, 2], [3, 2])
|
|
37
|
+
const top = wall('top', [0, 0], [4, 0], outside)
|
|
38
|
+
const other = wall('other', [1, 3], [3, 3])
|
|
39
|
+
const ids = wallParallelTargets(moving, DOWN, [moving, top, other]).map((t) => t.wallId)
|
|
40
|
+
expect(new Set(ids)).toEqual(new Set(['wall_top', 'wall_other']))
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('walls of different thickness line up face to face and centre to centre', () => {
|
|
44
|
+
const moving = wall('moving', [5, 1], [8, 1], { ...outside, thickness: 0.2 })
|
|
45
|
+
const top = wall('top', [0, 0], [4, 0], { ...outside, thickness: 0.1 })
|
|
46
|
+
const positions = wallParallelTargets(moving, DOWN, [top]).map((t) => t.position)
|
|
47
|
+
// Faces at ±0.05 against ±0.1, and the centres.
|
|
48
|
+
expect(positions.map((p) => Number(p.toFixed(3))).sort()).toEqual([-0.05, 0, 0.05])
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('a reversed or justified wall lines up by its faces', () => {
|
|
52
|
+
const moving = wall('moving', [8, 1], [5, 1], { ...outside, justification: 'a' })
|
|
53
|
+
const top = wall('top', [0, 0], [4, 0], outside)
|
|
54
|
+
const positions = wallParallelTargets(moving, DOWN, [top]).map((t) => t.position)
|
|
55
|
+
// Justified 'a' on a wall running -x puts its body on the -z side of its
|
|
56
|
+
// line, so its faces and centre all line up with the top wall's when that
|
|
57
|
+
// line sits half a thickness below it.
|
|
58
|
+
expect(positions.map((p) => Number(p.toFixed(3)))).toEqual([0.075])
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
describe('nearestWallParallelTarget', () => {
|
|
63
|
+
test('snaps within the radius and lets go past it', () => {
|
|
64
|
+
const moving = wall('moving', [5, 0.3], [8, 0.3], outside)
|
|
65
|
+
const targets = wallParallelTargets(moving, DOWN, [wall('top', [0, 0], [4, 0], outside)])
|
|
66
|
+
expect(nearestWallParallelTarget(targets, 0.1)?.position).toBeCloseTo(0)
|
|
67
|
+
expect(nearestWallParallelTarget(targets, WALL_PARALLEL_SNAP_RADIUS + 0.01)).toBeNull()
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
describe('nudgeWallPosition', () => {
|
|
72
|
+
const moving = wall('moving', [5, 0.3], [8, 0.3], outside)
|
|
73
|
+
const targets = wallParallelTargets(moving, DOWN, [wall('top', [0, 0.29], [4, 0.29], outside)])
|
|
74
|
+
|
|
75
|
+
test('stops on a line-up it would pass', () => {
|
|
76
|
+
expect(nudgeWallPosition(targets, 0.3, -0.0254)).toBeCloseTo(0.29)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('otherwise moves the whole step', () => {
|
|
80
|
+
expect(nudgeWallPosition(targets, 0.3, 0.0254)).toBeCloseTo(0.3254)
|
|
81
|
+
expect(nudgeWallPosition(targets, 0.29, -0.0254)).toBeCloseTo(0.2646)
|
|
82
|
+
})
|
|
83
|
+
})
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lines a wall that is moving sideways up with the parallel walls around it:
|
|
3
|
+
* a face onto the face on the same side of another wall, or its body centre
|
|
4
|
+
* onto theirs. An outside wall broken up by partitions moves one piece at a
|
|
5
|
+
* time, so each piece can be pulled back flush with the rest of the outside.
|
|
6
|
+
*
|
|
7
|
+
* A wall with an outdoor face lines up only with other outside walls; any
|
|
8
|
+
* other wall lines up with every wall on its level. The 2D and 3D wall moves
|
|
9
|
+
* snap through here, and the arrow-key nudge stops on these positions.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
getWallFaceOffsets,
|
|
14
|
+
isCurvedWall,
|
|
15
|
+
type WallNode,
|
|
16
|
+
type WallPlanPoint,
|
|
17
|
+
} from '@pascal-app/core'
|
|
18
|
+
import useAlignmentGuides from '../store/use-alignment-guides'
|
|
19
|
+
|
|
20
|
+
/** How near (metres) a moving wall comes to lining up before it snaps there. */
|
|
21
|
+
export const WALL_PARALLEL_SNAP_RADIUS = 0.15
|
|
22
|
+
|
|
23
|
+
// Walls within about 1° of each other count as parallel.
|
|
24
|
+
const PARALLEL_TOLERANCE = Math.sin(Math.PI / 180)
|
|
25
|
+
const SAME_POSITION = 1e-6
|
|
26
|
+
|
|
27
|
+
type MovingWall = Pick<
|
|
28
|
+
WallNode,
|
|
29
|
+
'id' | 'start' | 'end' | 'thickness' | 'justification' | 'frontSide' | 'backSide'
|
|
30
|
+
> &
|
|
31
|
+
Partial<Pick<WallNode, 'curveOffset'>>
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A position where the moving wall lines up with another: `position` is where
|
|
35
|
+
* its reference line sits along the move axis, `from` → `to` the shared face
|
|
36
|
+
* or centre line, drawn along both walls.
|
|
37
|
+
*/
|
|
38
|
+
export type WallParallelTarget = {
|
|
39
|
+
position: number
|
|
40
|
+
wallId: string
|
|
41
|
+
from: WallPlanPoint
|
|
42
|
+
to: WallPlanPoint
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isOutsideWall(wall: Pick<WallNode, 'frontSide' | 'backSide'>): boolean {
|
|
46
|
+
return wall.frontSide === 'exterior' || wall.backSide === 'exterior'
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function direction(wall: Pick<WallNode, 'start' | 'end'>): WallPlanPoint | null {
|
|
50
|
+
const dx = wall.end[0] - wall.start[0]
|
|
51
|
+
const dz = wall.end[1] - wall.start[1]
|
|
52
|
+
const length = Math.hypot(dx, dz)
|
|
53
|
+
return length > 1e-9 ? [dx / length, dz / length] : null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const dot = (a: readonly [number, number], b: readonly [number, number]) =>
|
|
57
|
+
a[0] * b[0] + a[1] * b[1]
|
|
58
|
+
|
|
59
|
+
// Which way the wall's left normal (-dy, dx) runs along `axis`.
|
|
60
|
+
function normalSign(dir: WallPlanPoint, axis: WallPlanPoint): number {
|
|
61
|
+
return Math.sign(dot([-dir[1], dir[0]], axis)) || 1
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The wall's faces and body centre along `axis`, relative to its reference
|
|
66
|
+
* line: the face on the axis's positive side, the other face, the centre.
|
|
67
|
+
*/
|
|
68
|
+
function sidePlanes(wall: MovingWall, dir: WallPlanPoint, axis: WallPlanPoint) {
|
|
69
|
+
const sign = normalSign(dir, axis)
|
|
70
|
+
const { a, b } = getWallFaceOffsets(wall)
|
|
71
|
+
const high = Math.max(a * sign, b * sign)
|
|
72
|
+
const low = Math.min(a * sign, b * sign)
|
|
73
|
+
return [high, low, (high + low) / 2] as const
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The order to try a wall's planes in, so the guide of planes that coincide
|
|
78
|
+
* is drawn on its outdoor face (frontSide is the left-normal side), else on
|
|
79
|
+
* its centre.
|
|
80
|
+
*/
|
|
81
|
+
function planeOrder(wall: MovingWall, dir: WallPlanPoint, axis: WallPlanPoint): number[] {
|
|
82
|
+
const outdoor = wall.frontSide === 'exterior' ? 1 : wall.backSide === 'exterior' ? -1 : 0
|
|
83
|
+
const outward = outdoor * normalSign(dir, axis)
|
|
84
|
+
return outward > 0 ? [0, 1, 2] : outward < 0 ? [1, 0, 2] : [2, 0, 1]
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Every position along `axis` (a unit vector square to the wall) where `wall`
|
|
89
|
+
* lines up with a parallel wall in `walls`.
|
|
90
|
+
*/
|
|
91
|
+
export function wallParallelTargets(
|
|
92
|
+
wall: MovingWall,
|
|
93
|
+
axis: WallPlanPoint,
|
|
94
|
+
walls: readonly WallNode[],
|
|
95
|
+
): WallParallelTarget[] {
|
|
96
|
+
const dir = direction(wall)
|
|
97
|
+
if (!dir || isCurvedWall(wall)) return []
|
|
98
|
+
const own = sidePlanes(wall, dir, axis)
|
|
99
|
+
const outsideOnly = isOutsideWall(wall)
|
|
100
|
+
const ownSpan = [dot(wall.start, dir), dot(wall.end, dir)]
|
|
101
|
+
const targets: WallParallelTarget[] = []
|
|
102
|
+
|
|
103
|
+
for (const other of walls) {
|
|
104
|
+
if (other.id === wall.id || isCurvedWall(other)) continue
|
|
105
|
+
if (outsideOnly && !isOutsideWall(other)) continue
|
|
106
|
+
const otherDir = direction(other)
|
|
107
|
+
if (!otherDir || Math.abs(otherDir[0] * dir[1] - otherDir[1] * dir[0]) > PARALLEL_TOLERANCE) {
|
|
108
|
+
continue
|
|
109
|
+
}
|
|
110
|
+
const line = dot(other.start, axis)
|
|
111
|
+
const planes = sidePlanes(other, otherDir, axis)
|
|
112
|
+
const along = [...ownSpan, dot(other.start, dir), dot(other.end, dir)]
|
|
113
|
+
const lo = Math.min(...along)
|
|
114
|
+
const hi = Math.max(...along)
|
|
115
|
+
const seen: number[] = []
|
|
116
|
+
for (const index of planeOrder(other, otherDir, axis)) {
|
|
117
|
+
const shared = line + planes[index]!
|
|
118
|
+
const position = shared - own[index]!
|
|
119
|
+
if (seen.some((value) => Math.abs(value - position) < SAME_POSITION)) continue
|
|
120
|
+
seen.push(position)
|
|
121
|
+
targets.push({
|
|
122
|
+
position,
|
|
123
|
+
wallId: other.id,
|
|
124
|
+
from: [dir[0] * lo + axis[0] * shared, dir[1] * lo + axis[1] * shared],
|
|
125
|
+
to: [dir[0] * hi + axis[0] * shared, dir[1] * hi + axis[1] * shared],
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return targets
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The target nearest `position`, if one is within `radius`. */
|
|
133
|
+
export function nearestWallParallelTarget(
|
|
134
|
+
targets: readonly WallParallelTarget[],
|
|
135
|
+
position: number,
|
|
136
|
+
radius = WALL_PARALLEL_SNAP_RADIUS,
|
|
137
|
+
): WallParallelTarget | null {
|
|
138
|
+
let best: WallParallelTarget | null = null
|
|
139
|
+
for (const target of targets) {
|
|
140
|
+
const gap = Math.abs(target.position - position)
|
|
141
|
+
if (gap > radius) continue
|
|
142
|
+
if (!best || gap < Math.abs(best.position - position)) best = target
|
|
143
|
+
}
|
|
144
|
+
return best
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Where a nudge of `step` (signed, along the axis) from `position` ends: the
|
|
149
|
+
* full step, or the first line-up it passes on the way.
|
|
150
|
+
*/
|
|
151
|
+
export function nudgeWallPosition(
|
|
152
|
+
targets: readonly WallParallelTarget[],
|
|
153
|
+
position: number,
|
|
154
|
+
step: number,
|
|
155
|
+
): number {
|
|
156
|
+
let end = position + step
|
|
157
|
+
for (const target of targets) {
|
|
158
|
+
const travel = (target.position - position) * Math.sign(step)
|
|
159
|
+
if (travel > SAME_POSITION && travel < Math.abs(step) - SAME_POSITION) {
|
|
160
|
+
if (Math.abs(target.position - position) < Math.abs(end - position)) end = target.position
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return end
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Show the line a moving wall has snapped onto (null clears it). */
|
|
167
|
+
export function publishWallParallelGuide(target: WallParallelTarget | null): void {
|
|
168
|
+
if (!target) {
|
|
169
|
+
useAlignmentGuides.getState().clear()
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
useAlignmentGuides.getState().setDraft({
|
|
173
|
+
lines: [{ kind: 'alignment', from: target.from, to: target.to }],
|
|
174
|
+
intersections: [],
|
|
175
|
+
})
|
|
176
|
+
}
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
createWallPointerTracker,
|
|
5
5
|
wallPointerInParent,
|
|
6
6
|
wallSideAtPointer,
|
|
7
|
+
wallSideOfPlanPoint,
|
|
7
8
|
} from './wall-placement-pointer'
|
|
8
9
|
|
|
9
10
|
test('keeps the grab offset across a pointer gap on the original face', () => {
|
|
@@ -40,3 +41,25 @@ test('reads the pointer in the rotated wall parent and identifies its face', ()
|
|
|
40
41
|
expect(local[1]).toBeCloseTo(3)
|
|
41
42
|
expect(wallSideAtPointer(local)).toBe('back')
|
|
42
43
|
})
|
|
44
|
+
|
|
45
|
+
test('a plan point reads the wall face it is on, as a 3D hover does', () => {
|
|
46
|
+
// Along +X the frame's +Z (front) is the plan's +Z side.
|
|
47
|
+
const wall = {
|
|
48
|
+
start: [0, 0] as [number, number],
|
|
49
|
+
end: [4, 0] as [number, number],
|
|
50
|
+
thickness: 0.2,
|
|
51
|
+
}
|
|
52
|
+
expect(wallSideOfPlanPoint(wall, [2, 0.3])).toBe('front')
|
|
53
|
+
expect(wallSideOfPlanPoint(wall, [2, -0.3])).toBe('back')
|
|
54
|
+
// Drawn the other way, the same point is on its back.
|
|
55
|
+
expect(
|
|
56
|
+
wallSideOfPlanPoint(
|
|
57
|
+
{ ...wall, start: [4, 0] as [number, number], end: [0, 0] as [number, number] },
|
|
58
|
+
[2, 0.3],
|
|
59
|
+
),
|
|
60
|
+
).toBe('back')
|
|
61
|
+
// A justified wall splits at its body's middle, not its reference line.
|
|
62
|
+
const justified = { ...wall, justification: 'a' as const }
|
|
63
|
+
expect(wallSideOfPlanPoint(justified, [2, 0.05])).toBe('back')
|
|
64
|
+
expect(wallSideOfPlanPoint(justified, [2, 0.15])).toBe('front')
|
|
65
|
+
})
|