@pascal-app/editor 1.0.5 → 1.0.7
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 +20 -17
- 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 +20 -8
- package/src/components/editor/elevation-3d-guide-layer.tsx +1 -1
- package/src/components/editor/first-person-controls.tsx +72 -61
- package/src/components/editor/floating-action-menu.tsx +27 -9
- package/src/components/editor/floating-building-action-menu.tsx +1 -1
- package/src/components/editor/floorplan-panel.test.ts +220 -0
- package/src/components/editor/floorplan-panel.tsx +285 -132
- package/src/components/editor/footprint-height-handle.tsx +1 -1
- 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 +20 -0
- package/src/components/editor/index.tsx +130 -20
- package/src/components/editor/inspector-extension-actions.tsx +36 -0
- package/src/components/editor/localized-html.tsx +22 -0
- package/src/components/editor/node-action-menu.tsx +46 -12
- package/src/components/editor/node-arrow-handles.tsx +1 -2
- package/src/components/editor/open-wall-ends-3d-layer.tsx +1 -1
- package/src/components/editor/opening-guides-3d-layer.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 +1 -1
- package/src/components/editor/room-handles-2d.tsx +156 -6
- package/src/components/editor/room-handles.tsx +241 -51
- package/src/components/editor/room-highlight.tsx +2 -0
- package/src/components/editor/room-labels.tsx +29 -15
- 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 +1 -1
- 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 +1 -1
- package/src/components/editor/wall-move-side-handles.tsx +5 -1
- package/src/components/editor/wall-pull-grip.tsx +193 -0
- package/src/components/editor/wall-snap-beacon-layer.tsx +81 -1
- 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 +31 -3
- package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +6 -0
- package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +22 -13
- package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +11 -3
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +7 -1
- 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-registry-layer.test.ts +12 -1
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +197 -37
- 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/tools/distribution/distribution-run-tool.tsx +50 -26
- 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 +30 -34
- package/src/components/tools/registry/item-surface-move.test.ts +11 -0
- 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 +1 -1
- package/src/components/tools/shared/placement-dimension-guides.tsx +1 -1
- package/src/components/tools/site/terrain-brush-cursor.tsx +1 -1
- 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/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 +30 -14
- 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 +53 -3
- package/src/components/ui/helpers/helper-manager.tsx +82 -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 +113 -38
- package/src/components/ui/panels/floor-opening-panel.tsx +73 -25
- 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 +8 -6
- 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 +8 -7
- package/src/components/ui/panels/multi-parametric-inspector.tsx +122 -34
- package/src/components/ui/panels/multi-selection-panel.tsx +75 -35
- package/src/components/ui/panels/node-display.ts +35 -24
- package/src/components/ui/panels/panel-manager.tsx +5 -3
- package/src/components/ui/panels/panel-wrapper.tsx +22 -109
- 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 +464 -65
- 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 +21 -12
- package/src/components/ui/panels/room-panel.tsx +206 -99
- 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/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/site-panel/building-tree-node.tsx +4 -3
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -2
- 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 +117 -64
- 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 +8 -3
- 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 +18 -8
- 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 +61 -4
- 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 +15117 -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 +37501 -0
- package/src/i18n/locales/es/messages.ts +1 -0
- package/src/i18n/locales/fr/messages.po +37501 -0
- package/src/i18n/locales/fr/messages.ts +1 -0
- package/src/i18n/locales/ja/messages.po +37501 -0
- package/src/i18n/locales/ja/messages.ts +1 -0
- package/src/i18n/locales/zh-CN/messages.po +37501 -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 +156 -4
- package/src/lib/add-storey.test.ts +3 -3
- package/src/lib/contextual-help.test.ts +20 -1
- package/src/lib/contextual-help.ts +95 -19
- package/src/lib/continuation.ts +38 -11
- package/src/lib/floor-footprints.test.ts +2 -1
- package/src/lib/floor-foundation-support.test.ts +7 -3
- 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/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/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/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/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-parser.ts +7 -3
- package/src/lib/measurements.test.ts +17 -0
- package/src/lib/measurements.ts +20 -5
- package/src/lib/mezzanine-selection.test.ts +3 -1
- 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/reference-scale.ts +83 -0
- package/src/lib/room-built-on.test.ts +26 -12
- package/src/lib/room-built-on.ts +20 -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 +140 -0
- package/src/lib/room-handle-drag.ts +557 -42
- 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 +37 -0
- package/src/lib/room-push-dimensions.ts +226 -2
- package/src/lib/room-selection.test.ts +23 -6
- package/src/lib/scene-clipboard.test.ts +70 -49
- package/src/lib/scene-clipboard.ts +9 -4
- 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/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 +20 -5
- 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 +14 -5
- 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 +42 -0
- package/src/lib/window-interaction.ts +47 -5
- 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 +48 -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-wall-snap-indicator.ts +15 -2
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
// Pure snap geometry for a wall pulled from a selected wall's end (the "pull to
|
|
2
|
+
// continue" grip) and for the run it continues — no store / viewer / React
|
|
3
|
+
// deps, so it's unit-testable in isolation. Coordinates are XZ plan points in
|
|
4
|
+
// the building's own frame, the frame both views draft in: the building's axes
|
|
5
|
+
// are the plan's X and Z, so a turned house keeps its square.
|
|
6
|
+
|
|
7
|
+
import { isCurvedWall, type WallNode } from '@pascal-app/core'
|
|
8
|
+
import {
|
|
9
|
+
distanceSquared,
|
|
10
|
+
findWallBodySnapTarget,
|
|
11
|
+
findWallSpecialPointSnap,
|
|
12
|
+
WALL_CONNECT_SNAP_RADIUS,
|
|
13
|
+
type WallPlanPoint,
|
|
14
|
+
wallIdsAtSnapPoint,
|
|
15
|
+
} from './wall-snap-geometry'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* What a pulled wall's end caught, as the snap marker names it: an existing
|
|
19
|
+
* wall's end or middle, a crossing (two walls, or two guides), a point on a
|
|
20
|
+
* wall (`perpendicular` when the new wall meets it square), a guide it lines up
|
|
21
|
+
* with, or a plain grid length.
|
|
22
|
+
*/
|
|
23
|
+
export type WallPullSnapKind =
|
|
24
|
+
| 'endpoint'
|
|
25
|
+
| 'midpoint'
|
|
26
|
+
| 'intersection'
|
|
27
|
+
| 'wall'
|
|
28
|
+
| 'perpendicular'
|
|
29
|
+
| 'alignment'
|
|
30
|
+
| 'grid'
|
|
31
|
+
|
|
32
|
+
/** The words the snap marker shows for each kind. */
|
|
33
|
+
export const WALL_PULL_SNAP_LABELS: Record<WallPullSnapKind, string> = {
|
|
34
|
+
endpoint: 'Endpoint',
|
|
35
|
+
midpoint: 'Midpoint',
|
|
36
|
+
intersection: 'Intersection',
|
|
37
|
+
wall: 'On wall',
|
|
38
|
+
perpendicular: 'Perpendicular',
|
|
39
|
+
alignment: 'Aligned',
|
|
40
|
+
grid: 'Grid',
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* How the wall's direction was held: straight on from the wall it comes from,
|
|
45
|
+
* square to it, along one of the house's axes, on a 15° step, or not at all.
|
|
46
|
+
*/
|
|
47
|
+
export type WallPullDirectionLock = 'straight' | 'square' | 'axis' | 'angle' | 'free'
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A dashed guide: the extension of the wall the new one comes from, a line
|
|
51
|
+
* through another wall's end along one of the house's axes, or the same through
|
|
52
|
+
* the run's start (so the run can close square back to it).
|
|
53
|
+
*/
|
|
54
|
+
export type WallPullGuideKind = 'extension' | 'alignment' | 'run-start'
|
|
55
|
+
|
|
56
|
+
export type WallPullGuide = {
|
|
57
|
+
kind: WallPullGuideKind
|
|
58
|
+
from: WallPlanPoint
|
|
59
|
+
to: WallPlanPoint
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type WallPullSnapResult = {
|
|
63
|
+
point: WallPlanPoint
|
|
64
|
+
snap: WallPullSnapKind | null
|
|
65
|
+
/** `WALL_PULL_SNAP_LABELS[snap]`, or "In line with start" for the run's start guide. */
|
|
66
|
+
label: string | null
|
|
67
|
+
lock: WallPullDirectionLock
|
|
68
|
+
/** Walls whose geometry the end landed on, for the marker's wall highlight. */
|
|
69
|
+
targetWallIds: string[]
|
|
70
|
+
guides: WallPullGuide[]
|
|
71
|
+
/** Where two guides cross near the cursor: markers, and snap targets. */
|
|
72
|
+
intersections: WallPlanPoint[]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Snap radii (m) measured from the cursor, or along the held direction for `wall` / `alignment`. */
|
|
76
|
+
export type WallPullSnapRadii = {
|
|
77
|
+
endpoint: number
|
|
78
|
+
/** A wall's middle, or where two walls cross. */
|
|
79
|
+
special: number
|
|
80
|
+
/** Where two guides cross. */
|
|
81
|
+
intersection: number
|
|
82
|
+
/** The held direction landing on a wall's line, or the square foot on it. */
|
|
83
|
+
wall: number
|
|
84
|
+
/** The held direction landing on a guide. */
|
|
85
|
+
alignment: number
|
|
86
|
+
/** How near a guide line passes the cursor to be considered at all. */
|
|
87
|
+
guide: number
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const WALL_PULL_SNAP_RADII: WallPullSnapRadii = {
|
|
91
|
+
endpoint: 0.3,
|
|
92
|
+
special: 0.2,
|
|
93
|
+
intersection: 0.2,
|
|
94
|
+
wall: 0.25,
|
|
95
|
+
alignment: 0.15,
|
|
96
|
+
guide: 0.6,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The direction holds straight on / square / on a house axis within this angle. */
|
|
100
|
+
export const WALL_PULL_LOCK_TOLERANCE = (7 * Math.PI) / 180
|
|
101
|
+
/** Past the lock the direction snaps to steps of this angle, on the house's axes. */
|
|
102
|
+
export const WALL_PULL_ANGLE_STEP = Math.PI / 12
|
|
103
|
+
|
|
104
|
+
const PERPENDICULAR_SINE = Math.sin((1 * Math.PI) / 180)
|
|
105
|
+
const ON_LINE = 1e-6
|
|
106
|
+
const INTERSECTION_SHOW_RADIUS = 0.75
|
|
107
|
+
const EXTENSION_MIN_LENGTH = 1.5
|
|
108
|
+
const EXTENSION_OVERSHOOT = 1
|
|
109
|
+
const GUIDE_OVERSHOOT = 0.5
|
|
110
|
+
|
|
111
|
+
export type WallPullSnapInput = {
|
|
112
|
+
/** The raw cursor. */
|
|
113
|
+
point: WallPlanPoint
|
|
114
|
+
/** Where the wall being drawn starts. */
|
|
115
|
+
start: WallPlanPoint
|
|
116
|
+
/** Where the run started: the pulled wall end. */
|
|
117
|
+
runStart: WallPlanPoint
|
|
118
|
+
/**
|
|
119
|
+
* Unit direction of the wall the new one comes from, pointing out of its end
|
|
120
|
+
* (the pulled wall's, then each placed wall's). Null draws on the house's
|
|
121
|
+
* axes and 15° steps only.
|
|
122
|
+
*/
|
|
123
|
+
reference: WallPlanPoint | null
|
|
124
|
+
/** The level's walls: snap targets and guide anchors. */
|
|
125
|
+
walls: readonly WallNode[]
|
|
126
|
+
/** Length step along the held direction; 0 leaves lengths free. */
|
|
127
|
+
gridStep: number
|
|
128
|
+
/** Alt, or snapping off: the end follows the cursor. */
|
|
129
|
+
free?: boolean
|
|
130
|
+
radii?: Partial<WallPullSnapRadii>
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
type GuideLine = {
|
|
134
|
+
kind: WallPullGuideKind
|
|
135
|
+
/** A point on the line: the anchor it is drawn from. */
|
|
136
|
+
anchor: WallPlanPoint
|
|
137
|
+
/** Unit direction. */
|
|
138
|
+
direction: WallPlanPoint
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function unit(vector: WallPlanPoint): WallPlanPoint | null {
|
|
142
|
+
const length = Math.hypot(vector[0], vector[1])
|
|
143
|
+
return length > 1e-9 ? [vector[0] / length, vector[1] / length] : null
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function angleBetween(a: WallPlanPoint, b: WallPlanPoint): number {
|
|
147
|
+
return Math.abs(Math.atan2(a[0] * b[1] - a[1] * b[0], a[0] * b[0] + a[1] * b[1]))
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function samePoint(a: WallPlanPoint, b: WallPlanPoint, tolerance = 1e-6): boolean {
|
|
151
|
+
return distanceSquared(a, b) <= tolerance * tolerance
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The direction a pull from `start` toward `vector` holds: straight on from
|
|
156
|
+
* `reference` or square to it, or along a house axis, whichever is nearest
|
|
157
|
+
* within `WALL_PULL_LOCK_TOLERANCE`; otherwise the nearest 15° step on the
|
|
158
|
+
* house's axes. Folding back over the wall it comes from is not offered.
|
|
159
|
+
*/
|
|
160
|
+
export function lockWallPullDirection(
|
|
161
|
+
vector: WallPlanPoint,
|
|
162
|
+
reference: WallPlanPoint | null,
|
|
163
|
+
): { direction: WallPlanPoint; lock: WallPullDirectionLock } {
|
|
164
|
+
const along = unit(vector)
|
|
165
|
+
if (!along) return { direction: reference ?? [1, 0], lock: 'free' }
|
|
166
|
+
const candidates: Array<{ direction: WallPlanPoint; lock: WallPullDirectionLock }> = []
|
|
167
|
+
const ref = reference ? unit(reference) : null
|
|
168
|
+
if (ref) {
|
|
169
|
+
candidates.push(
|
|
170
|
+
{ direction: ref, lock: 'straight' },
|
|
171
|
+
{ direction: [-ref[1], ref[0]], lock: 'square' },
|
|
172
|
+
{ direction: [ref[1], -ref[0]], lock: 'square' },
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
for (const axis of [
|
|
176
|
+
[1, 0],
|
|
177
|
+
[-1, 0],
|
|
178
|
+
[0, 1],
|
|
179
|
+
[0, -1],
|
|
180
|
+
] as WallPlanPoint[]) {
|
|
181
|
+
candidates.push({ direction: axis, lock: 'axis' })
|
|
182
|
+
}
|
|
183
|
+
let best: { direction: WallPlanPoint; lock: WallPullDirectionLock } | null = null
|
|
184
|
+
let bestAngle = Number.POSITIVE_INFINITY
|
|
185
|
+
for (const candidate of candidates) {
|
|
186
|
+
const angle = angleBetween(along, candidate.direction)
|
|
187
|
+
if (angle < bestAngle - 1e-9) {
|
|
188
|
+
best = candidate
|
|
189
|
+
bestAngle = angle
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (best && bestAngle <= WALL_PULL_LOCK_TOLERANCE) return best
|
|
193
|
+
const stepped =
|
|
194
|
+
Math.round(Math.atan2(along[1], along[0]) / WALL_PULL_ANGLE_STEP) * WALL_PULL_ANGLE_STEP
|
|
195
|
+
return { direction: [Math.cos(stepped), Math.sin(stepped)], lock: 'angle' }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The points guides run through: every wall end on the level and the run's
|
|
200
|
+
* start, once each, leaving out the start of the wall being drawn (a guide
|
|
201
|
+
* through it is the direction lock itself).
|
|
202
|
+
*/
|
|
203
|
+
function guideAnchors(
|
|
204
|
+
walls: readonly WallNode[],
|
|
205
|
+
runStart: WallPlanPoint,
|
|
206
|
+
start: WallPlanPoint,
|
|
207
|
+
): Array<{ point: WallPlanPoint; runStart: boolean }> {
|
|
208
|
+
const anchors: Array<{ point: WallPlanPoint; runStart: boolean }> = []
|
|
209
|
+
const add = (point: WallPlanPoint, isRunStart: boolean) => {
|
|
210
|
+
if (samePoint(point, start, 1e-4)) return
|
|
211
|
+
const existing = anchors.find((anchor) => samePoint(anchor.point, point, 1e-4))
|
|
212
|
+
if (existing) {
|
|
213
|
+
existing.runStart ||= isRunStart
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
anchors.push({ point: [point[0], point[1]], runStart: isRunStart })
|
|
217
|
+
}
|
|
218
|
+
add(runStart, true)
|
|
219
|
+
for (const wall of walls) {
|
|
220
|
+
add(wall.start, false)
|
|
221
|
+
add(wall.end, false)
|
|
222
|
+
}
|
|
223
|
+
return anchors
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The guide lines near the cursor: a vertical (constant X) line through each
|
|
228
|
+
* anchor whose X is within `radius` of the cursor's, and a horizontal one
|
|
229
|
+
* likewise. Several anchors on one line give one line, drawn from the anchor
|
|
230
|
+
* nearest the cursor; the run's start keeps its own lines.
|
|
231
|
+
*/
|
|
232
|
+
function nearbyGuideLines(
|
|
233
|
+
anchors: Array<{ point: WallPlanPoint; runStart: boolean }>,
|
|
234
|
+
cursor: WallPlanPoint,
|
|
235
|
+
radius: number,
|
|
236
|
+
): GuideLine[] {
|
|
237
|
+
const lines: GuideLine[] = []
|
|
238
|
+
for (const axis of [0, 1] as const) {
|
|
239
|
+
// axis 0: vertical lines (constant X); axis 1: horizontal lines (constant Z).
|
|
240
|
+
const across = axis === 0 ? 1 : 0
|
|
241
|
+
const byCoord = new Map<string, { point: WallPlanPoint; runStart: boolean }>()
|
|
242
|
+
for (const anchor of anchors) {
|
|
243
|
+
if (Math.abs(anchor.point[axis] - cursor[axis]) > radius) continue
|
|
244
|
+
const key = `${anchor.runStart ? 's' : 'a'}:${anchor.point[axis].toFixed(4)}`
|
|
245
|
+
const held = byCoord.get(key)
|
|
246
|
+
if (
|
|
247
|
+
!held ||
|
|
248
|
+
Math.abs(anchor.point[across] - cursor[across]) <
|
|
249
|
+
Math.abs(held.point[across] - cursor[across])
|
|
250
|
+
) {
|
|
251
|
+
byCoord.set(key, anchor)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
for (const anchor of byCoord.values()) {
|
|
255
|
+
lines.push({
|
|
256
|
+
kind: anchor.runStart ? 'run-start' : 'alignment',
|
|
257
|
+
anchor: anchor.point,
|
|
258
|
+
direction: axis === 0 ? [0, 1] : [1, 0],
|
|
259
|
+
})
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return lines
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Where two lines cross, or null when they run parallel. */
|
|
266
|
+
function crossLines(a: GuideLine, b: GuideLine): WallPlanPoint | null {
|
|
267
|
+
const denom = a.direction[0] * b.direction[1] - a.direction[1] * b.direction[0]
|
|
268
|
+
if (Math.abs(denom) < 1e-9) return null
|
|
269
|
+
const dx = b.anchor[0] - a.anchor[0]
|
|
270
|
+
const dz = b.anchor[1] - a.anchor[1]
|
|
271
|
+
const t = (dx * b.direction[1] - dz * b.direction[0]) / denom
|
|
272
|
+
return [a.anchor[0] + a.direction[0] * t, a.anchor[1] + a.direction[1] * t]
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Station along the ray `start + direction · t` where it crosses `line`, or null. */
|
|
276
|
+
function rayStationOnLine(
|
|
277
|
+
start: WallPlanPoint,
|
|
278
|
+
direction: WallPlanPoint,
|
|
279
|
+
line: GuideLine,
|
|
280
|
+
): number | null {
|
|
281
|
+
const denom = direction[0] * line.direction[1] - direction[1] * line.direction[0]
|
|
282
|
+
if (Math.abs(denom) < 1e-9) return null
|
|
283
|
+
const dx = line.anchor[0] - start[0]
|
|
284
|
+
const dz = line.anchor[1] - start[1]
|
|
285
|
+
const t = (dx * line.direction[1] - dz * line.direction[0]) / denom
|
|
286
|
+
return t > 1e-6 ? t : null
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function passesThrough(line: GuideLine, point: WallPlanPoint): boolean {
|
|
290
|
+
const dx = point[0] - line.anchor[0]
|
|
291
|
+
const dz = point[1] - line.anchor[1]
|
|
292
|
+
return Math.abs(dx * line.direction[1] - dz * line.direction[0]) <= 1e-4
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** A guide drawn from its anchor through `to` and a little past it. */
|
|
296
|
+
function guideThrough(line: GuideLine, to: WallPlanPoint): WallPullGuide {
|
|
297
|
+
const dx = to[0] - line.anchor[0]
|
|
298
|
+
const dz = to[1] - line.anchor[1]
|
|
299
|
+
const along = dx * line.direction[0] + dz * line.direction[1]
|
|
300
|
+
const sign = along >= 0 ? 1 : -1
|
|
301
|
+
return {
|
|
302
|
+
kind: line.kind,
|
|
303
|
+
from: [line.anchor[0], line.anchor[1]],
|
|
304
|
+
to: [
|
|
305
|
+
to[0] + line.direction[0] * sign * GUIDE_OVERSHOOT,
|
|
306
|
+
to[1] + line.direction[1] * sign * GUIDE_OVERSHOOT,
|
|
307
|
+
],
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function extensionGuide(
|
|
312
|
+
start: WallPlanPoint,
|
|
313
|
+
reference: WallPlanPoint,
|
|
314
|
+
cursor: WallPlanPoint,
|
|
315
|
+
): WallPullGuide {
|
|
316
|
+
const along = (cursor[0] - start[0]) * reference[0] + (cursor[1] - start[1]) * reference[1]
|
|
317
|
+
const length = Math.max(EXTENSION_MIN_LENGTH, along + EXTENSION_OVERSHOOT)
|
|
318
|
+
return {
|
|
319
|
+
kind: 'extension',
|
|
320
|
+
from: [start[0], start[1]],
|
|
321
|
+
to: [start[0] + reference[0] * length, start[1] + reference[1] * length],
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
type WallLine = { wall: WallNode; ux: number; uz: number; length: number }
|
|
326
|
+
|
|
327
|
+
function straightWallLines(walls: readonly WallNode[]): WallLine[] {
|
|
328
|
+
return walls.flatMap((wall) => {
|
|
329
|
+
if (isCurvedWall(wall)) return []
|
|
330
|
+
const dx = wall.end[0] - wall.start[0]
|
|
331
|
+
const dz = wall.end[1] - wall.start[1]
|
|
332
|
+
const length = Math.hypot(dx, dz)
|
|
333
|
+
return length > 1e-9 ? [{ wall, ux: dx / length, uz: dz / length, length }] : []
|
|
334
|
+
})
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** True when `segment` meets `wall` square (within 1°). */
|
|
338
|
+
function meetsSquare(segment: WallPlanPoint, line: WallLine): boolean {
|
|
339
|
+
const along = unit(segment)
|
|
340
|
+
if (!along) return false
|
|
341
|
+
return Math.abs(along[0] * line.ux + along[1] * line.uz) <= PERPENDICULAR_SINE
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Snap the end of a pulled wall (or of a wall continuing the run).
|
|
346
|
+
*
|
|
347
|
+
* The direction holds straight on from the wall it comes from, square to it, or
|
|
348
|
+
* along a house axis within about 7°, and on 15° steps past that; the length
|
|
349
|
+
* steps by `gridStep` along it. Existing walls win over the held direction so a
|
|
350
|
+
* loop closes exactly: a wall end (the run's start among them) first, then a
|
|
351
|
+
* wall's middle or a crossing of two walls, then a crossing of two guides; then
|
|
352
|
+
* a wall the held direction runs into (or the square foot on a wall near the
|
|
353
|
+
* cursor); then a guide the held direction runs into, so the run lines up with
|
|
354
|
+
* its start or another wall's end; then the grid length. `free` (Alt, or
|
|
355
|
+
* snapping off) leaves the end at the cursor.
|
|
356
|
+
*/
|
|
357
|
+
export function snapWallPullPoint(input: WallPullSnapInput): WallPullSnapResult {
|
|
358
|
+
const { point: cursor, start, runStart, walls, gridStep } = input
|
|
359
|
+
const radii = { ...WALL_PULL_SNAP_RADII, ...input.radii }
|
|
360
|
+
const reference = input.reference ? unit(input.reference) : null
|
|
361
|
+
const extension = reference ? extensionGuide(start, reference, cursor) : null
|
|
362
|
+
const extensionLine: GuideLine | null = reference
|
|
363
|
+
? { kind: 'extension', anchor: [start[0], start[1]], direction: reference }
|
|
364
|
+
: null
|
|
365
|
+
const baseGuides = extension ? [extension] : []
|
|
366
|
+
const result = (
|
|
367
|
+
point: WallPlanPoint,
|
|
368
|
+
snap: WallPullSnapKind | null,
|
|
369
|
+
lock: WallPullDirectionLock,
|
|
370
|
+
extra: Partial<Pick<WallPullSnapResult, 'label' | 'targetWallIds' | 'guides'>> = {},
|
|
371
|
+
intersections: WallPlanPoint[] = [],
|
|
372
|
+
): WallPullSnapResult => ({
|
|
373
|
+
point: [point[0], point[1]],
|
|
374
|
+
snap,
|
|
375
|
+
label: extra.label ?? (snap ? WALL_PULL_SNAP_LABELS[snap] : null),
|
|
376
|
+
lock,
|
|
377
|
+
targetWallIds: extra.targetWallIds ?? [],
|
|
378
|
+
guides: [...baseGuides, ...(extra.guides ?? [])],
|
|
379
|
+
intersections,
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
const vector: WallPlanPoint = [cursor[0] - start[0], cursor[1] - start[1]]
|
|
383
|
+
if (input.free) return result(cursor, null, 'free')
|
|
384
|
+
if (Math.hypot(vector[0], vector[1]) < 1e-6) return result(start, null, 'free')
|
|
385
|
+
|
|
386
|
+
// The held direction, and the length along it.
|
|
387
|
+
const { direction, lock } = lockWallPullDirection(vector, reference)
|
|
388
|
+
const along = vector[0] * direction[0] + vector[1] * direction[1]
|
|
389
|
+
const length = gridStep > 0 ? Math.round(along / gridStep) * gridStep : along
|
|
390
|
+
const held: WallPlanPoint = [start[0] + direction[0] * length, start[1] + direction[1] * length]
|
|
391
|
+
|
|
392
|
+
const anchors = guideAnchors(walls, runStart, start)
|
|
393
|
+
const lines = nearbyGuideLines(anchors, cursor, radii.guide)
|
|
394
|
+
const allLines = extensionLine ? [extensionLine, ...lines] : lines
|
|
395
|
+
// Guide crossings near the cursor, each with the two lines through it.
|
|
396
|
+
const crossings: Array<{ point: WallPlanPoint; a: GuideLine; b: GuideLine }> = []
|
|
397
|
+
for (let i = 0; i < allLines.length; i += 1) {
|
|
398
|
+
for (let j = i + 1; j < allLines.length; j += 1) {
|
|
399
|
+
const a = allLines[i]!
|
|
400
|
+
const b = allLines[j]!
|
|
401
|
+
const point = crossLines(a, b)
|
|
402
|
+
if (!point || samePoint(point, start, 1e-4)) continue
|
|
403
|
+
if (distanceSquared(point, cursor) > INTERSECTION_SHOW_RADIUS ** 2) continue
|
|
404
|
+
if (crossings.some((crossing) => samePoint(crossing.point, point, 1e-4))) continue
|
|
405
|
+
crossings.push({ point, a, b })
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
const intersections = crossings.map((crossing) => crossing.point)
|
|
409
|
+
// The guides the final point sits on, then those through each crossing
|
|
410
|
+
// shown, drawn from their anchors — each line once.
|
|
411
|
+
const guidesThrough = (point: WallPlanPoint) => {
|
|
412
|
+
const drawn = new Set<GuideLine>()
|
|
413
|
+
const guides: WallPullGuide[] = []
|
|
414
|
+
for (const line of lines) {
|
|
415
|
+
if (!passesThrough(line, point) || samePoint(line.anchor, point, 1e-4)) continue
|
|
416
|
+
drawn.add(line)
|
|
417
|
+
guides.push(guideThrough(line, point))
|
|
418
|
+
}
|
|
419
|
+
for (const crossing of crossings) {
|
|
420
|
+
for (const line of [crossing.a, crossing.b]) {
|
|
421
|
+
if (line.kind === 'extension' || drawn.has(line)) continue
|
|
422
|
+
drawn.add(line)
|
|
423
|
+
guides.push(guideThrough(line, crossing.point))
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return guides
|
|
427
|
+
}
|
|
428
|
+
const settle = (
|
|
429
|
+
point: WallPlanPoint,
|
|
430
|
+
snap: WallPullSnapKind,
|
|
431
|
+
pointLock: WallPullDirectionLock,
|
|
432
|
+
extra: Partial<Pick<WallPullSnapResult, 'label' | 'targetWallIds' | 'guides'>> = {},
|
|
433
|
+
) =>
|
|
434
|
+
result(
|
|
435
|
+
point,
|
|
436
|
+
snap,
|
|
437
|
+
pointLock,
|
|
438
|
+
{ ...extra, guides: extra.guides ?? guidesThrough(point) },
|
|
439
|
+
intersections,
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
// 1. A wall end, the run's start among them.
|
|
443
|
+
let endpoint: WallPlanPoint | null = null
|
|
444
|
+
let endpointDistance = radii.endpoint ** 2
|
|
445
|
+
for (const anchor of anchors) {
|
|
446
|
+
const distance = distanceSquared(anchor.point, cursor)
|
|
447
|
+
if (distance <= endpointDistance) {
|
|
448
|
+
endpoint = anchor.point
|
|
449
|
+
endpointDistance = distance
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
if (endpoint) {
|
|
453
|
+
return settle(endpoint, 'endpoint', 'free', {
|
|
454
|
+
targetWallIds: wallIdsAtSnapPoint(endpoint, [...walls]),
|
|
455
|
+
})
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// 2. A wall's middle, or where two walls cross.
|
|
459
|
+
const special = findWallSpecialPointSnap(cursor, [...walls], undefined, {
|
|
460
|
+
endpoint: 0,
|
|
461
|
+
midpoint: radii.special,
|
|
462
|
+
intersection: radii.special,
|
|
463
|
+
})
|
|
464
|
+
if (special && special.snap !== 'endpoint' && !samePoint(special.point, start, 1e-4)) {
|
|
465
|
+
return settle(special.point, special.snap ?? 'midpoint', 'free', {
|
|
466
|
+
targetWallIds: special.targetWallIds,
|
|
467
|
+
})
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// 3. Where two guides cross.
|
|
471
|
+
let crossing: (typeof crossings)[number] | null = null
|
|
472
|
+
let crossingDistance = radii.intersection ** 2
|
|
473
|
+
for (const candidate of crossings) {
|
|
474
|
+
const distance = distanceSquared(candidate.point, cursor)
|
|
475
|
+
if (distance <= crossingDistance) {
|
|
476
|
+
crossing = candidate
|
|
477
|
+
crossingDistance = distance
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
if (crossing) return settle(crossing.point, 'intersection', 'free')
|
|
481
|
+
|
|
482
|
+
// 4. A wall the held direction runs into: its body, its line near the held
|
|
483
|
+
// length, or the square foot on a wall line near the cursor.
|
|
484
|
+
const wallLines = straightWallLines(walls)
|
|
485
|
+
const onWall = (point: WallPlanPoint, pointLock: WallPullDirectionLock) => {
|
|
486
|
+
const targetWallIds = wallIdsAtSnapPoint(point, [...walls])
|
|
487
|
+
const atEnd = walls.some(
|
|
488
|
+
(wall) => samePoint(point, wall.start, 1e-6) || samePoint(point, wall.end, 1e-6),
|
|
489
|
+
)
|
|
490
|
+
const segment: WallPlanPoint = [point[0] - start[0], point[1] - start[1]]
|
|
491
|
+
const square = wallLines.some(
|
|
492
|
+
(line) => targetWallIds.includes(line.wall.id) && meetsSquare(segment, line),
|
|
493
|
+
)
|
|
494
|
+
return settle(point, atEnd ? 'endpoint' : square ? 'perpendicular' : 'wall', pointLock, {
|
|
495
|
+
targetWallIds,
|
|
496
|
+
})
|
|
497
|
+
}
|
|
498
|
+
if (length > ON_LINE) {
|
|
499
|
+
const body = findWallBodySnapTarget(cursor, [...walls], {
|
|
500
|
+
placed: held,
|
|
501
|
+
start,
|
|
502
|
+
margin: WALL_CONNECT_SNAP_RADIUS,
|
|
503
|
+
})
|
|
504
|
+
if (body && !samePoint(body, start, 1e-4)) return onWall(body, lock)
|
|
505
|
+
}
|
|
506
|
+
let rayHit: WallPlanPoint | null = null
|
|
507
|
+
let rayHitGap = radii.wall
|
|
508
|
+
for (const line of wallLines) {
|
|
509
|
+
const denom = direction[0] * line.uz - direction[1] * line.ux
|
|
510
|
+
if (Math.abs(denom) < 1e-9) continue
|
|
511
|
+
const dx = line.wall.start[0] - start[0]
|
|
512
|
+
const dz = line.wall.start[1] - start[1]
|
|
513
|
+
const t = (dx * line.uz - dz * line.ux) / denom
|
|
514
|
+
const s = (dx * direction[1] - dz * direction[0]) / denom
|
|
515
|
+
if (!(t > 1e-4) || s <= 1e-6 || s >= line.length - 1e-6) continue
|
|
516
|
+
const gap = Math.abs(t - along)
|
|
517
|
+
if (gap <= rayHitGap) {
|
|
518
|
+
rayHit = [start[0] + direction[0] * t, start[1] + direction[1] * t]
|
|
519
|
+
rayHitGap = gap
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
if (rayHit) return onWall(rayHit, lock)
|
|
523
|
+
let foot: WallPlanPoint | null = null
|
|
524
|
+
let footDistance = radii.wall ** 2
|
|
525
|
+
for (const line of wallLines) {
|
|
526
|
+
const station =
|
|
527
|
+
(start[0] - line.wall.start[0]) * line.ux + (start[1] - line.wall.start[1]) * line.uz
|
|
528
|
+
if (station <= 1e-6 || station >= line.length - 1e-6) continue
|
|
529
|
+
const point: WallPlanPoint = [
|
|
530
|
+
line.wall.start[0] + line.ux * station,
|
|
531
|
+
line.wall.start[1] + line.uz * station,
|
|
532
|
+
]
|
|
533
|
+
if (samePoint(point, start, 1e-4)) continue
|
|
534
|
+
const distance = distanceSquared(point, cursor)
|
|
535
|
+
if (distance <= footDistance) {
|
|
536
|
+
foot = point
|
|
537
|
+
footDistance = distance
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
if (foot) return onWall(foot, 'free')
|
|
541
|
+
|
|
542
|
+
// 5. A guide the held direction runs into.
|
|
543
|
+
let aligned: { point: WallPlanPoint; line: GuideLine } | null = null
|
|
544
|
+
let alignedGap = radii.alignment
|
|
545
|
+
for (const line of lines) {
|
|
546
|
+
const t = rayStationOnLine(start, direction, line)
|
|
547
|
+
if (t === null) continue
|
|
548
|
+
const gap = Math.abs(t - along)
|
|
549
|
+
if (gap <= alignedGap) {
|
|
550
|
+
aligned = { point: [start[0] + direction[0] * t, start[1] + direction[1] * t], line }
|
|
551
|
+
alignedGap = gap
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
if (aligned) {
|
|
555
|
+
return settle(aligned.point, 'alignment', lock, {
|
|
556
|
+
label: aligned.line.kind === 'run-start' ? 'In line with start' : undefined,
|
|
557
|
+
})
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// 6. The grid length along the held direction.
|
|
561
|
+
if (gridStep > 0) return settle(held, 'grid', lock)
|
|
562
|
+
return result(held, null, lock, { guides: guidesThrough(held) }, intersections)
|
|
563
|
+
}
|
|
@@ -2,12 +2,18 @@ import { describe, expect, test } from 'bun:test'
|
|
|
2
2
|
import type { WallNode } from '@pascal-app/core'
|
|
3
3
|
import {
|
|
4
4
|
chainEndJoinsExistingWall,
|
|
5
|
+
findWallBodySnapTarget,
|
|
5
6
|
findWallSnapTarget,
|
|
6
7
|
findWallSpecialPointSnap,
|
|
7
8
|
type WallPlanPoint,
|
|
8
9
|
} from './wall-snap-geometry'
|
|
9
10
|
|
|
10
|
-
function makeWall(
|
|
11
|
+
function makeWall(
|
|
12
|
+
start: WallPlanPoint,
|
|
13
|
+
end: WallPlanPoint,
|
|
14
|
+
id?: string,
|
|
15
|
+
overrides: Partial<WallNode> = {},
|
|
16
|
+
): WallNode {
|
|
11
17
|
return {
|
|
12
18
|
object: 'node',
|
|
13
19
|
id: (id ?? `wall_${start.join('_')}_${end.join('_')}`) as WallNode['id'],
|
|
@@ -22,6 +28,7 @@ function makeWall(start: WallPlanPoint, end: WallPlanPoint, id?: string): WallNo
|
|
|
22
28
|
thickness: 0.1,
|
|
23
29
|
frontSide: 'unknown',
|
|
24
30
|
backSide: 'unknown',
|
|
31
|
+
...overrides,
|
|
25
32
|
} as unknown as WallNode
|
|
26
33
|
}
|
|
27
34
|
|
|
@@ -129,3 +136,108 @@ describe('findWallSnapTarget (edge / along-wall)', () => {
|
|
|
129
136
|
expect(findWallSnapTarget([1.2, 0.1], walls, { radius: 0.08 })).toBeNull()
|
|
130
137
|
})
|
|
131
138
|
})
|
|
139
|
+
|
|
140
|
+
describe('findWallBodySnapTarget (a wall end aimed into a wall)', () => {
|
|
141
|
+
// An 8" wall centred on z = 0: its faces sit at z = ±0.1016.
|
|
142
|
+
const host = makeWall([0, 0], [4, 0], 'host', { thickness: 0.2032 })
|
|
143
|
+
|
|
144
|
+
test('an end inside the body or within the margin of a face lands on the reference line', () => {
|
|
145
|
+
expect(findWallBodySnapTarget([1.2, 0.05], [host])).toEqual([1.2, 0])
|
|
146
|
+
expect(findWallBodySnapTarget([1.2, 0.14], [host])).toEqual([1.2, 0])
|
|
147
|
+
expect(findWallBodySnapTarget([1.2, -0.14], [host])).toEqual([1.2, 0])
|
|
148
|
+
expect(findWallBodySnapTarget([1.2, 0.16], [host])).toBeNull()
|
|
149
|
+
// Past the wall's ends the corner snap decides, not the body.
|
|
150
|
+
expect(findWallBodySnapTarget([4.05, 0.05], [host])).toBeNull()
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
test('the grid placement keeps its station along the wall, and captures on its own', () => {
|
|
154
|
+
// The cursor sits by the face; its lattice point is out of the band.
|
|
155
|
+
expect(findWallBodySnapTarget([1.23, 0.12], [host], { placed: [1.25, 0.25] })).toEqual([
|
|
156
|
+
1.25, 0,
|
|
157
|
+
])
|
|
158
|
+
// The cursor is out of the band; the lattice point fell into it.
|
|
159
|
+
expect(findWallBodySnapTarget([1.23, 0.3], [host], { placed: [1.25, 0.1] })).toEqual([1.25, 0])
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
test('with a start, the end slides along the drafted segment onto the line', () => {
|
|
163
|
+
const start: WallPlanPoint = [1, 3]
|
|
164
|
+
const placed: WallPlanPoint = [1.3, 0.12]
|
|
165
|
+
const target = findWallBodySnapTarget(placed, [host], { start, placed })
|
|
166
|
+
expect(target?.[1]).toBeCloseTo(0, 12)
|
|
167
|
+
// Still on the drafted line start → placed: the segment keeps its direction.
|
|
168
|
+
const cross =
|
|
169
|
+
(placed[0] - start[0]) * (target![1] - start[1]) -
|
|
170
|
+
(placed[1] - start[1]) * (target![0] - start[0])
|
|
171
|
+
expect(cross).toBeCloseTo(0, 12)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
test('a square segment stays square', () => {
|
|
175
|
+
const target = findWallBodySnapTarget([2, 0.13], [host], { start: [2, 3] })
|
|
176
|
+
expect(target?.[0]).toBe(2)
|
|
177
|
+
expect(target?.[1]).toBeCloseTo(0, 12)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
test('walls the segment runs alongside, or starts on, are left alone', () => {
|
|
181
|
+
expect(findWallBodySnapTarget([2, 0.06], [host], { start: [0.5, 0.05] })).toBeNull()
|
|
182
|
+
expect(findWallBodySnapTarget([1.1, 0.12], [host], { start: [1, 0] })).toBeNull()
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
test('a justified wall captures across its whole body, measured from its faces', () => {
|
|
186
|
+
// Justified to face a: the body runs from the line (z = 0) to z = 0.2032.
|
|
187
|
+
const justified = makeWall([0, 0], [4, 0], 'justified', {
|
|
188
|
+
thickness: 0.2032,
|
|
189
|
+
justification: 'a',
|
|
190
|
+
})
|
|
191
|
+
expect(findWallBodySnapTarget([1.2, 0.24], [justified])).toEqual([1.2, 0])
|
|
192
|
+
expect(findWallBodySnapTarget([1.2, -0.04], [justified])).toEqual([1.2, 0])
|
|
193
|
+
expect(findWallBodySnapTarget([1.2, -0.08], [justified])).toBeNull()
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
test('an end already on the line stays put', () => {
|
|
197
|
+
const onLine: WallPlanPoint = [1.234, 0]
|
|
198
|
+
expect(findWallBodySnapTarget(onLine, [host], { start: [1.5, 3], placed: [1.5, 0.5] })).toEqual(
|
|
199
|
+
onLine,
|
|
200
|
+
)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
test('picks the wall whose body the end is closest to', () => {
|
|
204
|
+
// 0.05 off `near`'s face, 0.018 off the host's.
|
|
205
|
+
const near = makeWall([0, 0.22], [4, 0.22], 'near', { thickness: 0.1 })
|
|
206
|
+
expect(findWallBodySnapTarget([1, 0.12], [near, host])).toEqual([1, 0])
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
test('an end in the corner block of an L joins the corner, not an 8 cm stub', () => {
|
|
210
|
+
// Two 0.2 m walls meeting at the origin: the corner block is the 0.2 m
|
|
211
|
+
// square around it.
|
|
212
|
+
const l = [
|
|
213
|
+
makeWall([0, 0], [4, 0], 'south', { thickness: 0.2 }),
|
|
214
|
+
makeWall([0, 4], [0, 0], 'west', { thickness: 0.2 }),
|
|
215
|
+
]
|
|
216
|
+
expect(findWallBodySnapTarget([0.08, 0.08], l)).toEqual([0, 0])
|
|
217
|
+
// The block's outer quadrant lies in one wall's body only, and joins too.
|
|
218
|
+
expect(findWallBodySnapTarget([0.05, -0.05], l)).toEqual([0, 0])
|
|
219
|
+
// Aimed into the block from across the room.
|
|
220
|
+
expect(findWallBodySnapTarget([0.08, 0.08], l, { start: [2, 3] })).toEqual([0, 0])
|
|
221
|
+
// Past the other wall's face, the end T-joins as before.
|
|
222
|
+
expect(findWallBodySnapTarget([0.3, 0.05], l)).toEqual([0.3, 0])
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
test('an end in the corner block of a T joins the junction', () => {
|
|
226
|
+
// The host split at the junction, as the topology leaves a T-join.
|
|
227
|
+
const t = [
|
|
228
|
+
makeWall([0, 0], [2, 0], 'left', { thickness: 0.2 }),
|
|
229
|
+
makeWall([2, 0], [4, 0], 'right', { thickness: 0.2 }),
|
|
230
|
+
makeWall([2, 0], [2, 4], 'stem', { thickness: 0.2 }),
|
|
231
|
+
]
|
|
232
|
+
expect(findWallBodySnapTarget([2.08, 0.08], t)).toEqual([2, 0])
|
|
233
|
+
expect(findWallBodySnapTarget([1.92, 0.06], t)).toEqual([2, 0])
|
|
234
|
+
// A host left whole, the stem ending on its interior, from either wall's side.
|
|
235
|
+
const whole = makeWall([0, 0], [4, 0], 'whole', { thickness: 0.2 })
|
|
236
|
+
const stem = makeWall([2, 0], [2, 4], 'stem', { thickness: 0.2 })
|
|
237
|
+
expect(findWallBodySnapTarget([2.08, 0.08], [whole, stem])).toEqual([2, 0])
|
|
238
|
+
expect(findWallBodySnapTarget([2.08, 0.08], [stem, whole])).toEqual([2, 0])
|
|
239
|
+
// Clear of the corner block each wall T-joins as before.
|
|
240
|
+
expect(findWallBodySnapTarget([2.4, 0.08], [whole, stem])).toEqual([2.4, 0])
|
|
241
|
+
expect(findWallBodySnapTarget([2.06, 0.3], [whole, stem])).toEqual([2, 0.3])
|
|
242
|
+
})
|
|
243
|
+
})
|