@pascal-app/editor 1.0.6 → 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 +18 -15
- package/src/components/editor/action-menu-button.tsx +4 -1
- package/src/components/editor/alignment-3d-guide-layer.tsx +102 -11
- package/src/components/editor/bake-thumbnail.tsx +3 -2
- package/src/components/editor/custom-camera-controls.tsx +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,245 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
type AnyNode,
|
|
4
|
+
type AnyNodeId,
|
|
5
|
+
detectSpacesForLevel,
|
|
6
|
+
getWallAssemblyPreset,
|
|
7
|
+
LevelNode as LevelSchema,
|
|
8
|
+
nodeRegistry,
|
|
9
|
+
registerNode,
|
|
10
|
+
useScene,
|
|
11
|
+
type WallNode,
|
|
12
|
+
WallNode as WallSchema,
|
|
13
|
+
wallAssemblyPatch,
|
|
14
|
+
wallClosesRoom,
|
|
15
|
+
} from '@pascal-app/core'
|
|
16
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
17
|
+
import { createWallOnCurrentLevel } from '../components/tools/wall/wall-drafting'
|
|
18
|
+
import type { WallPlanPoint } from '../components/tools/wall/wall-snap-geometry'
|
|
19
|
+
import useEditor from '../store/use-editor'
|
|
20
|
+
import { selectWallDrawVariant } from './wall-draw-variant'
|
|
21
|
+
import {
|
|
22
|
+
endWallPullRun,
|
|
23
|
+
startWallPull,
|
|
24
|
+
useWallPullRun,
|
|
25
|
+
wallDraftIsRun,
|
|
26
|
+
wallPullSeed,
|
|
27
|
+
wallPullTypePatch,
|
|
28
|
+
} from './wall-pull'
|
|
29
|
+
import { isWallRunFree, snapWallRunDraftPoint, wallRunDirection } from './wall-run-snap'
|
|
30
|
+
|
|
31
|
+
// `updateNodes` batches its dirty-marking through requestAnimationFrame,
|
|
32
|
+
// which bun's test runtime doesn't provide.
|
|
33
|
+
if (typeof globalThis.requestAnimationFrame === 'undefined') {
|
|
34
|
+
globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) =>
|
|
35
|
+
setTimeout(() => callback(0), 0)) as unknown as typeof requestAnimationFrame
|
|
36
|
+
globalThis.cancelAnimationFrame = ((id: number) =>
|
|
37
|
+
clearTimeout(id)) as typeof cancelAnimationFrame
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const LEVEL_ID = 'level_pull' as AnyNodeId
|
|
41
|
+
|
|
42
|
+
function levelWalls(): WallNode[] {
|
|
43
|
+
return Object.values(useScene.getState().nodes).filter(
|
|
44
|
+
(node): node is WallNode => node?.type === 'wall' && node.parentId === LEVEL_ID,
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A lone stucco wall running east to the origin, 3.1 m tall on a 2.7 m storey, painted. */
|
|
49
|
+
function seedSourceWall(): WallNode {
|
|
50
|
+
const preset = getWallAssemblyPreset('exterior-2x6-stucco')
|
|
51
|
+
if (!preset) throw new Error('Expected the stucco preset')
|
|
52
|
+
const source = WallSchema.parse({
|
|
53
|
+
id: 'wall_source',
|
|
54
|
+
name: 'Source',
|
|
55
|
+
parentId: LEVEL_ID,
|
|
56
|
+
start: [-4, 0],
|
|
57
|
+
end: [0, 0],
|
|
58
|
+
height: 3.1,
|
|
59
|
+
...wallAssemblyPatch(structuredClone(preset.assembly)),
|
|
60
|
+
slots: { a: 'library:paint-sage', b: 'library:stucco-white' },
|
|
61
|
+
skirting: { enabled: true, sides: 'both', height: 0.12, proud: 0.02, profile: 'flat' },
|
|
62
|
+
})
|
|
63
|
+
const level = LevelSchema.parse({
|
|
64
|
+
id: LEVEL_ID,
|
|
65
|
+
level: 0,
|
|
66
|
+
height: 2.7,
|
|
67
|
+
children: [source.id],
|
|
68
|
+
})
|
|
69
|
+
useScene.setState({
|
|
70
|
+
nodes: { [level.id]: level as AnyNode, [source.id]: source as AnyNode },
|
|
71
|
+
rootNodeIds: [level.id],
|
|
72
|
+
dirtyNodes: new Set(),
|
|
73
|
+
collections: {},
|
|
74
|
+
} as never)
|
|
75
|
+
return source
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe('pulling the next wall from a wall end', () => {
|
|
79
|
+
let restoreRegistry: (() => void) | undefined
|
|
80
|
+
let previousEditor: Partial<ReturnType<typeof useEditor.getState>>
|
|
81
|
+
|
|
82
|
+
beforeEach(() => {
|
|
83
|
+
previousEditor = {
|
|
84
|
+
toolDefaults: useEditor.getState().toolDefaults,
|
|
85
|
+
continuationByContext: useEditor.getState().continuationByContext,
|
|
86
|
+
}
|
|
87
|
+
// The wall's snap profile makes the armed wall tool the 'wall' snapping
|
|
88
|
+
// context (grid lengths), as in the app.
|
|
89
|
+
restoreRegistry = nodeRegistry._snapshot()
|
|
90
|
+
registerNode({
|
|
91
|
+
kind: 'wall',
|
|
92
|
+
schemaVersion: 10,
|
|
93
|
+
schema: WallSchema,
|
|
94
|
+
category: 'structure',
|
|
95
|
+
snapProfile: 'structural',
|
|
96
|
+
defaults: () => WallSchema.parse({ start: [0, 0], end: [1, 0] }),
|
|
97
|
+
capabilities: {},
|
|
98
|
+
} as never)
|
|
99
|
+
useEditor.getState().setContinuation('wall', 'room')
|
|
100
|
+
useEditor.getState().setSnappingMode('wall', 'grid')
|
|
101
|
+
useEditor.getState().armToolMode({ mode: 'select' })
|
|
102
|
+
useViewer.setState({
|
|
103
|
+
selection: {
|
|
104
|
+
buildingId: null,
|
|
105
|
+
levelId: LEVEL_ID,
|
|
106
|
+
zoneId: null,
|
|
107
|
+
selectedIds: ['wall_source'],
|
|
108
|
+
},
|
|
109
|
+
} as never)
|
|
110
|
+
seedSourceWall()
|
|
111
|
+
useScene.temporal.getState().clear()
|
|
112
|
+
useScene.temporal.getState().resume()
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
afterEach(() => {
|
|
116
|
+
endWallPullRun()
|
|
117
|
+
restoreRegistry?.()
|
|
118
|
+
restoreRegistry = undefined
|
|
119
|
+
useEditor.setState(previousEditor as never)
|
|
120
|
+
useEditor.getState().armToolMode({ mode: 'select' })
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test('the pull arms the Walls run at the end, with the pulled wall’s type', () => {
|
|
124
|
+
const source = useScene.getState().nodes['wall_source' as AnyNodeId] as WallNode
|
|
125
|
+
expect(startWallPull({ wallId: source.id, endpoint: 'end', origin: '3d' })).toBe(true)
|
|
126
|
+
|
|
127
|
+
const run = useWallPullRun.getState().run
|
|
128
|
+
expect(run).toMatchObject({
|
|
129
|
+
sourceWallId: source.id,
|
|
130
|
+
levelId: LEVEL_ID,
|
|
131
|
+
runStart: [0, 0],
|
|
132
|
+
start: [0, 0],
|
|
133
|
+
origin: '3d',
|
|
134
|
+
dragging: true,
|
|
135
|
+
})
|
|
136
|
+
expect(run?.reference[0]).toBeCloseTo(1, 9)
|
|
137
|
+
expect(run?.reference[1]).toBeCloseTo(0, 9)
|
|
138
|
+
const editor = useEditor.getState()
|
|
139
|
+
expect(editor.mode).toBe('build')
|
|
140
|
+
expect(editor.tool).toBe('wall')
|
|
141
|
+
expect(editor.getContinuation('wall')).toBe('single')
|
|
142
|
+
expect(useViewer.getState().selection.selectedIds).toEqual([])
|
|
143
|
+
|
|
144
|
+
// The pulled wall's construction, finishes and own height, not the Build
|
|
145
|
+
// panel's types.
|
|
146
|
+
const pulled = createWallOnCurrentLevel([0, 0], [3, 0])
|
|
147
|
+
expect(pulled?.assembly).toEqual(source.assembly)
|
|
148
|
+
expect(pulled?.thickness).toBe(source.thickness)
|
|
149
|
+
expect(pulled?.height).toBe(3.1)
|
|
150
|
+
expect(pulled?.slots).toEqual(source.slots)
|
|
151
|
+
expect(pulled?.skirting).toEqual(source.skirting)
|
|
152
|
+
expect(pulled?.start).toEqual([0, 0])
|
|
153
|
+
|
|
154
|
+
// Ending puts back the Rooms shape and the wall tool defaults, in Select.
|
|
155
|
+
endWallPullRun()
|
|
156
|
+
expect(useWallPullRun.getState().run).toBeNull()
|
|
157
|
+
expect(useEditor.getState().mode).toBe('select')
|
|
158
|
+
expect(useEditor.getState().getContinuation('wall')).toBe('room')
|
|
159
|
+
expect(useEditor.getState().toolDefaults.wall).toBeUndefined()
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
test('the type patch keeps a plain wall plain and a storey-high wall on the storey', () => {
|
|
163
|
+
const plain = WallSchema.parse({ start: [0, 0], end: [2, 0], height: 2.7 })
|
|
164
|
+
expect(wallPullTypePatch(plain, 2.7)).toEqual({ thickness: plain.thickness ?? 0.1 })
|
|
165
|
+
const pulledFromStart = wallPullSeed(plain, 'start')
|
|
166
|
+
expect(pulledFromStart?.start).toEqual([0, 0])
|
|
167
|
+
expect(pulledFromStart?.reference[0]).toBeCloseTo(-1, 9)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
test('closing the run back to its start forms a room, one undo step per wall', () => {
|
|
171
|
+
startWallPull({ wallId: 'wall_source', endpoint: 'end', origin: '3d' })
|
|
172
|
+
expect(isWallRunFree()).toBe(false)
|
|
173
|
+
const run = useWallPullRun.getState().run!
|
|
174
|
+
// The run as the tool drafts it: each wall from the last one's end,
|
|
175
|
+
// square to it — the pulled wall first.
|
|
176
|
+
let start = run.start
|
|
177
|
+
let reference: WallPlanPoint | null = run.reference
|
|
178
|
+
const place = (cursor: WallPlanPoint) => {
|
|
179
|
+
const snapped = snapWallRunDraftPoint({
|
|
180
|
+
point: cursor,
|
|
181
|
+
start,
|
|
182
|
+
runStart: run.runStart,
|
|
183
|
+
reference,
|
|
184
|
+
walls: levelWalls(),
|
|
185
|
+
free: isWallRunFree(),
|
|
186
|
+
})
|
|
187
|
+
const before = useScene.temporal.getState().pastStates.length
|
|
188
|
+
const wall = createWallOnCurrentLevel(start, snapped.point)!
|
|
189
|
+
expect(useScene.temporal.getState().pastStates.length).toBe(before + 1)
|
|
190
|
+
start = wall.end
|
|
191
|
+
reference = wallRunDirection(wall.start, wall.end)
|
|
192
|
+
return { wall, snapped }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Straight on (6° off) to a grid length; square up; back west until the
|
|
196
|
+
// run's start lines up; then onto the start itself.
|
|
197
|
+
const first = place([5.1, 0.45])
|
|
198
|
+
expect(first.snapped.lock).toBe('straight')
|
|
199
|
+
expect(first.wall.end).toEqual([5, 0])
|
|
200
|
+
const second = place([5.25, 3.9])
|
|
201
|
+
expect(second.snapped.lock).toBe('square')
|
|
202
|
+
expect(second.wall.end).toEqual([5, 4])
|
|
203
|
+
expect(wallClosesRoom(levelWalls(), second.wall)).toBe(false)
|
|
204
|
+
const third = place([0.1, 4.05])
|
|
205
|
+
expect(third.snapped.snap).toBe('alignment')
|
|
206
|
+
expect(third.wall.end).toEqual([0, 4])
|
|
207
|
+
const last = place([0.12, 0.2])
|
|
208
|
+
expect(last.snapped.snap).toBe('endpoint')
|
|
209
|
+
expect(last.wall.end).toEqual([0, 0])
|
|
210
|
+
|
|
211
|
+
const walls = levelWalls()
|
|
212
|
+
expect(wallClosesRoom(walls, last.wall)).toBe(true)
|
|
213
|
+
const rooms = detectSpacesForLevel(LEVEL_ID, walls).spaces.filter((space) => !space.isExterior)
|
|
214
|
+
expect(rooms).toHaveLength(1)
|
|
215
|
+
const xs = rooms[0]!.polygon.map((point) => point[0])
|
|
216
|
+
const zs = rooms[0]!.polygon.map((point) => point[1])
|
|
217
|
+
expect([Math.min(...xs), Math.max(...xs), Math.min(...zs), Math.max(...zs)]).toEqual([
|
|
218
|
+
0, 5, 0, 4,
|
|
219
|
+
])
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
// The Walls variant had become a run that chained every wall from the last; it draws one wall
|
|
223
|
+
// per two clicks and stops. A Polygon room's sides and a pulled run still chain.
|
|
224
|
+
test('a Walls segment is one wall; Polygon sides and a pulled run chain', () => {
|
|
225
|
+
selectWallDrawVariant('walls')
|
|
226
|
+
expect(wallDraftIsRun()).toBe(false)
|
|
227
|
+
selectWallDrawVariant('polygon')
|
|
228
|
+
expect(wallDraftIsRun()).toBe(true)
|
|
229
|
+
startWallPull({ wallId: 'wall_source', endpoint: 'end', origin: '2d' })
|
|
230
|
+
expect(useEditor.getState().getContinuation('wall')).toBe('single')
|
|
231
|
+
expect(wallDraftIsRun()).toBe(true)
|
|
232
|
+
endWallPullRun()
|
|
233
|
+
selectWallDrawVariant('walls')
|
|
234
|
+
expect(wallDraftIsRun()).toBe(false)
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
test('leaving the wall tool ends the run without leaving Select behind', () => {
|
|
238
|
+
startWallPull({ wallId: 'wall_source', endpoint: 'start', origin: '2d' })
|
|
239
|
+
expect(useWallPullRun.getState().run?.start).toEqual([-4, 0])
|
|
240
|
+
useEditor.getState().armToolMode({ mode: 'build', tool: 'slab' })
|
|
241
|
+
expect(useWallPullRun.getState().run).toBeNull()
|
|
242
|
+
expect(useEditor.getState().tool).toBe('slab')
|
|
243
|
+
expect(useEditor.getState().getContinuation('wall')).toBe('room')
|
|
244
|
+
})
|
|
245
|
+
})
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Pull to continue": a selected wall's end carries a grip that, dragged,
|
|
3
|
+
* draws the next wall from that end.
|
|
4
|
+
*
|
|
5
|
+
* Pressing the grip arms the wall tool on Walls, as a run (each wall starts
|
|
6
|
+
* where the last ended, `wallDraftIsRun`) already started at the pulled end,
|
|
7
|
+
* with the pulled wall's type: its assembly, thickness, height and finishes.
|
|
8
|
+
* The drag shows the next wall; letting go places it, and the run carries on
|
|
9
|
+
* from its far end, click by click, until Esc, a double-click, closing the loop
|
|
10
|
+
* or teeing into a wall. Then the editor is back in Select, with the Rooms
|
|
11
|
+
* shape and wall tool settings the user had.
|
|
12
|
+
*
|
|
13
|
+
* Its walls snap as every line run does once started (`wall-run-snap.ts`):
|
|
14
|
+
* square to the wall before — the pulled one, for the first — or the house's
|
|
15
|
+
* axes, 15° steps past that, Alt free. The 3D wall tool and the floor plan each
|
|
16
|
+
* seed their run from this store, the way they share the plain run: whichever
|
|
17
|
+
* view owns the input places the walls.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
type AnyNodeId,
|
|
22
|
+
getWallCurveFrameAt,
|
|
23
|
+
getWallThickness,
|
|
24
|
+
useScene,
|
|
25
|
+
WALL_CONSTRUCTION_FIELDS,
|
|
26
|
+
type WallNode,
|
|
27
|
+
type WallPlanPoint,
|
|
28
|
+
wallFollowsStorey,
|
|
29
|
+
} from '@pascal-app/core'
|
|
30
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
31
|
+
import { create } from 'zustand'
|
|
32
|
+
import useEditor from '../store/use-editor'
|
|
33
|
+
import type { ContinuationMode } from './continuation'
|
|
34
|
+
import { getWallDrawVariant, selectWallDrawVariant } from './wall-draw-variant'
|
|
35
|
+
import { publishWallRunFeedback } from './wall-run-snap'
|
|
36
|
+
|
|
37
|
+
/** The floor plan's affordance id for a wall end's pull grip (a `move-arrow`, variant `pull`). */
|
|
38
|
+
export const WALL_PULL_AFFORDANCE = 'wall-pull'
|
|
39
|
+
|
|
40
|
+
/** What a pull grip carries: the wall, and which of its ends it pulls from. */
|
|
41
|
+
export type WallPullGripPayload = { wallId: string; endpoint: 'start' | 'end' }
|
|
42
|
+
|
|
43
|
+
/** The grip's tooltip, in both views. */
|
|
44
|
+
export const WALL_PULL_GRIP_TITLE = 'Drag to draw the next wall'
|
|
45
|
+
|
|
46
|
+
export type WallPullRun = {
|
|
47
|
+
/** Bumped per pull, so each view seeds its draft from it once. */
|
|
48
|
+
seq: number
|
|
49
|
+
sourceWallId: AnyNodeId
|
|
50
|
+
levelId: AnyNodeId
|
|
51
|
+
/** The pulled end, where the run started: its walls can close square back to it. */
|
|
52
|
+
runStart: WallPlanPoint
|
|
53
|
+
/** Where the first wall starts: the pulled end. */
|
|
54
|
+
start: WallPlanPoint
|
|
55
|
+
/** Unit direction of the pulled wall, pointing out of the pulled end. */
|
|
56
|
+
reference: WallPlanPoint
|
|
57
|
+
/** The view the grip was pulled in; the floor plan drafts a 3D pull only beside it. */
|
|
58
|
+
origin: '2d' | '3d'
|
|
59
|
+
/** From the press on the grip until it is let go. */
|
|
60
|
+
dragging: boolean
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
type WallPullState = { run: WallPullRun | null }
|
|
64
|
+
|
|
65
|
+
/** The open pull run, if any. */
|
|
66
|
+
export const useWallPullRun = create<WallPullState>(() => ({ run: null }))
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Whether the wall tool's open segment belongs to a run: a Polygon room's sides, or the walls
|
|
70
|
+
* pulled from a wall end. A run's segment holds square to the one before it (`wall-run-snap.ts`)
|
|
71
|
+
* and the next chains from its end. Otherwise it is a Walls segment: one wall, locked to 15° rays
|
|
72
|
+
* from its start, and the tool stops once it is placed.
|
|
73
|
+
*/
|
|
74
|
+
export function wallDraftIsRun(): boolean {
|
|
75
|
+
return getWallDrawVariant() !== 'walls' || useWallPullRun.getState().run !== null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The wall tool defaults that draw walls of `wall`'s type (core
|
|
80
|
+
* `WALL_CONSTRUCTION_FIELDS`; position, support and paint regions stay with
|
|
81
|
+
* each wall): its assembly and the thickness it sums to (a plain wall's own
|
|
82
|
+
* thickness, so the Build panel's types don't take over), its finishes and
|
|
83
|
+
* trims, and its height when it has its own rather than following the storey
|
|
84
|
+
* (`storeyHeight`).
|
|
85
|
+
*/
|
|
86
|
+
export function wallPullTypePatch(wall: WallNode, storeyHeight: number | null): Partial<WallNode> {
|
|
87
|
+
const patch: Record<string, unknown> = {}
|
|
88
|
+
for (const field of WALL_CONSTRUCTION_FIELDS) {
|
|
89
|
+
if (wall[field] !== undefined) patch[field] = structuredClone(wall[field])
|
|
90
|
+
}
|
|
91
|
+
patch.thickness = getWallThickness(wall)
|
|
92
|
+
if (!wallFollowsStorey(wall, storeyHeight)) patch.height = wall.height
|
|
93
|
+
return patch as Partial<WallNode>
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The pulled end of `wall` and the direction out of it: along the wall for a
|
|
98
|
+
* straight one, along its tangent for a curved one.
|
|
99
|
+
*/
|
|
100
|
+
export function wallPullSeed(
|
|
101
|
+
wall: Pick<WallNode, 'start' | 'end' | 'curveOffset'>,
|
|
102
|
+
endpoint: 'start' | 'end',
|
|
103
|
+
): { start: WallPlanPoint; reference: WallPlanPoint } | null {
|
|
104
|
+
const start: WallPlanPoint =
|
|
105
|
+
endpoint === 'start' ? [wall.start[0], wall.start[1]] : [wall.end[0], wall.end[1]]
|
|
106
|
+
const tangent = getWallCurveFrameAt(wall as WallNode, endpoint === 'start' ? 0 : 1).tangent
|
|
107
|
+
const sign = endpoint === 'start' ? -1 : 1
|
|
108
|
+
const length = Math.hypot(tangent.x, tangent.y)
|
|
109
|
+
if (!(length > 1e-9)) return null
|
|
110
|
+
return { start, reference: [(tangent.x / length) * sign, (tangent.y / length) * sign] }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// What the pull changed for its run, put back when it ends.
|
|
114
|
+
let restore: { shape: ContinuationMode; toolDefaults: Record<string, unknown> | null } | null = null
|
|
115
|
+
let stopWatching: (() => void) | null = null
|
|
116
|
+
let pullSeq = 0
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Start a pull from `endpoint` of wall `wallId`: arm the Walls run at that end
|
|
120
|
+
* with the wall's type. `origin` is the view the grip was pressed in. False
|
|
121
|
+
* when there is no such wall.
|
|
122
|
+
*/
|
|
123
|
+
export function startWallPull(args: {
|
|
124
|
+
wallId: string
|
|
125
|
+
endpoint: 'start' | 'end'
|
|
126
|
+
origin: '2d' | '3d'
|
|
127
|
+
}): boolean {
|
|
128
|
+
const nodes = useScene.getState().nodes
|
|
129
|
+
const wall = nodes[args.wallId as AnyNodeId]
|
|
130
|
+
if (wall?.type !== 'wall' || !wall.parentId) return false
|
|
131
|
+
const level = nodes[wall.parentId as AnyNodeId]
|
|
132
|
+
if (level?.type !== 'level') return false
|
|
133
|
+
const seed = wallPullSeed(wall, args.endpoint)
|
|
134
|
+
if (!seed) return false
|
|
135
|
+
|
|
136
|
+
endWallPullRun()
|
|
137
|
+
const editor = useEditor.getState()
|
|
138
|
+
restore = {
|
|
139
|
+
shape: editor.getContinuation('wall'),
|
|
140
|
+
toolDefaults: (editor.toolDefaults.wall as Record<string, unknown> | undefined) ?? null,
|
|
141
|
+
}
|
|
142
|
+
// The grips belong to a selected wall; drawing goes on without one.
|
|
143
|
+
useViewer.getState().setSelection({ selectedIds: [], levelId: level.id })
|
|
144
|
+
selectWallDrawVariant('walls')
|
|
145
|
+
editor.setToolDefaults('wall', wallPullTypePatch(wall, level.height ?? null))
|
|
146
|
+
editor.armToolMode({ mode: 'build', tool: 'wall' })
|
|
147
|
+
pullSeq += 1
|
|
148
|
+
useWallPullRun.setState({
|
|
149
|
+
run: {
|
|
150
|
+
seq: pullSeq,
|
|
151
|
+
sourceWallId: wall.id as AnyNodeId,
|
|
152
|
+
levelId: wall.parentId as AnyNodeId,
|
|
153
|
+
runStart: seed.start,
|
|
154
|
+
start: seed.start,
|
|
155
|
+
reference: seed.reference,
|
|
156
|
+
origin: args.origin,
|
|
157
|
+
dragging: true,
|
|
158
|
+
},
|
|
159
|
+
})
|
|
160
|
+
watchWallPullRun()
|
|
161
|
+
return true
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function watchWallPullRun() {
|
|
165
|
+
stopWatching?.()
|
|
166
|
+
// Leaving the wall tool (another tool, Select) ends the run.
|
|
167
|
+
const stopEditor = useEditor.subscribe((state) => {
|
|
168
|
+
if (!(state.mode === 'build' && state.tool === 'wall')) endWallPullRun()
|
|
169
|
+
})
|
|
170
|
+
// A release the view did not take as the placing click (off the plan, say)
|
|
171
|
+
// still ends the drag; the run waits for a click.
|
|
172
|
+
const onPointerUp = () => releaseWallPullDrag()
|
|
173
|
+
const target = typeof window === 'undefined' ? null : window
|
|
174
|
+
target?.addEventListener('pointerup', onPointerUp)
|
|
175
|
+
target?.addEventListener('pointercancel', onPointerUp)
|
|
176
|
+
stopWatching = () => {
|
|
177
|
+
stopEditor()
|
|
178
|
+
target?.removeEventListener('pointerup', onPointerUp)
|
|
179
|
+
target?.removeEventListener('pointercancel', onPointerUp)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The grip was let go: the run goes on by clicks. */
|
|
184
|
+
export function releaseWallPullDrag(): void {
|
|
185
|
+
const run = useWallPullRun.getState().run
|
|
186
|
+
if (run?.dragging) useWallPullRun.setState({ run: { ...run, dragging: false } })
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* End the pull run, however its walls ended: back to Select from the wall tool,
|
|
191
|
+
* with the Rooms shape and wall tool defaults the pull replaced. No-op when no
|
|
192
|
+
* run is open.
|
|
193
|
+
*/
|
|
194
|
+
export function endWallPullRun(): void {
|
|
195
|
+
const run = useWallPullRun.getState().run
|
|
196
|
+
if (!run) return
|
|
197
|
+
useWallPullRun.setState({ run: null })
|
|
198
|
+
stopWatching?.()
|
|
199
|
+
stopWatching = null
|
|
200
|
+
publishWallRunFeedback(null)
|
|
201
|
+
const editor = useEditor.getState()
|
|
202
|
+
if (editor.mode === 'build' && editor.tool === 'wall') editor.armToolMode({ mode: 'select' })
|
|
203
|
+
const saved = restore
|
|
204
|
+
restore = null
|
|
205
|
+
if (!saved) return
|
|
206
|
+
const after = useEditor.getState()
|
|
207
|
+
if (after.getContinuation('wall') !== saved.shape) after.setContinuation('wall', saved.shape)
|
|
208
|
+
after.setToolDefaults('wall', saved.toolDefaults)
|
|
209
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test'
|
|
2
|
+
import { clearSceneHistory, nodeRegistry, useScene, WallNode } from '@pascal-app/core'
|
|
3
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
4
|
+
import useAlignmentGuides from '../store/use-alignment-guides'
|
|
5
|
+
import useEditor from '../store/use-editor'
|
|
6
|
+
import useInteractionScope from '../store/use-interaction-scope'
|
|
7
|
+
import { installImmediateAnimationFrames } from '../test-utils/immediate-animation-frames'
|
|
8
|
+
import { seedRoundTripScene } from '../test-utils/live-gesture-round-trip'
|
|
9
|
+
import { cancelGestures } from './gesture-lifecycle'
|
|
10
|
+
import { runWallPushDrag, wallPushHandles } from './room-handle-drag'
|
|
11
|
+
import { nextInStack } from './selection-cycle'
|
|
12
|
+
|
|
13
|
+
// A side arrow push lines the wall up with a parallel wall before the grid, as
|
|
14
|
+
// the wall moves do; Alt pushes it free.
|
|
15
|
+
|
|
16
|
+
const LEVEL = 'level_push_line_up'
|
|
17
|
+
const stubbed: string[] = []
|
|
18
|
+
let stop = () => {}
|
|
19
|
+
let restoreFrames = () => {}
|
|
20
|
+
let registry: { mockRestore: () => void } | null = null
|
|
21
|
+
|
|
22
|
+
const nodes = () => useScene.getState().nodes
|
|
23
|
+
const eastWall = () =>
|
|
24
|
+
Object.values(nodes()).find(
|
|
25
|
+
(n): n is WallNode => n.type === 'wall' && n.start[0] === 6 && n.end[0] === 6,
|
|
26
|
+
)!
|
|
27
|
+
|
|
28
|
+
function pointer(type: 'pointermove' | 'pointerup', clientX: number, altKey = false) {
|
|
29
|
+
window.dispatchEvent(Object.assign(new Event(type), { clientX, clientY: 0, altKey }))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
restoreFrames = installImmediateAnimationFrames()
|
|
34
|
+
if (!globalThis.document) {
|
|
35
|
+
globalThis.document = { body: { style: { cursor: '' } } } as unknown as Document
|
|
36
|
+
stubbed.push('document')
|
|
37
|
+
}
|
|
38
|
+
if (!globalThis.window) {
|
|
39
|
+
globalThis.window = new EventTarget() as Window & typeof globalThis
|
|
40
|
+
stubbed.push('window')
|
|
41
|
+
}
|
|
42
|
+
// The nodes package that registers walls is not loaded here.
|
|
43
|
+
const get = nodeRegistry.get.bind(nodeRegistry)
|
|
44
|
+
registry = spyOn(nodeRegistry, 'get').mockImplementation(((kind: string) =>
|
|
45
|
+
kind === 'wall' ? { snapProfile: 'structural' } : get(kind)) as typeof nodeRegistry.get)
|
|
46
|
+
cancelGestures('cancel')
|
|
47
|
+
useInteractionScope.getState().end()
|
|
48
|
+
useScene.temporal.getState().resume()
|
|
49
|
+
;({ stop } = seedRoundTripScene('building_push_line_up', LEVEL))
|
|
50
|
+
// An outside wall standing apart, 0.3 m east of the room's east wall line.
|
|
51
|
+
const apart = WallNode.parse({
|
|
52
|
+
id: 'wall_apart',
|
|
53
|
+
start: [6.3, 6],
|
|
54
|
+
end: [6.3, 9],
|
|
55
|
+
frontSide: 'exterior',
|
|
56
|
+
backSide: 'interior',
|
|
57
|
+
})
|
|
58
|
+
useScene.getState().applyNodeChanges({ create: [{ node: apart, parentId: LEVEL as never }] })
|
|
59
|
+
useViewer.getState().setSelection({
|
|
60
|
+
buildingId: 'building_push_line_up',
|
|
61
|
+
levelId: LEVEL,
|
|
62
|
+
selectedIds: [],
|
|
63
|
+
})
|
|
64
|
+
useEditor.setState({ phase: 'building', mode: 'select', gridSnapStep: 0.5 })
|
|
65
|
+
useEditor.getState().setSnappingMode('polygon', 'grid')
|
|
66
|
+
clearSceneHistory()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
afterEach(() => {
|
|
70
|
+
cancelGestures('cancel')
|
|
71
|
+
stop()
|
|
72
|
+
registry?.mockRestore()
|
|
73
|
+
registry = null
|
|
74
|
+
restoreFrames()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
afterAll(async () => {
|
|
78
|
+
// Let the click-swallow timers fire before the stubs go.
|
|
79
|
+
await new Promise((resolve) => setTimeout(resolve, 350))
|
|
80
|
+
for (const key of stubbed.splice(0)) delete (globalThis as Record<string, unknown>)[key]
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
function startPush() {
|
|
84
|
+
const handle = wallPushHandles(eastWall(), nodes()).find((h) => h.outward[0] > 0.5)!
|
|
85
|
+
runWallPushDrag({ handle, levelId: LEVEL, from: 0, along: (clientX) => clientX })
|
|
86
|
+
return eastWall().id
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
describe('wall push line-up', () => {
|
|
90
|
+
test('a push near a parallel outside wall lands on its line, guide shown while it holds', () => {
|
|
91
|
+
expect(eastWall().frontSide === 'exterior' || eastWall().backSide === 'exterior').toBe(true)
|
|
92
|
+
const id = startPush()
|
|
93
|
+
pointer('pointermove', 0.24)
|
|
94
|
+
expect(useAlignmentGuides.getState().draft?.lines).toHaveLength(1)
|
|
95
|
+
pointer('pointerup', 0.24)
|
|
96
|
+
expect(useAlignmentGuides.getState().draft).toBeNull()
|
|
97
|
+
const wall = nodes()[id as never] as WallNode
|
|
98
|
+
expect(wall.start[0]).toBeCloseTo(6.3, 9)
|
|
99
|
+
expect(wall.end[0]).toBeCloseTo(6.3, 9)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('away from any line the push still steps on the grid', () => {
|
|
103
|
+
const id = startPush()
|
|
104
|
+
pointer('pointermove', 0.9)
|
|
105
|
+
pointer('pointerup', 0.9)
|
|
106
|
+
expect((nodes()[id as never] as WallNode).start[0]).toBeCloseTo(7, 9)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('Alt pushes it free', () => {
|
|
110
|
+
const id = startPush()
|
|
111
|
+
pointer('pointermove', 0.24, true)
|
|
112
|
+
expect(useAlignmentGuides.getState().draft).toBeNull()
|
|
113
|
+
pointer('pointerup', 0.24, true)
|
|
114
|
+
expect((nodes()[id as never] as WallNode).start[0]).toBeCloseTo(6.24, 9)
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
describe('Tab through stacked objects', () => {
|
|
119
|
+
test('steps to the next one under the pointer and wraps round', () => {
|
|
120
|
+
const stack = ['wall_top', 'wall_inside', 'zone_room']
|
|
121
|
+
expect(nextInStack(stack, [], false)).toBe('wall_top')
|
|
122
|
+
expect(nextInStack(stack, ['wall_top'], false)).toBe('wall_inside')
|
|
123
|
+
expect(nextInStack(stack, ['zone_room'], false)).toBe('wall_top')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('Shift+Tab steps back; a selection elsewhere starts at the top', () => {
|
|
127
|
+
const stack = ['wall_top', 'wall_inside']
|
|
128
|
+
expect(nextInStack(stack, ['wall_top'], true)).toBe('wall_inside')
|
|
129
|
+
expect(nextInStack(stack, ['wall_other'], true)).toBe('wall_top')
|
|
130
|
+
expect(nextInStack([], ['wall_top'], false)).toBeNull()
|
|
131
|
+
})
|
|
132
|
+
})
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type AnyNode,
|
|
3
3
|
getCatalogMaterialById,
|
|
4
|
+
getMaterialSwatch,
|
|
4
5
|
parseMaterialRef,
|
|
5
6
|
roomSideFaces,
|
|
6
7
|
type SceneMaterial,
|
|
@@ -18,7 +19,13 @@ import { getLinearUnitLabel, type LinearUnit, metersToLinearUnit } from './measu
|
|
|
18
19
|
|
|
19
20
|
const FALLBACK_SWATCH_COLOR = '#d4d4d8'
|
|
20
21
|
|
|
21
|
-
export type WallRegionFinish = {
|
|
22
|
+
export type WallRegionFinish = {
|
|
23
|
+
name: string
|
|
24
|
+
color: string
|
|
25
|
+
imageUrl?: string
|
|
26
|
+
/** The image is a white albedo `color` tints: multiply it (a siding's profile). */
|
|
27
|
+
tinted?: boolean
|
|
28
|
+
}
|
|
22
29
|
|
|
23
30
|
export type WallRegionRow = {
|
|
24
31
|
id: string
|
|
@@ -63,11 +70,13 @@ export function describeRegionFinish(
|
|
|
63
70
|
}
|
|
64
71
|
if (parsed?.kind === 'library') {
|
|
65
72
|
const item = getCatalogMaterialById(parsed.id)
|
|
66
|
-
|
|
73
|
+
if (!item) return { name: 'Library finish', color: FALLBACK_SWATCH_COLOR }
|
|
74
|
+
const swatch = getMaterialSwatch(item)
|
|
67
75
|
return {
|
|
68
|
-
name: item
|
|
69
|
-
color:
|
|
70
|
-
...(imageUrl ? { imageUrl } : {}),
|
|
76
|
+
name: item.label,
|
|
77
|
+
color: swatch.color,
|
|
78
|
+
...(swatch.imageUrl ? { imageUrl: swatch.imageUrl } : {}),
|
|
79
|
+
...(swatch.tinted ? { tinted: true } : {}),
|
|
71
80
|
}
|
|
72
81
|
}
|
|
73
82
|
return { name: 'Custom finish', color: FALLBACK_SWATCH_COLOR }
|