@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,431 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
type AnyNode,
|
|
4
|
+
type AnyNodeId,
|
|
5
|
+
area,
|
|
6
|
+
BuildingNode,
|
|
7
|
+
clearSceneHistory,
|
|
8
|
+
createZone,
|
|
9
|
+
generateId,
|
|
10
|
+
initSpaceDetectionSync,
|
|
11
|
+
LevelNode,
|
|
12
|
+
nodeRegistry,
|
|
13
|
+
planSeparatorInsertion,
|
|
14
|
+
railingWallDefaults,
|
|
15
|
+
type SeparatorNode,
|
|
16
|
+
structureChangeBatch,
|
|
17
|
+
useLiveNodeOverrides,
|
|
18
|
+
useScene,
|
|
19
|
+
WallNode,
|
|
20
|
+
type ZoneNode,
|
|
21
|
+
} from '@pascal-app/core'
|
|
22
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
23
|
+
import { getRoomSelectionIndex } from '../hooks/use-selected-room'
|
|
24
|
+
import useEditor from '../store/use-editor'
|
|
25
|
+
import useInteractionScope from '../store/use-interaction-scope'
|
|
26
|
+
import { installImmediateAnimationFrames } from '../test-utils/immediate-animation-frames'
|
|
27
|
+
import { cancelGestures } from './gesture-lifecycle'
|
|
28
|
+
import { endpointReshapeScope } from './interaction/scope'
|
|
29
|
+
import {
|
|
30
|
+
roomPushHandles,
|
|
31
|
+
runFloorplanRoomPush,
|
|
32
|
+
runFloorplanWallPush,
|
|
33
|
+
separatorPushHandles,
|
|
34
|
+
useRoomHandleDrag,
|
|
35
|
+
type WallPushHandle,
|
|
36
|
+
} from './room-handle-drag'
|
|
37
|
+
import { applyRoomPlan } from './room-structure-commands'
|
|
38
|
+
import {
|
|
39
|
+
beginSeparatorEndDrag,
|
|
40
|
+
runSeparatorEndDrag,
|
|
41
|
+
SEPARATOR_END_DRAG_LABEL,
|
|
42
|
+
} from './separator-edit'
|
|
43
|
+
|
|
44
|
+
// A porch off an 8 × 4 house, closed by invisible walls, pushed and dragged
|
|
45
|
+
// the way the plan and the 3D view do it. The pointer is faked: plan points
|
|
46
|
+
// come straight from the client point (x → plan x, y → plan z).
|
|
47
|
+
|
|
48
|
+
type Point = [number, number]
|
|
49
|
+
const LEVEL = 'level_separator_edit'
|
|
50
|
+
const stubbed: string[] = []
|
|
51
|
+
let stop = () => {}
|
|
52
|
+
let restoreFrames = () => {}
|
|
53
|
+
let registry: { mockRestore: () => void } | null = null
|
|
54
|
+
const toPlan = (clientX: number, clientY: number): [number, number] => [clientX, clientY]
|
|
55
|
+
|
|
56
|
+
function pointer(type: 'pointermove' | 'pointerup', x: number, y: number, altKey = false) {
|
|
57
|
+
window.dispatchEvent(Object.assign(new Event(type), { clientX: x, clientY: y, altKey }))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const near = (a: Point, b: Point) => Math.hypot(a[0] - b[0], a[1] - b[1]) < 1e-6
|
|
61
|
+
const separators = () =>
|
|
62
|
+
Object.values(useScene.getState().nodes).filter(
|
|
63
|
+
(node): node is SeparatorNode => node.type === 'separator',
|
|
64
|
+
)
|
|
65
|
+
/** The invisible wall from `a` to `b`, either way round. */
|
|
66
|
+
const between = (a: Point, b: Point) =>
|
|
67
|
+
separators().find(
|
|
68
|
+
(node) =>
|
|
69
|
+
(near(node.start, a) && near(node.end, b)) || (near(node.start, b) && near(node.end, a)),
|
|
70
|
+
)
|
|
71
|
+
const porchZone = () =>
|
|
72
|
+
Object.values(useScene.getState().nodes).find(
|
|
73
|
+
(node): node is ZoneNode =>
|
|
74
|
+
node.type === 'zone' && (node.roomType === 'porch' || node.roomType === 'deck'),
|
|
75
|
+
)
|
|
76
|
+
const porchArea = () => {
|
|
77
|
+
const zone = porchZone()
|
|
78
|
+
return zone ? area([{ outer: zone.polygon, holes: zone.holes }]) : 0
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Draws the porch's sides — invisible walls, or railings where `railed` — through the store. */
|
|
82
|
+
function drawPorch(
|
|
83
|
+
sides: Array<[Point, Point]> = [
|
|
84
|
+
[
|
|
85
|
+
[2, 4],
|
|
86
|
+
[2, 6],
|
|
87
|
+
],
|
|
88
|
+
[
|
|
89
|
+
[2, 6],
|
|
90
|
+
[6, 6],
|
|
91
|
+
],
|
|
92
|
+
[
|
|
93
|
+
[6, 6],
|
|
94
|
+
[6, 4],
|
|
95
|
+
],
|
|
96
|
+
],
|
|
97
|
+
railed = false,
|
|
98
|
+
) {
|
|
99
|
+
for (const [start, end] of sides) {
|
|
100
|
+
const nodes = useScene.getState().nodes
|
|
101
|
+
if (railed) {
|
|
102
|
+
const railing = WallNode.parse({
|
|
103
|
+
...railingWallDefaults(),
|
|
104
|
+
id: generateId('wall'),
|
|
105
|
+
parentId: LEVEL,
|
|
106
|
+
start,
|
|
107
|
+
end,
|
|
108
|
+
})
|
|
109
|
+
useScene.getState().applyNodeChanges(structureChangeBatch([{ op: 'create', node: railing }]))
|
|
110
|
+
continue
|
|
111
|
+
}
|
|
112
|
+
const plan = planSeparatorInsertion(nodes as Record<AnyNodeId, AnyNode>, {
|
|
113
|
+
levelId: LEVEL as AnyNodeId,
|
|
114
|
+
start,
|
|
115
|
+
end,
|
|
116
|
+
joinRadius: 0.05,
|
|
117
|
+
mintId: () => generateId('separator'),
|
|
118
|
+
})
|
|
119
|
+
useScene.getState().applyNodeChanges(plan!.changes)
|
|
120
|
+
}
|
|
121
|
+
clearSceneHistory()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function porchHandles(): WallPushHandle[] {
|
|
125
|
+
const zone = porchZone()!
|
|
126
|
+
const record = getRoomSelectionIndex(LEVEL)
|
|
127
|
+
.update(useScene.getState().nodes)
|
|
128
|
+
.find((room) => room.zoneId === zone.id)!
|
|
129
|
+
return roomPushHandles(useScene.getState().nodes, record.spans)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The porch arrow pointing along `direction`. */
|
|
133
|
+
const arrow = (handles: WallPushHandle[], direction: Point) =>
|
|
134
|
+
handles.find(
|
|
135
|
+
(handle) =>
|
|
136
|
+
handle.outward[0] * direction[0] + handle.outward[1] * direction[1] > 0.99 &&
|
|
137
|
+
!!handle.separators,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
beforeEach(() => {
|
|
141
|
+
restoreFrames = installImmediateAnimationFrames()
|
|
142
|
+
if (!globalThis.document) {
|
|
143
|
+
globalThis.document = { body: { style: { cursor: '' } } } as unknown as Document
|
|
144
|
+
stubbed.push('document')
|
|
145
|
+
}
|
|
146
|
+
if (!globalThis.window) {
|
|
147
|
+
globalThis.window = new EventTarget() as Window & typeof globalThis
|
|
148
|
+
stubbed.push('window')
|
|
149
|
+
}
|
|
150
|
+
// The nodes package that registers walls and invisible walls is not loaded
|
|
151
|
+
// here: answer their snap context lookup as their definitions do.
|
|
152
|
+
const get = nodeRegistry.get.bind(nodeRegistry)
|
|
153
|
+
registry = spyOn(nodeRegistry, 'get').mockImplementation(((kind: string) =>
|
|
154
|
+
kind === 'wall' || kind === 'separator'
|
|
155
|
+
? { snapProfile: 'structural' }
|
|
156
|
+
: get(kind)) as typeof nodeRegistry.get)
|
|
157
|
+
cancelGestures('cancel')
|
|
158
|
+
useInteractionScope.getState().end()
|
|
159
|
+
const building = BuildingNode.parse({ id: 'building_separator_edit', children: [LEVEL] })
|
|
160
|
+
const level = LevelNode.parse({ id: LEVEL, parentId: building.id })
|
|
161
|
+
useScene.setState({
|
|
162
|
+
nodes: { [building.id]: building, [level.id]: level },
|
|
163
|
+
rootNodeIds: [building.id],
|
|
164
|
+
dirtyNodes: new Set(),
|
|
165
|
+
materials: {},
|
|
166
|
+
collections: {},
|
|
167
|
+
readOnly: false,
|
|
168
|
+
})
|
|
169
|
+
useScene.temporal.getState().resume()
|
|
170
|
+
stop = initSpaceDetectionSync(useScene, { getState: () => ({ spaces: {}, setSpaces: () => {} }) })
|
|
171
|
+
applyRoomPlan(
|
|
172
|
+
createZone(useScene.getState().nodes, {
|
|
173
|
+
levelId: LEVEL,
|
|
174
|
+
polygon: [
|
|
175
|
+
[0, 0],
|
|
176
|
+
[8, 0],
|
|
177
|
+
[8, 4],
|
|
178
|
+
[0, 4],
|
|
179
|
+
],
|
|
180
|
+
enclose: true,
|
|
181
|
+
mintId: generateId,
|
|
182
|
+
}),
|
|
183
|
+
)
|
|
184
|
+
useViewer.getState().setSelection({ buildingId: building.id, levelId: LEVEL, selectedIds: [] })
|
|
185
|
+
useEditor.setState({ phase: 'building', mode: 'select', gridSnapStep: 0.5 })
|
|
186
|
+
useEditor.getState().setSnappingMode('polygon', 'grid')
|
|
187
|
+
useEditor.getState().setSnappingMode('wall', 'grid')
|
|
188
|
+
clearSceneHistory()
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
afterEach(() => {
|
|
192
|
+
cancelGestures('cancel')
|
|
193
|
+
stop()
|
|
194
|
+
registry?.mockRestore()
|
|
195
|
+
registry = null
|
|
196
|
+
restoreFrames()
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
afterAll(async () => {
|
|
200
|
+
// Let the click-swallow timers fire before the stubs go.
|
|
201
|
+
await new Promise((resolve) => setTimeout(resolve, 350))
|
|
202
|
+
for (const key of stubbed.splice(0)) delete (globalThis as Record<string, unknown>)[key]
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
describe('a porch closed by invisible walls, selected', () => {
|
|
206
|
+
test('has a push arrow on each invisible-wall edge, pointing off the porch, beside the house wall’s', () => {
|
|
207
|
+
drawPorch()
|
|
208
|
+
expect(porchZone()?.roomType).toBe('porch')
|
|
209
|
+
const handles = porchHandles()
|
|
210
|
+
for (const direction of [
|
|
211
|
+
[-1, 0],
|
|
212
|
+
[0, 1],
|
|
213
|
+
[1, 0],
|
|
214
|
+
] as Point[])
|
|
215
|
+
expect(arrow(handles, direction)?.separators).toHaveLength(1)
|
|
216
|
+
// The house wall's arrow is the wall push it always was.
|
|
217
|
+
expect(handles.filter((handle) => !handle.separators)).toHaveLength(1)
|
|
218
|
+
const bottom = arrow(handles, [0, 1])!
|
|
219
|
+
expect(bottom.position[0]).toBeCloseTo(4, 6)
|
|
220
|
+
expect(bottom.position[1]).toBeGreaterThan(6)
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
test('an edge drawn in two pieces has one arrow, and it pushes both', () => {
|
|
224
|
+
drawPorch([
|
|
225
|
+
[
|
|
226
|
+
[2, 4],
|
|
227
|
+
[2, 6],
|
|
228
|
+
],
|
|
229
|
+
[
|
|
230
|
+
[2, 6],
|
|
231
|
+
[4, 6],
|
|
232
|
+
],
|
|
233
|
+
[
|
|
234
|
+
[4, 6],
|
|
235
|
+
[6, 6],
|
|
236
|
+
],
|
|
237
|
+
[
|
|
238
|
+
[6, 6],
|
|
239
|
+
[6, 4],
|
|
240
|
+
],
|
|
241
|
+
])
|
|
242
|
+
const bottom = arrow(porchHandles(), [0, 1])!
|
|
243
|
+
expect(bottom.separators).toHaveLength(2)
|
|
244
|
+
expect(bottom.position[0]).toBeCloseTo(4, 6)
|
|
245
|
+
runFloorplanRoomPush({ handle: bottom, levelId: LEVEL, clientX: 4, clientY: 6.3, toPlan })
|
|
246
|
+
pointer('pointermove', 4, 7.3)
|
|
247
|
+
pointer('pointerup', 4, 7.3)
|
|
248
|
+
expect(between([2, 7], [4, 7])).toBeDefined()
|
|
249
|
+
expect(between([4, 7], [6, 7])).toBeDefined()
|
|
250
|
+
expect(porchArea()).toBeCloseTo(12, 6)
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
test('its edge pushed out moves the invisible wall, the sides follow, the porch grows; one undo step', () => {
|
|
254
|
+
drawPorch()
|
|
255
|
+
const before = useScene.getState().nodes
|
|
256
|
+
const bottom = arrow(porchHandles(), [0, 1])!
|
|
257
|
+
const zoneId = porchZone()!.id
|
|
258
|
+
expect(
|
|
259
|
+
runFloorplanRoomPush({
|
|
260
|
+
handle: bottom,
|
|
261
|
+
levelId: LEVEL,
|
|
262
|
+
zoneId,
|
|
263
|
+
clientX: 4,
|
|
264
|
+
clientY: 6.3,
|
|
265
|
+
toPlan,
|
|
266
|
+
}),
|
|
267
|
+
).not.toBeNull()
|
|
268
|
+
const depth = () => {
|
|
269
|
+
const drag = useRoomHandleDrag.getState().drag
|
|
270
|
+
return drag?.kind === 'push' ? drag.dimensions.map((dimension) => dimension.distance) : []
|
|
271
|
+
}
|
|
272
|
+
// The porch's depth, edge to the house wall's outside face.
|
|
273
|
+
const [rest] = depth()
|
|
274
|
+
expect(rest).toBeGreaterThan(1.8)
|
|
275
|
+
expect(rest).toBeLessThan(2)
|
|
276
|
+
// 0.8 m of travel lands the edge on the 0.5 m lattice, a metre out.
|
|
277
|
+
pointer('pointermove', 4, 7.1)
|
|
278
|
+
expect(useRoomHandleDrag.getState().drag).toMatchObject({ kind: 'push', distance: 1 })
|
|
279
|
+
expect(depth()[0]).toBeCloseTo(rest! + 1, 6)
|
|
280
|
+
const live = useLiveNodeOverrides.getState().overrides
|
|
281
|
+
const preview = live.get(bottom.wallId) as Partial<SeparatorNode> | undefined
|
|
282
|
+
expect(preview?.start?.[1]).toBeCloseTo(7, 6)
|
|
283
|
+
// The porch floor follows live; nothing is written until release.
|
|
284
|
+
expect(live.get(zoneId as AnyNodeId)).toHaveProperty('polygon')
|
|
285
|
+
expect(useScene.getState().nodes).toBe(before)
|
|
286
|
+
|
|
287
|
+
pointer('pointerup', 4, 7.1)
|
|
288
|
+
expect(useLiveNodeOverrides.getState().overrides.has(bottom.wallId)).toBe(false)
|
|
289
|
+
expect(between([2, 7], [6, 7])?.id).toBe(bottom.wallId as SeparatorNode['id'])
|
|
290
|
+
expect(between([2, 4], [2, 7])).toBeDefined()
|
|
291
|
+
expect(between([6, 7], [6, 4])).toBeDefined()
|
|
292
|
+
expect(porchArea()).toBeCloseTo(12, 6)
|
|
293
|
+
expect(useScene.temporal.getState().pastStates).toHaveLength(1)
|
|
294
|
+
|
|
295
|
+
useScene.temporal.getState().undo()
|
|
296
|
+
expect(between([2, 6], [6, 6])).toBeDefined()
|
|
297
|
+
expect(between([2, 4], [2, 6])).toBeDefined()
|
|
298
|
+
expect(porchArea()).toBeCloseTo(8, 6)
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
test('pulled in, it shrinks the porch and stays closed', () => {
|
|
302
|
+
drawPorch()
|
|
303
|
+
const bottom = arrow(porchHandles(), [0, 1])!
|
|
304
|
+
runFloorplanRoomPush({ handle: bottom, levelId: LEVEL, clientX: 4, clientY: 6.3, toPlan })
|
|
305
|
+
pointer('pointermove', 4, 5.3)
|
|
306
|
+
pointer('pointerup', 4, 5.3)
|
|
307
|
+
expect(between([2, 5], [6, 5])).toBeDefined()
|
|
308
|
+
expect(porchArea()).toBeCloseTo(4, 6)
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
test('its house-wall arrow still pushes the house wall, the porch following it', () => {
|
|
312
|
+
drawPorch()
|
|
313
|
+
const house = porchHandles().find((handle) => !handle.separators)!
|
|
314
|
+
expect(house.outward[1]).toBeCloseTo(-1, 6)
|
|
315
|
+
runFloorplanRoomPush({
|
|
316
|
+
handle: house,
|
|
317
|
+
levelId: LEVEL,
|
|
318
|
+
clientX: house.position[0],
|
|
319
|
+
clientY: house.position[1],
|
|
320
|
+
toPlan,
|
|
321
|
+
})
|
|
322
|
+
pointer('pointermove', house.position[0], house.position[1] - 1)
|
|
323
|
+
pointer('pointerup', house.position[0], house.position[1] - 1)
|
|
324
|
+
const moved = Object.values(useScene.getState().nodes).find(
|
|
325
|
+
(node): node is WallNode =>
|
|
326
|
+
node.type === 'wall' &&
|
|
327
|
+
Math.abs(node.start[1] - 3) < 1e-6 &&
|
|
328
|
+
Math.abs(node.end[1] - 3) < 1e-6,
|
|
329
|
+
)
|
|
330
|
+
expect(moved).toBeDefined()
|
|
331
|
+
expect(porchArea()).toBeCloseTo(12, 6)
|
|
332
|
+
})
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
test('a deck fenced by railings has the wall arrows on its railings', () => {
|
|
336
|
+
drawPorch(undefined, true)
|
|
337
|
+
const deck = porchZone()
|
|
338
|
+
expect(deck?.roomType).toBe('deck')
|
|
339
|
+
const record = getRoomSelectionIndex(LEVEL)
|
|
340
|
+
.update(useScene.getState().nodes)
|
|
341
|
+
.find((room) => room.zoneId === deck!.id)!
|
|
342
|
+
const handles = roomPushHandles(useScene.getState().nodes, record.spans)
|
|
343
|
+
const railings = handles.filter(
|
|
344
|
+
(handle) =>
|
|
345
|
+
(useScene.getState().nodes[handle.wallId as AnyNodeId] as WallNode).wallType === 'railing',
|
|
346
|
+
)
|
|
347
|
+
expect(railings).toHaveLength(3)
|
|
348
|
+
expect(railings.every((handle) => !handle.separators)).toBe(true)
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
describe('a selected invisible wall', () => {
|
|
352
|
+
test('its side arrow pushes it, the invisible wall joined to it stretching and its end sliding on the house', () => {
|
|
353
|
+
drawPorch()
|
|
354
|
+
const left = between([2, 4], [2, 6])!
|
|
355
|
+
useViewer.getState().setSelection({ selectedIds: [left.id] })
|
|
356
|
+
const side = separatorPushHandles(left).find((handle) => handle.outward[0] < -0.99)!
|
|
357
|
+
expect(
|
|
358
|
+
runFloorplanWallPush(
|
|
359
|
+
{ wallId: left.id, side: side.key.endsWith(':a') ? 'a' : 'b' },
|
|
360
|
+
1.9,
|
|
361
|
+
5,
|
|
362
|
+
toPlan,
|
|
363
|
+
),
|
|
364
|
+
).not.toBeNull()
|
|
365
|
+
pointer('pointermove', 0.9, 5)
|
|
366
|
+
pointer('pointerup', 0.9, 5)
|
|
367
|
+
expect(between([1, 4], [1, 6])?.id).toBe(left.id)
|
|
368
|
+
expect(between([1, 6], [6, 6])).toBeDefined()
|
|
369
|
+
expect(porchArea()).toBeCloseTo(10, 6)
|
|
370
|
+
expect(useScene.temporal.getState().pastStates).toHaveLength(1)
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
/** Drags the end of `separator` at `corner` (its 3D grip) along `path`, Alt held when `alt`. */
|
|
374
|
+
function dragEnd(separator: SeparatorNode, corner: Point, path: Point[], alt = false) {
|
|
375
|
+
const endpoint = near(separator.start, corner) ? 'start' : 'end'
|
|
376
|
+
runSeparatorEndDrag({ separatorId: separator.id, endpoint, levelId: LEVEL, toPlan })
|
|
377
|
+
for (const [x, z] of path) pointer('pointermove', x, z, alt)
|
|
378
|
+
const [x, z] = path.at(-1)!
|
|
379
|
+
pointer('pointerup', x, z, alt)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
test('an end dragged takes the invisible wall joined there along, square and on the grid', () => {
|
|
383
|
+
drawPorch()
|
|
384
|
+
const bottom = between([2, 6], [6, 6])!
|
|
385
|
+
const endpoint = near(bottom.start, [2, 6]) ? 'start' : 'end'
|
|
386
|
+
runSeparatorEndDrag({ separatorId: bottom.id, endpoint, levelId: LEVEL, toPlan })
|
|
387
|
+
expect(useInteractionScope.getState().scope).toMatchObject({
|
|
388
|
+
kind: 'handle-drag',
|
|
389
|
+
nodeId: bottom.id,
|
|
390
|
+
handle: SEPARATOR_END_DRAG_LABEL,
|
|
391
|
+
})
|
|
392
|
+
pointer('pointermove', 1.07, 5.96)
|
|
393
|
+
const live = useLiveNodeOverrides.getState().overrides.get(bottom.id) as Partial<SeparatorNode>
|
|
394
|
+
expect(live[endpoint]).toEqual([1, 6])
|
|
395
|
+
expect(between([2, 6], [6, 6])).toBeDefined()
|
|
396
|
+
pointer('pointerup', 1.07, 5.96)
|
|
397
|
+
expect(between([1, 6], [6, 6])?.id).toBe(bottom.id)
|
|
398
|
+
expect(between([2, 4], [1, 6])).toBeDefined()
|
|
399
|
+
expect(useLiveNodeOverrides.getState().overrides.has(bottom.id)).toBe(false)
|
|
400
|
+
expect(useScene.temporal.getState().pastStates).toHaveLength(1)
|
|
401
|
+
expect(useInteractionScope.getState().scope.kind).toBe('idle')
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
test('let go with Alt, its end comes back together on its old corner', () => {
|
|
405
|
+
drawPorch()
|
|
406
|
+
const bottom = between([2, 6], [6, 6])!
|
|
407
|
+
dragEnd(bottom, [2, 6], [[1, 7]], true)
|
|
408
|
+
expect(between([2, 4], [2, 6])).toBeDefined()
|
|
409
|
+
expect(between([2, 6], [6, 6])).toBeUndefined()
|
|
410
|
+
|
|
411
|
+
const moved = useScene.getState().nodes[bottom.id as AnyNodeId] as SeparatorNode
|
|
412
|
+
dragEnd(moved, near(moved.start, [6, 6]) ? moved.end : moved.start, [[2.12, 6.09]], true)
|
|
413
|
+
expect(between([2, 6], [6, 6])?.id).toBe(bottom.id)
|
|
414
|
+
expect(porchArea()).toBeCloseTo(8, 6)
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
test('the plan’s end handle runs the same drag', () => {
|
|
418
|
+
drawPorch()
|
|
419
|
+
const bottom = between([2, 6], [6, 6])!
|
|
420
|
+
const endpoint = near(bottom.start, [2, 6]) ? 'start' : 'end'
|
|
421
|
+
// The plan begins its end-drag scope (the wall snapping set) around the session.
|
|
422
|
+
useInteractionScope.getState().begin(endpointReshapeScope(bottom.id, endpoint, 'floorplan'))
|
|
423
|
+
const session = beginSeparatorEndDrag(bottom.id, endpoint)!
|
|
424
|
+
session.move([1.07, 6.04], false)
|
|
425
|
+
expect(session.canCommit()).toBe(true)
|
|
426
|
+
useInteractionScope.getState().end()
|
|
427
|
+
expect(session.commit()).toBe(true)
|
|
428
|
+
expect(between([1, 6], [6, 6])?.id).toBe(bottom.id)
|
|
429
|
+
expect(between([2, 4], [1, 6])).toBeDefined()
|
|
430
|
+
})
|
|
431
|
+
})
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AnyNode,
|
|
3
|
+
type AnyNodeId,
|
|
4
|
+
joinedSeparatorIds,
|
|
5
|
+
runAsSingleSceneHistoryStep,
|
|
6
|
+
type SeparatorNode,
|
|
7
|
+
setSeparatorEnd,
|
|
8
|
+
useLiveNodeOverrides,
|
|
9
|
+
useScene,
|
|
10
|
+
type WallNode,
|
|
11
|
+
type WallPlanPoint,
|
|
12
|
+
} from '@pascal-app/core'
|
|
13
|
+
import { runRoomHandleDrag } from './room-handle-drag'
|
|
14
|
+
import { applyRoomPlan } from './room-structure-commands'
|
|
15
|
+
import { levelSeparatorLines } from './separator-lines'
|
|
16
|
+
import { isWallRunFree, publishWallRunFeedback, snapWallRunDraftPoint } from './wall-run-snap'
|
|
17
|
+
|
|
18
|
+
/** Scope label of an invisible wall's end drag (its 3D grip). */
|
|
19
|
+
export const SEPARATOR_END_DRAG_LABEL = 'separator-end'
|
|
20
|
+
|
|
21
|
+
const SAME_POINT = 1e-6
|
|
22
|
+
|
|
23
|
+
export type SeparatorEndDrag = {
|
|
24
|
+
/** The invisible walls the drag may move: the dragged one and those it can carry. */
|
|
25
|
+
affectedIds: AnyNodeId[]
|
|
26
|
+
/** Snaps the end to `point` (level plan) and previews it; `detach` (Alt) leaves the joined ones. */
|
|
27
|
+
move: (point: WallPlanPoint, detach: boolean) => void
|
|
28
|
+
/** Whether the end has moved somewhere it may stay. */
|
|
29
|
+
canCommit: () => boolean
|
|
30
|
+
/** Writes the end as one undo step; false when it did not move or core refused it. */
|
|
31
|
+
commit: () => boolean
|
|
32
|
+
/** Drops the preview; writes nothing. */
|
|
33
|
+
cancel: () => void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* An invisible wall's end dragged: its handle on the plan, its grip in 3D. The
|
|
38
|
+
* end snaps as a wall run does — square to the house or to its own line, its
|
|
39
|
+
* length on the grid, onto wall and invisible-wall ends and bodies, so it
|
|
40
|
+
* comes back together on its old corner — the invisible walls joined at that
|
|
41
|
+
* end follow it live, and the release writes it once (`setSeparatorEnd`).
|
|
42
|
+
* The scene is untouched until then.
|
|
43
|
+
*/
|
|
44
|
+
export function beginSeparatorEndDrag(
|
|
45
|
+
separatorId: string,
|
|
46
|
+
endpoint: 'start' | 'end',
|
|
47
|
+
): SeparatorEndDrag | null {
|
|
48
|
+
const nodes = useScene.getState().nodes
|
|
49
|
+
const separator = nodes[separatorId as AnyNodeId]
|
|
50
|
+
if (separator?.type !== 'separator') return null
|
|
51
|
+
const from = separator[endpoint]
|
|
52
|
+
const fixed = endpoint === 'start' ? separator.end : separator.start
|
|
53
|
+
const levelId = separator.parentId
|
|
54
|
+
const atFrom = (point: WallPlanPoint) =>
|
|
55
|
+
Math.abs(point[0] - from[0]) <= SAME_POINT && Math.abs(point[1] - from[1]) <= SAME_POINT
|
|
56
|
+
const others = Object.values(nodes).filter(
|
|
57
|
+
(node): node is SeparatorNode =>
|
|
58
|
+
node.type === 'separator' && node.parentId === levelId && node.id !== separator.id,
|
|
59
|
+
)
|
|
60
|
+
// The ends following this one are no snap targets while they do: they sit where it left.
|
|
61
|
+
const joined = joinedSeparatorIds(nodes, separator, endpoint)
|
|
62
|
+
const walls = Object.values(nodes).filter(
|
|
63
|
+
(node): node is WallNode => node.type === 'wall' && node.parentId === levelId,
|
|
64
|
+
)
|
|
65
|
+
const targets = {
|
|
66
|
+
attached: [
|
|
67
|
+
...walls,
|
|
68
|
+
...levelSeparatorLines(nodes, levelId, new Set([separator.id, ...joined])),
|
|
69
|
+
],
|
|
70
|
+
detached: [...walls, ...levelSeparatorLines(nodes, levelId, new Set([separator.id]))],
|
|
71
|
+
}
|
|
72
|
+
const length = Math.hypot(from[0] - fixed[0], from[1] - fixed[1])
|
|
73
|
+
const reference: WallPlanPoint | null =
|
|
74
|
+
length > 1e-9 ? [(from[0] - fixed[0]) / length, (from[1] - fixed[1]) / length] : null
|
|
75
|
+
let target: { point: WallPlanPoint; detach: boolean } | null = null
|
|
76
|
+
const previewIds = new Set<AnyNodeId>()
|
|
77
|
+
const preview = (updates: Array<[AnyNodeId, Partial<AnyNode>]>) => {
|
|
78
|
+
const overrides = useLiveNodeOverrides.getState()
|
|
79
|
+
const next = new Set(updates.map(([id]) => id))
|
|
80
|
+
for (const id of previewIds) {
|
|
81
|
+
if (next.has(id)) continue
|
|
82
|
+
overrides.clear(id)
|
|
83
|
+
useScene.getState().markDirty(id)
|
|
84
|
+
previewIds.delete(id)
|
|
85
|
+
}
|
|
86
|
+
if (updates.length) overrides.setMany(updates)
|
|
87
|
+
for (const id of next) {
|
|
88
|
+
previewIds.add(id)
|
|
89
|
+
useScene.getState().markDirty(id)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
affectedIds: [separator.id, ...others.map((node) => node.id)] as AnyNodeId[],
|
|
94
|
+
move(point, detach) {
|
|
95
|
+
const snap = snapWallRunDraftPoint({
|
|
96
|
+
point,
|
|
97
|
+
start: fixed,
|
|
98
|
+
runStart: fixed,
|
|
99
|
+
reference,
|
|
100
|
+
walls: detach ? targets.detached : targets.attached,
|
|
101
|
+
free: isWallRunFree(null),
|
|
102
|
+
})
|
|
103
|
+
publishWallRunFeedback(snap)
|
|
104
|
+
const plan = setSeparatorEnd(nodes, { separatorId, endpoint, point: snap.point, detach })
|
|
105
|
+
if (plan.conflicts?.length) {
|
|
106
|
+
target = null
|
|
107
|
+
preview([])
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
target = { point: snap.point, detach }
|
|
111
|
+
preview(
|
|
112
|
+
plan.changes.flatMap(
|
|
113
|
+
(change): Array<[AnyNodeId, Partial<AnyNode>]> =>
|
|
114
|
+
change.op === 'update' ? [[change.id as AnyNodeId, change.data]] : [],
|
|
115
|
+
),
|
|
116
|
+
)
|
|
117
|
+
},
|
|
118
|
+
canCommit: () => !!target && !atFrom(target.point),
|
|
119
|
+
commit() {
|
|
120
|
+
preview([])
|
|
121
|
+
publishWallRunFeedback(null)
|
|
122
|
+
if (!target || atFrom(target.point)) return false
|
|
123
|
+
const current = useScene.getState().nodes
|
|
124
|
+
if (current[separatorId as AnyNodeId]?.type !== 'separator') return false
|
|
125
|
+
const plan = setSeparatorEnd(current, { separatorId, endpoint, ...target })
|
|
126
|
+
return runAsSingleSceneHistoryStep(useScene, () => applyRoomPlan(plan))
|
|
127
|
+
},
|
|
128
|
+
cancel() {
|
|
129
|
+
preview([])
|
|
130
|
+
publishWallRunFeedback(null)
|
|
131
|
+
},
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* An invisible wall's 3D end grip: `toPlan` reads the pointer on the floor the
|
|
137
|
+
* separator lies on, in level plan metres. One gesture under the lifecycle
|
|
138
|
+
* owner (Escape or ⌘Z cancels), one undo step; Alt leaves the joined
|
|
139
|
+
* invisible walls where they are.
|
|
140
|
+
*/
|
|
141
|
+
export function runSeparatorEndDrag({
|
|
142
|
+
separatorId,
|
|
143
|
+
endpoint,
|
|
144
|
+
levelId,
|
|
145
|
+
toPlan,
|
|
146
|
+
}: {
|
|
147
|
+
separatorId: string
|
|
148
|
+
endpoint: 'start' | 'end'
|
|
149
|
+
levelId: string
|
|
150
|
+
toPlan: (clientX: number, clientY: number) => WallPlanPoint | null
|
|
151
|
+
}) {
|
|
152
|
+
const drag = beginSeparatorEndDrag(separatorId, endpoint)
|
|
153
|
+
if (!drag) return null
|
|
154
|
+
let altHeld = false
|
|
155
|
+
const trackAlt = (event: KeyboardEvent | PointerEvent) => {
|
|
156
|
+
altHeld = event.altKey
|
|
157
|
+
}
|
|
158
|
+
const events = ['pointermove', 'keydown', 'keyup'] as const
|
|
159
|
+
for (const type of events) window.addEventListener(type, trackAlt, true)
|
|
160
|
+
const stop = () => {
|
|
161
|
+
for (const type of events) window.removeEventListener(type, trackAlt, true)
|
|
162
|
+
}
|
|
163
|
+
return runRoomHandleDrag<WallPlanPoint>({
|
|
164
|
+
label: SEPARATOR_END_DRAG_LABEL,
|
|
165
|
+
nodeId: separatorId,
|
|
166
|
+
levelId,
|
|
167
|
+
requires: [separatorId],
|
|
168
|
+
sample: toPlan,
|
|
169
|
+
onValue: (point) => drag.move(point, altHeld),
|
|
170
|
+
onCommit: () => {
|
|
171
|
+
stop()
|
|
172
|
+
drag.commit()
|
|
173
|
+
},
|
|
174
|
+
onCancel: () => {
|
|
175
|
+
stop()
|
|
176
|
+
drag.cancel()
|
|
177
|
+
},
|
|
178
|
+
})
|
|
179
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { AnyNode, SeparatorNode, WallNode } from '@pascal-app/core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Invisible walls (room separators) as the wall snapping reads walls: a line
|
|
5
|
+
* with no thickness. A drafted or dragged invisible-wall end catches another's
|
|
6
|
+
* ends and body this way, and a pushed one lines up with them, as walls do
|
|
7
|
+
* with walls. Never written to the scene.
|
|
8
|
+
*/
|
|
9
|
+
export function separatorWallLine(separator: SeparatorNode): WallNode {
|
|
10
|
+
return {
|
|
11
|
+
object: 'node',
|
|
12
|
+
id: separator.id as string as WallNode['id'],
|
|
13
|
+
type: 'wall',
|
|
14
|
+
name: separator.name,
|
|
15
|
+
parentId: separator.parentId,
|
|
16
|
+
visible: true,
|
|
17
|
+
metadata: {},
|
|
18
|
+
children: [],
|
|
19
|
+
start: separator.start,
|
|
20
|
+
end: separator.end,
|
|
21
|
+
thickness: 0,
|
|
22
|
+
frontSide: 'unknown',
|
|
23
|
+
backSide: 'unknown',
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The invisible walls on `levelId` as wall lines, leaving out `exclude`. */
|
|
28
|
+
export function levelSeparatorLines(
|
|
29
|
+
nodes: Readonly<Record<string, AnyNode>>,
|
|
30
|
+
levelId: string | null,
|
|
31
|
+
exclude: ReadonlySet<string> = new Set(),
|
|
32
|
+
): WallNode[] {
|
|
33
|
+
return Object.values(nodes).flatMap((node) =>
|
|
34
|
+
node.type === 'separator' && node.parentId === levelId && !exclude.has(node.id)
|
|
35
|
+
? [separatorWallLine(node)]
|
|
36
|
+
: [],
|
|
37
|
+
)
|
|
38
|
+
}
|