@pascal-app/editor 1.0.3 → 1.0.4
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 +11 -9
- package/src/components/editor/action-menu-button.tsx +83 -0
- package/src/components/editor/bake-thumbnail.tsx +315 -4
- package/src/components/editor/capture-level-picker.tsx +72 -0
- package/src/components/editor/custom-camera-controls.tsx +202 -57
- package/src/components/editor/delete-confirmation-dialog.tsx +297 -7
- package/src/components/editor/duplicate-node.ts +244 -0
- package/src/components/editor/editor-layout-mobile.tsx +36 -25
- package/src/components/editor/editor-layout-v2.tsx +131 -58
- package/src/components/editor/export-manager.tsx +34 -8
- package/src/components/editor/first-person/build-collider-world.test.ts +51 -0
- package/src/components/editor/first-person/build-collider-world.ts +6 -2
- package/src/components/editor/first-person/mechanism-targets.test.ts +75 -0
- package/src/components/editor/first-person/mechanism-targets.ts +36 -0
- package/src/components/editor/first-person-controls.tsx +205 -2
- package/src/components/editor/floating-action-menu.tsx +63 -207
- package/src/components/editor/floor-aware-camera-controls.test.ts +442 -0
- package/src/components/editor/floor-aware-camera-controls.ts +324 -0
- package/src/components/editor/floor-openings-overlay.tsx +243 -0
- package/src/components/editor/floor-region-controls.tsx +846 -0
- package/src/components/editor/floor-region-webgpu-materials.test.ts +12 -0
- package/src/components/editor/floor-surface.test.ts +165 -0
- package/src/components/editor/floor-surface.ts +182 -0
- package/src/components/editor/floorplan-background-selection.test.ts +11 -2
- package/src/components/editor/floorplan-background-selection.ts +1 -3
- package/src/components/editor/floorplan-panel.tsx +344 -342
- package/src/components/editor/footprint-height-handle.tsx +204 -0
- package/src/components/editor/group-actions.ts +54 -2
- package/src/components/editor/handles/handle-hit-priority.test.tsx +63 -0
- package/src/components/editor/handles/handle-hit-priority.tsx +37 -0
- package/src/components/editor/handles/swallow-next-click.test.ts +28 -0
- package/src/components/editor/handles/use-handle-drag.ts +269 -197
- package/src/components/editor/index.tsx +294 -169
- package/src/components/editor/join-walls-pill.tsx +44 -0
- package/src/components/editor/node-action-menu.tsx +68 -70
- package/src/components/editor/node-arrow-handles.tsx +25 -6
- package/src/components/editor/open-wall-ends-3d-layer.tsx +273 -0
- package/src/components/editor/open-wall-ends-hint.tsx +89 -0
- package/src/components/editor/quick-measurement-hud.tsx +3 -2
- package/src/components/editor/registry-action-contributions.test.tsx +63 -0
- package/src/components/editor/registry-action-contributions.tsx +80 -1
- package/src/components/editor/room-controls.tsx +847 -0
- package/src/components/editor/room-floor-highlight.tsx +128 -0
- package/src/components/editor/room-handles-2d.tsx +354 -0
- package/src/components/editor/room-handles.tsx +675 -0
- package/src/components/editor/room-highlight.tsx +225 -0
- package/src/components/editor/room-labels.test.ts +357 -0
- package/src/components/editor/room-labels.tsx +990 -0
- package/src/components/editor/room-transform-ghost.tsx +256 -0
- package/src/components/editor/room-webgpu-materials.test.ts +27 -0
- package/src/components/editor/selection-manager.tsx +451 -317
- package/src/components/editor/slab-hole-highlights.tsx +3 -0
- package/src/components/editor/snapshot-capture-overlay.tsx +4 -0
- package/src/components/editor/snapshot-capture.test.ts +52 -4
- package/src/components/editor/snapshot-capture.ts +30 -5
- package/src/components/editor/thumbnail-generator.test.ts +308 -0
- package/src/components/editor/thumbnail-generator.tsx +1035 -229
- package/src/components/editor/use-floorplan-background-placement.ts +31 -11
- package/src/components/editor/vector-edge-extractor.tsx +121 -0
- package/src/components/editor/views/host-view.tsx +61 -0
- package/src/components/editor/views/plugins-view.tsx +670 -0
- package/src/components/editor/views/scene-region.tsx +59 -0
- package/src/components/editor/views/use-editor-views.ts +57 -0
- package/src/components/editor/views/view-bar.tsx +220 -0
- package/src/components/editor/wall-justification.test.ts +140 -0
- package/src/components/editor/wall-move-side-handles.tsx +43 -142
- package/src/components/editor/wall-push-arrow.tsx +92 -0
- package/src/components/editor/wall-region-handles.tsx +509 -0
- package/src/components/editor/wall-snap-beacon-layer.tsx +3 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +8 -4
- package/src/components/editor-2d/floorplan-open-wall-ends-layer.test.tsx +86 -0
- package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +290 -0
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +11 -1
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +113 -102
- package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +156 -0
- package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +278 -0
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +77 -0
- package/src/components/editor-2d/renderers/{floorplan-draft-wall-measurement.tsx → floorplan-draft-measurement.tsx} +8 -8
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +4 -1
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +38 -8
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +291 -227
- package/src/components/editor-2d/svg-paths.ts +12 -15
- package/src/components/procedural-items/inspector.tsx +40 -15
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +97 -14
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +18 -18
- package/src/components/systems/zone/zone-system.test.ts +47 -0
- package/src/components/systems/zone/zone-system.tsx +29 -68
- package/src/components/tools/item/placement-history.test.tsx +474 -0
- package/src/components/tools/item/placement-strategies.ts +8 -4
- package/src/components/tools/item/use-draft-node.test.tsx +432 -1
- package/src/components/tools/item/use-draft-node.ts +166 -95
- package/src/components/tools/item/use-placement-coordinator.tsx +93 -46
- package/src/components/tools/paint-region/use-paint-tint.ts +37 -0
- package/src/components/tools/paint-region/wall-paint-region-tool.tsx +466 -0
- package/src/components/tools/registry/move-registry-node-tool.tsx +3 -1
- package/src/components/tools/select/box-select-tool.tsx +2 -12
- package/src/components/tools/select/plane-box-select-tool.test.tsx +79 -0
- package/src/components/tools/select/plane-box-select-tool.tsx +35 -22
- package/src/components/tools/select/select-candidates.test.ts +37 -4
- package/src/components/tools/select/select-candidates.ts +15 -11
- package/src/components/tools/shared/cursor-sphere.tsx +36 -12
- package/src/components/tools/site/site-boundary-editor.tsx +1 -2
- package/src/components/tools/stair/stair-defaults.ts +0 -3
- package/src/components/tools/stair/stair-tool.tsx +90 -42
- package/src/components/tools/tool-manager.tsx +46 -20
- package/src/components/tools/wall/wall-drafting.test.ts +19 -4
- package/src/components/tools/zone/zone-tool.tsx +4 -12
- package/src/components/ui/action-menu/action-button.tsx +3 -0
- package/src/components/ui/action-menu/camera-actions.tsx +2 -1
- package/src/components/ui/action-menu/control-modes.tsx +2 -35
- package/src/components/ui/action-menu/measurement-control.tsx +3 -10
- package/src/components/ui/build-panel/build-panel.tsx +235 -0
- package/src/components/ui/command-palette/editor-commands.tsx +4 -30
- package/src/components/ui/command-palette/index.tsx +5 -24
- package/src/components/ui/controls/finish-swatch.tsx +110 -0
- package/src/components/ui/controls/material-paint-panel.tsx +97 -26
- package/src/components/ui/controls/material-picker.tsx +4 -1
- package/src/components/ui/controls/scene-material-list.tsx +4 -32
- package/src/components/ui/controls/select-control.tsx +65 -0
- package/src/components/ui/floating-level-selector.tsx +9 -14
- package/src/components/ui/helpers/building-helper.tsx +4 -1
- package/src/components/ui/helpers/contextual-helper-panel.tsx +154 -70
- package/src/components/ui/helpers/helper-manager.test.tsx +69 -0
- package/src/components/ui/helpers/helper-manager.tsx +325 -14
- package/src/components/ui/helpers/item-helper.tsx +8 -0
- package/src/components/ui/helpers/registered-tool-helper.tsx +8 -1
- package/src/components/ui/icon-ref.tsx +45 -1
- package/src/components/ui/level-duplicate-dialog.tsx +1 -1
- package/src/components/ui/node-icon-image.tsx +16 -0
- package/src/components/ui/panels/collections/collections-popover.tsx +20 -14
- package/src/components/ui/panels/floor-foundation-panel.tsx +551 -0
- package/src/components/ui/panels/floor-opening-panel.tsx +143 -0
- package/src/components/ui/panels/mobile-panel-sheet.tsx +2 -2
- package/src/components/ui/panels/mobile-selection-bar.tsx +2 -2
- package/src/components/ui/panels/multi-field-value.test.ts +26 -3
- package/src/components/ui/panels/multi-field-value.ts +6 -3
- package/src/components/ui/panels/multi-parametric-inspector.tsx +52 -2
- package/src/components/ui/panels/multi-selection-panel.tsx +43 -4
- package/src/components/ui/panels/node-display.ts +8 -3
- package/src/components/ui/panels/panel-manager.tsx +103 -68
- package/src/components/ui/panels/panel-wrapper.tsx +78 -36
- package/src/components/ui/panels/parametric-field-control.tsx +1 -1
- package/src/components/ui/panels/parametric-inspector.tsx +4 -4
- package/src/components/ui/panels/reference-panel.tsx +2 -3
- package/src/components/ui/panels/room-construction-rows.tsx +774 -0
- package/src/components/ui/panels/room-name-options.test.ts +55 -0
- package/src/components/ui/panels/room-name-options.ts +47 -0
- package/src/components/ui/panels/room-openings-field.tsx +110 -0
- package/src/components/ui/panels/room-panel.tsx +725 -0
- package/src/components/ui/panels/shape-choice.tsx +58 -0
- package/src/components/ui/panels/wall-region-list.tsx +109 -0
- package/src/components/ui/primitives/color-dot.tsx +36 -24
- package/src/components/ui/primitives/sidebar.tsx +2 -0
- package/src/components/ui/right-stack.test.ts +29 -0
- package/src/components/ui/right-stack.tsx +145 -0
- package/src/components/ui/sidebar/icon-rail.tsx +5 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +31 -4
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +5 -5
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -4
- package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +1 -5
- package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +197 -0
- package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -5
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +1 -5
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +1 -5
- package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -5
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +24 -11
- package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +1 -5
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +176 -211
- package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +30 -8
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +1 -3
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +4 -1
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +1 -3
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +1 -4
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +1 -3
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +21 -13
- package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +1 -5
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -5
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +1 -5
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +0 -27
- package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +2 -3
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -5
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +1 -5
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +9 -4
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +0 -2
- package/src/components/ui/sidebar/tab-bar.tsx +10 -0
- package/src/components/ui/sidebar/use-plugin-panels.tsx +15 -5
- package/src/components/viewer/floorplan-compass-button.tsx +9 -1
- package/src/components/viewer/floorplan-preview.tsx +5 -6
- package/src/components/viewer/viewer-hover-label.tsx +34 -0
- package/src/components/viewer/viewer-rooms.tsx +175 -0
- package/src/components/viewer/viewer-scene-header.tsx +181 -145
- package/src/components/viewer/viewer-selection-manager.tsx +124 -0
- package/src/components/viewer/viewer-stage.tsx +2 -2
- package/src/components/viewer-zone-system.tsx +3 -8
- package/src/hooks/use-auto-save.integration.test.tsx +123 -9
- package/src/hooks/use-auto-save.ts +34 -8
- package/src/hooks/use-keyboard.test.ts +178 -3
- package/src/hooks/use-keyboard.ts +89 -71
- package/src/hooks/use-open-wall-ends.test.ts +99 -0
- package/src/hooks/use-open-wall-ends.ts +238 -0
- package/src/hooks/use-placement-notice.test.ts +48 -0
- package/src/hooks/use-placement-notice.ts +42 -0
- package/src/hooks/use-selected-room.ts +143 -0
- package/src/index.tsx +246 -39
- package/src/lib/ceiling-edit-session.test.ts +321 -0
- package/src/lib/ceiling-edit-session.ts +206 -0
- package/src/lib/ceiling-region-session.test.ts +179 -0
- package/src/lib/continuation.test.ts +19 -1
- package/src/lib/continuation.ts +25 -4
- package/src/lib/door-interaction.ts +14 -0
- package/src/lib/editor-level-display.test.ts +96 -0
- package/src/lib/editor-level-display.ts +48 -0
- package/src/lib/editor-views.ts +144 -0
- package/src/lib/export-geometry-wait.test.ts +90 -0
- package/src/lib/floor-edit-session.ts +153 -0
- package/src/lib/floor-footprints.test.ts +563 -0
- package/src/lib/floor-footprints.ts +395 -0
- package/src/lib/floor-foundation-support.test.ts +283 -0
- package/src/lib/floor-opening-draft.ts +218 -0
- package/src/lib/floor-openings.test.ts +216 -0
- package/src/lib/floor-region-geometry.ts +191 -0
- package/src/lib/floor-region-session.test.ts +485 -0
- package/src/lib/floor-region-session.ts +589 -0
- package/src/lib/floor-region-snap.ts +101 -0
- package/src/lib/floor-step-hover.test.ts +32 -0
- package/src/lib/floorplan/annotation-visibility.ts +17 -0
- package/src/lib/floorplan/dimension-drive.test.ts +360 -0
- package/src/lib/floorplan/dimension-drive.ts +479 -0
- package/src/lib/floorplan/floorplan-export.test.ts +12 -8
- package/src/lib/floorplan/floorplan-export.tsx +32 -8
- package/src/lib/floorplan/floorplan-extension.ts +32 -0
- package/src/lib/floorplan/floorplan-mode.test.ts +4 -0
- package/src/lib/floorplan/floorplan-mode.ts +2 -0
- package/src/lib/floorplan/floorplan-pdfkit-document.ts +339 -18
- package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +138 -8
- package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +56 -25
- package/src/lib/floorplan/floorplan-plugin-install.test.tsx +138 -0
- package/src/lib/floorplan/floorplan-readonly.ts +6 -0
- package/src/lib/floorplan/geometry.ts +13 -6
- package/src/lib/floorplan/index.ts +0 -9
- package/src/lib/floorplan/items.ts +5 -2
- package/src/lib/floorplan/marks.test.ts +213 -0
- package/src/lib/floorplan/marks.ts +238 -0
- package/src/lib/floorplan/open-wall-ends.test.ts +268 -0
- package/src/lib/floorplan/open-wall-ends.ts +162 -0
- package/src/lib/floorplan/pdf-fonts/GeistMono-Bold.ttf +0 -0
- package/src/lib/floorplan/pdf-fonts/GeistMono-Regular.ttf +0 -0
- package/src/lib/floorplan/pdf-fonts/LICENSE-GeistMono.txt +92 -0
- package/src/lib/floorplan/pdf-fonts/LICENSE-LiberationSans.txt +102 -0
- package/src/lib/floorplan/pdf-fonts/LiberationSans-Bold.ttf +0 -0
- package/src/lib/floorplan/pdf-fonts/LiberationSans-Regular.ttf +0 -0
- package/src/lib/floorplan/schedules.test.ts +189 -0
- package/src/lib/floorplan/schedules.ts +339 -0
- package/src/lib/floorplan/sheet-export.test.ts +100 -0
- package/src/lib/floorplan/sheet-export.ts +318 -0
- package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +779 -0
- package/src/lib/floorplan/site-plan/contours.test.ts +43 -0
- package/src/lib/floorplan/site-plan/contributors.ts +97 -0
- package/src/lib/floorplan/site-plan/coverage.ts +232 -0
- package/src/lib/floorplan/site-plan/drawing-type-switch.test.ts +15 -0
- package/src/lib/floorplan/site-plan/drawing-type-switch.tsx +86 -0
- package/src/lib/floorplan/site-plan/front-edge.test.ts +103 -0
- package/src/lib/floorplan/site-plan/front-edge.ts +156 -0
- package/src/lib/floorplan/site-plan/geometry.test.ts +275 -0
- package/src/lib/floorplan/site-plan/geometry.ts +557 -0
- package/src/lib/floorplan/site-plan/index.ts +71 -0
- package/src/lib/floorplan/site-plan/site-annotations.test.ts +366 -0
- package/src/lib/floorplan/site-plan/site-annotations.ts +1083 -0
- package/src/lib/floorplan/site-plan/site-parts.ts +379 -0
- package/src/lib/floorplan/site-plan/site-plan-layer.tsx +138 -0
- package/src/lib/floorplan/site-plan/street-name.test.ts +69 -0
- package/src/lib/floorplan/site-plan/street-name.ts +302 -0
- package/src/lib/floorplan/site-plan/yards.test.ts +69 -0
- package/src/lib/floorplan/types.ts +1 -27
- package/src/lib/fresh-planar-placement.test.ts +36 -0
- package/src/lib/fresh-planar-placement.ts +59 -36
- package/src/lib/geometry-script/author.ts +152 -0
- package/src/lib/geometry-script/client.ts +152 -0
- package/src/lib/geometry-script/protocol.ts +12 -0
- package/src/lib/gesture-lifecycle.test.ts +595 -0
- package/src/lib/gesture-lifecycle.ts +290 -0
- package/src/lib/glb-export-zone-holes.test.ts +41 -0
- package/src/lib/glb-export.test.ts +485 -0
- package/src/lib/glb-export.ts +237 -119
- package/src/lib/history.test.ts +6 -0
- package/src/lib/history.ts +7 -0
- package/src/lib/hud-title.ts +109 -0
- package/src/lib/hydration-gated-storage.ts +49 -0
- package/src/lib/ifc-export.test.ts +174 -0
- package/src/lib/ifc-export.ts +201 -0
- package/src/lib/ifc-parts.ts +15 -0
- package/src/lib/inspector-expanded.test.ts +44 -0
- package/src/lib/inspector-expanded.ts +36 -0
- package/src/lib/interaction/scope.ts +18 -0
- package/src/lib/keyboard-pan.test.ts +129 -7
- package/src/lib/keyboard-pan.ts +161 -12
- package/src/lib/live-gesture-round-trip.test.ts +360 -0
- package/src/lib/lot/__fixtures__/map-dossier-miami-shores.json +1 -0
- package/src/lib/lot/__fixtures__/map-dossier-stpete.json +1 -0
- package/src/lib/lot/clean-ring.test.ts +186 -0
- package/src/lib/lot/clean-ring.ts +217 -0
- package/src/lib/lot/dossier.test.ts +202 -0
- package/src/lib/lot/dossier.ts +498 -0
- package/src/lib/lot/drop-in.test.ts +326 -0
- package/src/lib/lot/drop-in.ts +407 -0
- package/src/lib/lot/index.ts +43 -0
- package/src/lib/lot/lot-patch.test.ts +340 -0
- package/src/lib/lot/lot-patch.ts +394 -0
- package/src/lib/lot/parcel-provider.ts +43 -0
- package/src/lib/lot/terrain.test.ts +161 -0
- package/src/lib/lot/terrain.ts +266 -0
- package/src/lib/material-catalog-model.ts +3 -1
- package/src/lib/material-paint-panel-model.ts +9 -5
- package/src/lib/material-paint.ts +4 -0
- package/src/lib/mezzanine-actions.test.ts +342 -0
- package/src/lib/mezzanine-draft.test.ts +501 -0
- package/src/lib/mezzanine-draft.ts +278 -0
- package/src/lib/mezzanine-messages.ts +26 -0
- package/src/lib/mezzanine-selection.test.ts +207 -0
- package/src/lib/mezzanine-stairs.ts +46 -0
- package/src/lib/model-export.ts +3 -1
- package/src/lib/node-action-movement.ts +17 -6
- package/src/lib/node-display-label.ts +37 -0
- package/src/lib/orbit-floor-clearance.test.ts +165 -0
- package/src/lib/orbit-floor-clearance.ts +106 -0
- package/src/lib/paint-commit-route.test.ts +37 -0
- package/src/lib/paint-commit-route.ts +17 -0
- package/src/lib/paint-eyedropper.test.tsx +165 -0
- package/src/lib/paint-eyedropper.ts +99 -0
- package/src/lib/paint-pass-through.test.ts +111 -0
- package/src/lib/paint-pass-through.ts +76 -0
- package/src/lib/paint-region-hover.ts +25 -0
- package/src/lib/paint-region-mode.ts +154 -0
- package/src/lib/paint-regions.test.ts +166 -0
- package/src/lib/paint-regions.ts +192 -0
- package/src/lib/paint-scope.test.ts +317 -35
- package/src/lib/paint-scope.ts +266 -25
- package/src/lib/panel-rows.ts +21 -0
- package/src/lib/parametric-node-update.ts +1 -1
- package/src/lib/plate-paint-affected.test.ts +300 -0
- package/src/lib/plate-paint-affected.ts +177 -0
- package/src/lib/plugin-chat.ts +50 -0
- package/src/lib/plugin-panels.test.ts +25 -0
- package/src/lib/plugin-panels.ts +94 -0
- package/src/lib/print-shell-compiler.ts +1 -0
- package/src/lib/registered-draft-snap.ts +12 -0
- package/src/lib/roof-duplication.ts +10 -2
- package/src/lib/room-assembly-overlay.test.ts +275 -0
- package/src/lib/room-assembly-overlay.ts +266 -0
- package/src/lib/room-built-on.test.ts +379 -0
- package/src/lib/room-built-on.ts +138 -0
- package/src/lib/room-construction-commands.ts +712 -0
- package/src/lib/room-construction.test.ts +746 -0
- package/src/lib/room-construction.ts +385 -0
- package/src/lib/room-controls-phase6.test.ts +630 -0
- package/src/lib/room-divide-session.ts +523 -0
- package/src/lib/room-handle-drag.ts +1068 -0
- package/src/lib/room-name-catalog.test.ts +90 -0
- package/src/lib/room-name-catalog.ts +168 -0
- package/src/lib/room-push-dimensions.test.ts +311 -0
- package/src/lib/room-push-dimensions.ts +251 -0
- package/src/lib/room-selection-commands.ts +62 -0
- package/src/lib/room-selection.test.ts +973 -0
- package/src/lib/room-selection.ts +417 -0
- package/src/lib/room-structure-commands.ts +94 -0
- package/src/lib/room-structure.test.ts +921 -0
- package/src/lib/room-transform-session.ts +570 -0
- package/src/lib/room-zone-routing.test.ts +294 -0
- package/src/lib/room-zone-routing.ts +197 -0
- package/src/lib/scene-clipboard.test.ts +364 -0
- package/src/lib/scene-clipboard.ts +229 -48
- package/src/lib/scene-material-refs.test.ts +79 -0
- package/src/lib/scene-material-refs.ts +79 -0
- package/src/lib/scene.test.ts +24 -36
- package/src/lib/scene.ts +33 -57
- package/src/lib/selection-routing.test.ts +129 -45
- package/src/lib/selection-routing.ts +53 -74
- package/src/lib/session-writes.test.ts +120 -0
- package/src/lib/session-writes.ts +205 -0
- package/src/lib/sidebar-panel.ts +62 -0
- package/src/lib/site-boundary.test.ts +4 -6
- package/src/lib/site-boundary.ts +1 -1
- package/src/lib/snapping-mode.test.ts +4 -0
- package/src/lib/snapping-mode.ts +8 -1
- package/src/lib/terrace-draft.test.ts +145 -0
- package/src/lib/terrace-draft.ts +213 -0
- package/src/lib/tool-cursor-icon.test.ts +16 -0
- package/src/lib/tool-cursor-icon.ts +11 -0
- package/src/lib/typing-target.ts +9 -0
- package/src/lib/units.ts +77 -39
- package/src/lib/vector-edges.test.ts +58 -0
- package/src/lib/vector-edges.ts +400 -0
- package/src/lib/viewer-selection.test.ts +577 -0
- package/src/lib/viewer-selection.ts +569 -0
- package/src/lib/wall-draw-variant.test.ts +71 -0
- package/src/lib/wall-draw-variant.ts +91 -0
- package/src/lib/wall-paint-region-session.ts +246 -0
- package/src/lib/wall-paint-region.test.ts +373 -0
- package/src/lib/wall-polygon-draft.test.ts +262 -0
- package/src/lib/wall-polygon-draft.ts +286 -0
- package/src/lib/wall-push.test.ts +453 -0
- package/src/lib/wall-region-face.ts +352 -0
- package/src/lib/wall-region-handles.test.ts +304 -0
- package/src/lib/wall-region-handles.ts +368 -0
- package/src/lib/wall-region-list.test.ts +119 -0
- package/src/lib/wall-region-list.ts +136 -0
- package/src/lib/wall-region-snap.ts +110 -0
- package/src/lib/window-interaction.ts +14 -0
- package/src/lib/zone-content.ts +4 -1
- package/src/lib/zone-name.test.ts +17 -0
- package/src/lib/zone-name.ts +17 -0
- package/src/store/opens-in-select-mode.test.ts +223 -0
- package/src/store/paint-mode.test.ts +188 -0
- package/src/store/terrain-sculpt-mode.test.ts +19 -21
- package/src/store/tool-mode.test.ts +137 -6
- package/src/store/use-delete-confirmation.ts +28 -1
- package/src/store/use-drawing-view.test.ts +24 -3
- package/src/store/use-drawing-view.ts +29 -1
- package/src/store/use-editor.tsx +432 -321
- package/src/store/use-floorplan-draft-preview.ts +13 -0
- package/src/store/use-floorplan-marquee.ts +12 -2
- package/src/store/use-quick-measurement-hud.ts +3 -3
- package/src/store/use-stair-build-preview.ts +13 -5
- package/src/store/use-wall-move-ghosts.ts +4 -2
- package/src/store/use-wall-region-handles-selection.test.ts +222 -0
- package/src/store/use-wall-region-handles-selection.ts +139 -0
- package/src/store/view-layout.test.ts +175 -0
- package/src/store/view-layout.ts +196 -0
- package/src/test-utils/immediate-animation-frames.ts +16 -0
- package/src/test-utils/live-gesture-round-trip.ts +224 -0
- package/src/test-utils/selection-harness.ts +82 -0
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +0 -499
- package/src/components/systems/zone/zone-label-editor-system.tsx +0 -333
- package/src/components/tools/fence/fence-drafting.test.ts +0 -100
- package/src/components/tools/fence/fence-drafting.ts +0 -266
- package/src/components/ui/sidebar/panels/plugins-panel.tsx +0 -218
- package/src/components/viewer/viewer-units-panel.tsx +0 -116
- package/src/lib/floorplan/stairs.ts +0 -489
- package/src/lib/level-duplication.test.ts +0 -144
- package/src/lib/level-duplication.ts +0 -195
|
@@ -0,0 +1,1068 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AnyNode,
|
|
3
|
+
type AnyNodeId,
|
|
4
|
+
createLevelStructurePreview,
|
|
5
|
+
DEFAULT_LEVEL_HEIGHT,
|
|
6
|
+
floorPlateGestureMinimum,
|
|
7
|
+
generateId,
|
|
8
|
+
getStoredLevelHeight,
|
|
9
|
+
getWallBaseElevationForNodes,
|
|
10
|
+
getWallCurveFrameAt,
|
|
11
|
+
getWallCurveLength,
|
|
12
|
+
getWallEffectiveHeightForNodes,
|
|
13
|
+
getWallFaceOffsets,
|
|
14
|
+
groundFloorConstruction,
|
|
15
|
+
type NodeChange,
|
|
16
|
+
type Point,
|
|
17
|
+
planWallDivision,
|
|
18
|
+
resolveMovedWallSupportSlabPatch,
|
|
19
|
+
runAsSingleSceneHistoryStep,
|
|
20
|
+
type StructureNodes,
|
|
21
|
+
type StructurePlan,
|
|
22
|
+
setRoomFloorConstruction,
|
|
23
|
+
setWallGeometry,
|
|
24
|
+
setZoneEdges,
|
|
25
|
+
setZoneIntent,
|
|
26
|
+
upperFloorHeightControl,
|
|
27
|
+
useLiveNodeOverrides,
|
|
28
|
+
useScene,
|
|
29
|
+
type WallNode,
|
|
30
|
+
} from '@pascal-app/core'
|
|
31
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
32
|
+
import type { Ray } from 'three'
|
|
33
|
+
import { create } from 'zustand'
|
|
34
|
+
import { ARROW_COLOR } from '../components/editor/handles/handle-arrow'
|
|
35
|
+
import { resolveResizeSnapValue } from '../components/editor/handles/resize-snap'
|
|
36
|
+
import { swallowNextClick } from '../components/editor/handles/use-handle-drag'
|
|
37
|
+
import { getRoomSelectionIndex } from '../hooks/use-selected-room'
|
|
38
|
+
import useEditor, { isGridSnapActive } from '../store/use-editor'
|
|
39
|
+
import { useWallMoveGhosts } from '../store/use-wall-move-ghosts'
|
|
40
|
+
import { footprintHeightPatch, footprintHeightValue } from './floor-footprints'
|
|
41
|
+
import { clientToPlan } from './floorplan/plan-coords'
|
|
42
|
+
import { beginGesture } from './gesture-lifecycle'
|
|
43
|
+
import { isHistoryShortcut } from './history'
|
|
44
|
+
import { MEZZANINE_FAILED_MESSAGE, mezzanineConflictMessage } from './mezzanine-messages'
|
|
45
|
+
import { roomOwnPlate } from './room-built-on'
|
|
46
|
+
import {
|
|
47
|
+
mezzanineEdgeDimensions,
|
|
48
|
+
type RoomDimension,
|
|
49
|
+
wallPushDimensionRooms,
|
|
50
|
+
wallPushDimensions,
|
|
51
|
+
} from './room-push-dimensions'
|
|
52
|
+
import { applyRoomPlan } from './room-structure-commands'
|
|
53
|
+
import { sfxEmitter } from './sfx-bus'
|
|
54
|
+
import { type SpatialPointerId, spatialPointerInput } from './spatial-pointer-input'
|
|
55
|
+
import { isTypingTarget } from './typing-target'
|
|
56
|
+
|
|
57
|
+
export const ROOM_ELEVATION_DRAG_LABEL = 'room-elevation'
|
|
58
|
+
/** Scope label of every wall push: a selected wall's side arrow and a room's boundary arrow. */
|
|
59
|
+
export const WALL_PUSH_DRAG_LABEL = 'wall-push'
|
|
60
|
+
/** Scope label of a mezzanine edge push (its plate grows or shrinks). */
|
|
61
|
+
export const MEZZANINE_EDGE_DRAG_LABEL = 'mezzanine-edge'
|
|
62
|
+
|
|
63
|
+
/** A wall a push would create (a moved span piece, a bridge), drawn as a ghost. */
|
|
64
|
+
export type RoomPushGhost = { start: Point; end: Point; thickness: number; height: number }
|
|
65
|
+
|
|
66
|
+
/** The drag in flight, read by the preview overlays (handles hide while it runs). */
|
|
67
|
+
export type RoomHandleDrag =
|
|
68
|
+
| {
|
|
69
|
+
kind: 'elevation'
|
|
70
|
+
zoneId: string
|
|
71
|
+
levelId: string
|
|
72
|
+
/** Handle spot and the room's outline, in level plan coordinates. */
|
|
73
|
+
anchor: Point
|
|
74
|
+
outline: Point[][]
|
|
75
|
+
from: number
|
|
76
|
+
value: number
|
|
77
|
+
}
|
|
78
|
+
| {
|
|
79
|
+
kind: 'push'
|
|
80
|
+
/** The room whose arrow started it; absent for a selected wall's own arrow. */
|
|
81
|
+
zoneId?: string
|
|
82
|
+
levelId: string
|
|
83
|
+
wallId: string
|
|
84
|
+
/** Arrow position and outward direction at rest, in level plan coordinates. */
|
|
85
|
+
anchor: Point
|
|
86
|
+
height: number
|
|
87
|
+
/** Base elevation of the pushed wall, where its ghost pieces stand. */
|
|
88
|
+
base: number
|
|
89
|
+
outward: Point
|
|
90
|
+
distance: number
|
|
91
|
+
ghosts: RoomPushGhost[]
|
|
92
|
+
/** The pushed rooms' live widths, moving face to each facing parallel wall. */
|
|
93
|
+
dimensions: RoomDimension[]
|
|
94
|
+
message?: string
|
|
95
|
+
}
|
|
96
|
+
| {
|
|
97
|
+
kind: 'mezzanine-edge'
|
|
98
|
+
zoneId: string
|
|
99
|
+
levelId: string
|
|
100
|
+
/** The plate's walking surface, where the preview outline is drawn. */
|
|
101
|
+
elevation: number
|
|
102
|
+
/** The mezzanine outline the push would leave, in level plan coordinates. */
|
|
103
|
+
outline: Point[]
|
|
104
|
+
anchor: Point
|
|
105
|
+
outward: Point
|
|
106
|
+
distance: number
|
|
107
|
+
/** The mezzanine's live widths, moving edge to each facing parallel edge. */
|
|
108
|
+
dimensions: RoomDimension[]
|
|
109
|
+
message?: string
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export const useRoomHandleDrag = create<{ drag: RoomHandleDrag | null }>(() => ({ drag: null }))
|
|
113
|
+
|
|
114
|
+
type Nodes = Record<string, AnyNode>
|
|
115
|
+
|
|
116
|
+
/** The walking surface of a room: its own elevation, else its plate's. */
|
|
117
|
+
export function roomFloorElevation(nodes: Nodes, zoneId: string): number {
|
|
118
|
+
const zone = nodes[zoneId]
|
|
119
|
+
if (zone?.type !== 'zone') return 0
|
|
120
|
+
const own = roomOwnPlate(nodes, zoneId)
|
|
121
|
+
if (own) return own.elevation
|
|
122
|
+
if (typeof zone.floor?.elevation === 'number') return zone.floor.elevation
|
|
123
|
+
const plate = Object.values(nodes).find(
|
|
124
|
+
(node) => node.type === 'slab' && node.zoneIds?.includes(zoneId),
|
|
125
|
+
)
|
|
126
|
+
return plate?.type === 'slab' ? (plate.elevation ?? 0.05) : 0.05
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The stretch of a wall a push moves: `[0, 1]` is the whole wall (its
|
|
131
|
+
* neighbours follow through the junction planner), anything shorter is split
|
|
132
|
+
* off first and only that piece moves.
|
|
133
|
+
*/
|
|
134
|
+
export type WallPushSpan = { wallId: string; t0: number; t1: number }
|
|
135
|
+
|
|
136
|
+
/** One push/pull arrow: a selected wall's side arrow or a room's boundary arrow. */
|
|
137
|
+
export type WallPushHandle = WallPushSpan & {
|
|
138
|
+
key: string
|
|
139
|
+
/** Arrow position (plan) just outside the wall face it points out of. */
|
|
140
|
+
position: Point
|
|
141
|
+
/** Arrow height in the level frame (wall base included). */
|
|
142
|
+
height: number
|
|
143
|
+
outward: Point
|
|
144
|
+
/** The wall's reference line as a coordinate along `outward` (grid snap lands it on the lattice). */
|
|
145
|
+
line: number
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const HANDLE_OFFSET = 0.27
|
|
149
|
+
const HANDLE_MIN_OFFSET = 0.33
|
|
150
|
+
const HANDLE_MIN_HEIGHT = 0.4
|
|
151
|
+
const HANDLE_TOP_INSET = 0.08
|
|
152
|
+
const WHOLE = 1e-4
|
|
153
|
+
|
|
154
|
+
/** The arrow for `[t0, t1]` of `wall`, outside its `side` face and pointing out of it. */
|
|
155
|
+
function wallPushHandle(
|
|
156
|
+
nodes: Nodes,
|
|
157
|
+
wall: WallNode,
|
|
158
|
+
span: { t0: number; t1: number },
|
|
159
|
+
side: 'a' | 'b',
|
|
160
|
+
key: string,
|
|
161
|
+
): WallPushHandle | null {
|
|
162
|
+
if (Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) < 1e-6) return null
|
|
163
|
+
const frame = getWallCurveFrameAt(wall, (span.t0 + span.t1) / 2)
|
|
164
|
+
const outward: Point =
|
|
165
|
+
side === 'a' ? [frame.normal.x, frame.normal.y] : [-frame.normal.x, -frame.normal.y]
|
|
166
|
+
const offsets = getWallFaceOffsets(wall)
|
|
167
|
+
const face = side === 'a' ? offsets.a : -offsets.b
|
|
168
|
+
const offset = Math.max(face + HANDLE_OFFSET, HANDLE_MIN_OFFSET)
|
|
169
|
+
const wallHeight = getWallEffectiveHeightForNodes(wall, nodes)
|
|
170
|
+
return {
|
|
171
|
+
key,
|
|
172
|
+
wallId: wall.id,
|
|
173
|
+
t0: span.t0,
|
|
174
|
+
t1: span.t1,
|
|
175
|
+
position: [frame.point.x + outward[0] * offset, frame.point.y + outward[1] * offset],
|
|
176
|
+
height:
|
|
177
|
+
getWallBaseElevationForNodes(wall, nodes) +
|
|
178
|
+
Math.max(wallHeight - HANDLE_TOP_INSET, HANDLE_MIN_HEIGHT),
|
|
179
|
+
outward,
|
|
180
|
+
line:
|
|
181
|
+
((wall.start[0] + wall.end[0]) / 2) * outward[0] +
|
|
182
|
+
((wall.start[1] + wall.end[1]) / 2) * outward[1],
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** A selected wall's two side arrows (front, back): each pushes the whole wall. */
|
|
187
|
+
export function wallPushHandles(wall: WallNode, nodes: Nodes): WallPushHandle[] {
|
|
188
|
+
return (['a', 'b'] as const).flatMap((side) => {
|
|
189
|
+
const handle = wallPushHandle(nodes, wall, { t0: 0, t1: 1 }, side, `${wall.id}:${side}`)
|
|
190
|
+
return handle ? [handle] : []
|
|
191
|
+
})
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* One push/pull arrow per boundary wall span of the room, outside the wall's
|
|
196
|
+
* far face, pointing away from the room — where the wall's own side arrow for
|
|
197
|
+
* that face sits. `face` is the wall face the room lies on (`a` = front).
|
|
198
|
+
*/
|
|
199
|
+
export function roomPushHandles(
|
|
200
|
+
nodes: Nodes,
|
|
201
|
+
spans: ReadonlyArray<{
|
|
202
|
+
boundaryId: string
|
|
203
|
+
kind: string
|
|
204
|
+
face: 'a' | 'b'
|
|
205
|
+
t0: number
|
|
206
|
+
t1: number
|
|
207
|
+
}>,
|
|
208
|
+
): WallPushHandle[] {
|
|
209
|
+
return spans.flatMap((span) => {
|
|
210
|
+
const wall = nodes[span.boundaryId]
|
|
211
|
+
if (span.kind !== 'wall' || wall?.type !== 'wall') return []
|
|
212
|
+
const handle = wallPushHandle(
|
|
213
|
+
nodes,
|
|
214
|
+
wall,
|
|
215
|
+
span,
|
|
216
|
+
span.face === 'a' ? 'b' : 'a',
|
|
217
|
+
`${span.boundaryId}:${span.face}:${span.t0.toFixed(4)}`,
|
|
218
|
+
)
|
|
219
|
+
return handle ? [handle] : []
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Snaps a push the way the wall move always has: the wall's reference line
|
|
225
|
+
* (not the travel) lands on the grid lattice along its normal, so an off-grid
|
|
226
|
+
* wall steps onto grid lines. Free while grid snapping is off.
|
|
227
|
+
*/
|
|
228
|
+
export function snapPushDistance(line: number, raw: number): number {
|
|
229
|
+
return (
|
|
230
|
+
resolveResizeSnapValue({
|
|
231
|
+
rawValue: line + raw,
|
|
232
|
+
gridSnapEnabled: true,
|
|
233
|
+
gridSnapActive: isGridSnapActive(),
|
|
234
|
+
gridSnapStep: useEditor.getState().gridSnapStep,
|
|
235
|
+
magneticSnapActive: false,
|
|
236
|
+
}) - line
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Moves a room's floor to `elevation`. A room on its own floor has no height
|
|
242
|
+
* of its own: its plate moves, through the room floor construction command the
|
|
243
|
+
* panel and MCP share — upstairs by its thickness; on the ground by its
|
|
244
|
+
* foundation (the slab stays on it), never below on the ground.
|
|
245
|
+
*/
|
|
246
|
+
export function planRoomElevation(nodes: StructureNodes, zoneId: string, elevation: number) {
|
|
247
|
+
const own = roomOwnPlate(nodes, zoneId)
|
|
248
|
+
if (!own) return setZoneIntent(nodes, { zoneId, patch: { floor: { elevation } } })
|
|
249
|
+
const patch = upperFloorHeightControl(nodes, own)
|
|
250
|
+
? footprintHeightPatch(nodes, own, elevation)
|
|
251
|
+
: footprintHeightPatch(
|
|
252
|
+
nodes,
|
|
253
|
+
own,
|
|
254
|
+
elevation - own.thickness - groundFloorConstruction(nodes, own).grade,
|
|
255
|
+
)
|
|
256
|
+
return setRoomFloorConstruction(nodes, { zoneId, slabId: own.id, patch })
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
type Scratch = Record<string, AnyNode>
|
|
260
|
+
|
|
261
|
+
function applyChanges(scratch: Scratch, changes: readonly NodeChange[]): Scratch {
|
|
262
|
+
const next = { ...scratch }
|
|
263
|
+
for (const change of changes) {
|
|
264
|
+
if (change.op === 'delete') delete next[change.id]
|
|
265
|
+
else if (change.op === 'create') next[change.node.id] = change.node
|
|
266
|
+
else if (next[change.id]) next[change.id] = { ...next[change.id], ...change.data } as AnyNode
|
|
267
|
+
}
|
|
268
|
+
return next
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function diffNodes(before: Scratch, after: Scratch): NodeChange[] {
|
|
272
|
+
const changes: NodeChange[] = []
|
|
273
|
+
for (const node of Object.values(after)) {
|
|
274
|
+
const previous = before[node.id]
|
|
275
|
+
if (!previous) changes.push({ op: 'create', node })
|
|
276
|
+
else if (previous !== node) {
|
|
277
|
+
const data: Record<string, unknown> = {}
|
|
278
|
+
for (const key of new Set([...Object.keys(previous), ...Object.keys(node)]))
|
|
279
|
+
if (
|
|
280
|
+
JSON.stringify(previous[key as keyof AnyNode]) !==
|
|
281
|
+
JSON.stringify(node[key as keyof AnyNode])
|
|
282
|
+
)
|
|
283
|
+
data[key] = node[key as keyof AnyNode]
|
|
284
|
+
if (Object.keys(data).length)
|
|
285
|
+
changes.push({ op: 'update', id: node.id as AnyNodeId, data: data as Partial<AnyNode> })
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
for (const id of Object.keys(before))
|
|
289
|
+
if (!after[id]) changes.push({ op: 'delete', id: id as AnyNodeId })
|
|
290
|
+
return changes
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Pushes the room's stretch of a wall `distance` along `outward`. A span that
|
|
295
|
+
* covers the whole wall moves the wall; a partial span is split off at its
|
|
296
|
+
* ends first (hosted openings stay on their pieces) and only that piece moves,
|
|
297
|
+
* bridged back to the rest by the junction planner. One plan, one undo step.
|
|
298
|
+
*/
|
|
299
|
+
export function planRoomPush(
|
|
300
|
+
nodes: StructureNodes,
|
|
301
|
+
span: WallPushSpan,
|
|
302
|
+
outward: Point,
|
|
303
|
+
distance: number,
|
|
304
|
+
mintId: (kind: 'zone' | 'wall' | 'separator') => string = generateId,
|
|
305
|
+
): StructurePlan {
|
|
306
|
+
const wall = nodes[span.wallId]
|
|
307
|
+
if (wall?.type !== 'wall') throw Error('Select a wall.')
|
|
308
|
+
const shift = ([x, z]: Point): Point => [x + outward[0] * distance, z + outward[1] * distance]
|
|
309
|
+
const whole = span.t0 <= WHOLE && span.t1 >= 1 - WHOLE
|
|
310
|
+
if (whole)
|
|
311
|
+
return setWallGeometry(nodes, {
|
|
312
|
+
wallId: wall.id,
|
|
313
|
+
start: shift(wall.start),
|
|
314
|
+
end: shift(wall.end),
|
|
315
|
+
mintId,
|
|
316
|
+
})
|
|
317
|
+
let scratch: Scratch = { ...nodes }
|
|
318
|
+
let targetId = wall.id as WallNode['id']
|
|
319
|
+
const length = getWallCurveLength(wall)
|
|
320
|
+
try {
|
|
321
|
+
// Far end first: the original id keeps the part before each cut, so the
|
|
322
|
+
// piece created by the second cut is exactly [t0, t1].
|
|
323
|
+
for (const t of [span.t1, span.t0]) {
|
|
324
|
+
if (t <= WHOLE || t >= 1 - WHOLE) continue
|
|
325
|
+
const division = planWallDivision(
|
|
326
|
+
scratch as Record<AnyNodeId, AnyNode>,
|
|
327
|
+
wall.id as WallNode['id'],
|
|
328
|
+
t * length,
|
|
329
|
+
() => mintId('wall'),
|
|
330
|
+
)
|
|
331
|
+
for (const { node, parentId } of division.changes.create)
|
|
332
|
+
scratch[node.id] = { ...node, parentId: parentId ?? node.parentId } as AnyNode
|
|
333
|
+
for (const id of division.changes.delete) delete scratch[id]
|
|
334
|
+
for (const { id, data } of division.changes.update)
|
|
335
|
+
if (scratch[id]) scratch[id] = { ...scratch[id], ...data } as AnyNode
|
|
336
|
+
if (t === span.t0) targetId = division.changes.create[0]!.node.id as WallNode['id']
|
|
337
|
+
}
|
|
338
|
+
} catch (error) {
|
|
339
|
+
return {
|
|
340
|
+
changes: [],
|
|
341
|
+
conflicts: [
|
|
342
|
+
{
|
|
343
|
+
code: 'occupied-split',
|
|
344
|
+
nodeIds: [wall.id],
|
|
345
|
+
message: error instanceof Error ? error.message : String(error),
|
|
346
|
+
},
|
|
347
|
+
],
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
const piece = scratch[targetId] as WallNode
|
|
351
|
+
const moved = setWallGeometry(scratch, {
|
|
352
|
+
wallId: piece.id,
|
|
353
|
+
start: shift(piece.start),
|
|
354
|
+
end: shift(piece.end),
|
|
355
|
+
mintId,
|
|
356
|
+
})
|
|
357
|
+
if (moved.conflicts?.length) return moved
|
|
358
|
+
return { changes: diffNodes(nodes, applyChanges(scratch, moved.changes)) }
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function previewMint() {
|
|
362
|
+
let n = 0
|
|
363
|
+
return (kind: 'zone' | 'wall' | 'separator') => `${kind}_pushpreview${n++}`
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
let previewIds: string[] = []
|
|
367
|
+
let planGhosts = false
|
|
368
|
+
// One structure previewer per drag: it keeps its own draft, and the scene is
|
|
369
|
+
// untouched until the commit, so it is rebuilt only if the scene moves on.
|
|
370
|
+
let surfacePreview: {
|
|
371
|
+
levelId: string
|
|
372
|
+
nodes: Nodes
|
|
373
|
+
run: ReturnType<typeof createLevelStructurePreview>
|
|
374
|
+
} | null = null
|
|
375
|
+
|
|
376
|
+
function clearPushPreview() {
|
|
377
|
+
const overrides = useLiveNodeOverrides.getState()
|
|
378
|
+
for (const id of previewIds) {
|
|
379
|
+
overrides.clear(id as AnyNodeId)
|
|
380
|
+
useScene.getState().markDirty(id as AnyNodeId)
|
|
381
|
+
}
|
|
382
|
+
previewIds = []
|
|
383
|
+
if (planGhosts) useWallMoveGhosts.getState().clear()
|
|
384
|
+
planGhosts = false
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Room floors, ceilings and plates reshaped around the pushed walls, as override patches. */
|
|
388
|
+
function previewSurfaces(nodes: Nodes, levelId: string, after: Scratch) {
|
|
389
|
+
if (surfacePreview?.levelId !== levelId || surfacePreview.nodes !== nodes)
|
|
390
|
+
surfacePreview = { levelId, nodes, run: createLevelStructurePreview(levelId, nodes) }
|
|
391
|
+
const walls = Object.values(after).filter(
|
|
392
|
+
(node): node is WallNode => node.type === 'wall' && node.parentId === levelId,
|
|
393
|
+
)
|
|
394
|
+
return surfacePreview
|
|
395
|
+
.run(walls)
|
|
396
|
+
.map((patch) => [patch.id as AnyNodeId, patch.data as Partial<AnyNode>] as const)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Shows the push live: the plan's updates to existing walls — and the room
|
|
401
|
+
* surfaces reshaped around them — ride `useLiveNodeOverrides` (renderers merge
|
|
402
|
+
* them), and the walls it would create come back as ghosts. Returns the
|
|
403
|
+
* conflict code, if any.
|
|
404
|
+
*/
|
|
405
|
+
export function previewRoomPush(
|
|
406
|
+
span: WallPushSpan,
|
|
407
|
+
outward: Point,
|
|
408
|
+
distance: number,
|
|
409
|
+
): { code?: string; ghosts: RoomPushGhost[] } {
|
|
410
|
+
clearPushPreview()
|
|
411
|
+
if (Math.abs(distance) < 1e-6) return { ghosts: [] }
|
|
412
|
+
const nodes = useScene.getState().nodes
|
|
413
|
+
let plan: StructurePlan
|
|
414
|
+
try {
|
|
415
|
+
plan = planRoomPush(nodes, span, outward, distance, previewMint())
|
|
416
|
+
} catch {
|
|
417
|
+
return { code: 'invalid-geometry', ghosts: [] }
|
|
418
|
+
}
|
|
419
|
+
if (plan.conflicts?.length) return { code: plan.conflicts[0]!.code, ghosts: [] }
|
|
420
|
+
const updates: Array<readonly [AnyNodeId, Partial<AnyNode>]> = plan.changes.flatMap((change) =>
|
|
421
|
+
change.op === 'update' ? [[change.id as AnyNodeId, change.data] as const] : [],
|
|
422
|
+
)
|
|
423
|
+
const levelId = nodes[span.wallId as AnyNodeId]?.parentId
|
|
424
|
+
if (levelId) updates.push(...previewSurfaces(nodes, levelId, applyChanges(nodes, plan.changes)))
|
|
425
|
+
useLiveNodeOverrides
|
|
426
|
+
.getState()
|
|
427
|
+
.setMany(updates.map(([id, data]) => [id, data] as [AnyNodeId, Partial<AnyNode>]))
|
|
428
|
+
previewIds = updates.map(([id]) => id)
|
|
429
|
+
for (const id of previewIds) useScene.getState().markDirty(id as AnyNodeId)
|
|
430
|
+
const ghosts = plan.changes.flatMap((change): RoomPushGhost[] =>
|
|
431
|
+
change.op === 'create' && change.node.type === 'wall'
|
|
432
|
+
? [
|
|
433
|
+
{
|
|
434
|
+
start: change.node.start,
|
|
435
|
+
end: change.node.end,
|
|
436
|
+
thickness: change.node.thickness ?? 0.1,
|
|
437
|
+
height: getWallEffectiveHeightForNodes(change.node, nodes),
|
|
438
|
+
},
|
|
439
|
+
]
|
|
440
|
+
: [],
|
|
441
|
+
)
|
|
442
|
+
// The floor plan draws the same pieces through its bridge-ghost layer.
|
|
443
|
+
if (ghosts.length) {
|
|
444
|
+
useWallMoveGhosts.getState().setBridges(
|
|
445
|
+
ghosts.map((ghost, index) => ({
|
|
446
|
+
id: `wall-push:${index}`,
|
|
447
|
+
start: ghost.start,
|
|
448
|
+
end: ghost.end,
|
|
449
|
+
thickness: ghost.thickness,
|
|
450
|
+
color: ARROW_COLOR,
|
|
451
|
+
})),
|
|
452
|
+
)
|
|
453
|
+
planGhosts = true
|
|
454
|
+
}
|
|
455
|
+
return { ghosts }
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
export function endRoomPushPreview() {
|
|
459
|
+
clearPushPreview()
|
|
460
|
+
surfacePreview = null
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Commits a push as one undo step; false when the planner refuses. The walls
|
|
465
|
+
* it moved or created then re-elect the slab they stand on, as a wall move
|
|
466
|
+
* always has, inside the same step.
|
|
467
|
+
*/
|
|
468
|
+
export function commitRoomPush(span: WallPushSpan, outward: Point, distance: number) {
|
|
469
|
+
endRoomPushPreview()
|
|
470
|
+
if (Math.abs(distance) < 1e-6) return true
|
|
471
|
+
try {
|
|
472
|
+
return runAsSingleSceneHistoryStep(useScene, () => {
|
|
473
|
+
const plan = planRoomPush(useScene.getState().nodes, span, outward, distance)
|
|
474
|
+
if (!applyRoomPlan(plan)) return false
|
|
475
|
+
const nodes = useScene.getState().nodes
|
|
476
|
+
const patches = plan.changes.flatMap((change) => {
|
|
477
|
+
const wall = nodes[change.op === 'create' ? change.node.id : change.id]
|
|
478
|
+
if (change.op === 'delete' || wall?.type !== 'wall') return []
|
|
479
|
+
const patch = resolveMovedWallSupportSlabPatch(wall, nodes)
|
|
480
|
+
return patch.supportSlabId === wall.supportSlabId
|
|
481
|
+
? []
|
|
482
|
+
: [{ id: wall.id as AnyNodeId, data: patch as Partial<AnyNode> }]
|
|
483
|
+
})
|
|
484
|
+
if (patches.length) useScene.getState().updateNodes(patches)
|
|
485
|
+
return true
|
|
486
|
+
})
|
|
487
|
+
} catch {
|
|
488
|
+
return false
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** Commits a floor elevation as one undo step. */
|
|
493
|
+
/**
|
|
494
|
+
* The floor heights a mezzanine may take (core refuses the rest): above its own
|
|
495
|
+
* plate thickness and at least 0.3 m under the storey top. Null for other rooms.
|
|
496
|
+
*/
|
|
497
|
+
export function mezzanineElevationBounds(
|
|
498
|
+
nodes: Nodes,
|
|
499
|
+
zoneId: string,
|
|
500
|
+
): { min: number; max: number } | null {
|
|
501
|
+
const zone = nodes[zoneId]
|
|
502
|
+
if (zone?.type !== 'zone' || zone.floor?.support !== 'open') return null
|
|
503
|
+
const level = zone.parentId ? nodes[zone.parentId] : undefined
|
|
504
|
+
const height = level?.type === 'level' ? (level.height ?? DEFAULT_LEVEL_HEIGHT) : 0
|
|
505
|
+
const thickness = zone.floor.thickness ?? 0.2
|
|
506
|
+
return { min: Math.round((thickness + 0.01) * 100) / 100, max: height - 0.3 }
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* The floor heights an own-floor room's handle may take: core's plate minimum
|
|
511
|
+
* (sunken ground floors are allowed; upstairs the underside stays fixed), up
|
|
512
|
+
* to 0.5 m under the storey top. Null for other rooms.
|
|
513
|
+
*/
|
|
514
|
+
export function ownFloorElevationBounds(
|
|
515
|
+
nodes: Nodes,
|
|
516
|
+
zoneId: string,
|
|
517
|
+
): { min: number; max: number } | null {
|
|
518
|
+
const own = roomOwnPlate(nodes, zoneId)
|
|
519
|
+
if (!own) return null
|
|
520
|
+
const level = own.parentId ? nodes[own.parentId] : undefined
|
|
521
|
+
const max = level?.type === 'level' ? getStoredLevelHeight(level) - 0.5 : 3
|
|
522
|
+
// On the ground the handle moves the foundation (0 up to the panel's limit)
|
|
523
|
+
// under the slab; upstairs it moves the top from its resting minimum.
|
|
524
|
+
const upper = upperFloorHeightControl(nodes, own)
|
|
525
|
+
const offset = upper
|
|
526
|
+
? own.elevation - footprintHeightValue(nodes, own)
|
|
527
|
+
: groundFloorConstruction(nodes, own).grade + own.thickness
|
|
528
|
+
return {
|
|
529
|
+
min: upper ? Math.min(own.elevation, floorPlateGestureMinimum(nodes, own)) : offset,
|
|
530
|
+
max: Math.max(own.elevation, offset + max),
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** One push arrow per edge of a mezzanine's outline, just outside the edge at plate height. */
|
|
535
|
+
export type MezzanineEdgeHandle = {
|
|
536
|
+
key: string
|
|
537
|
+
edgeIndex: number
|
|
538
|
+
position: Point
|
|
539
|
+
outward: Point
|
|
540
|
+
/** The edge's offset along `outward`, so a push lands the edge on the grid. */
|
|
541
|
+
line: number
|
|
542
|
+
height: number
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** How far outside its edge (m) a mezzanine push arrow sits. */
|
|
546
|
+
const MEZZANINE_ARROW_OFFSET = 0.3
|
|
547
|
+
|
|
548
|
+
export function mezzanineEdgeHandles(nodes: Nodes, zoneId: string): MezzanineEdgeHandle[] {
|
|
549
|
+
const zone = nodes[zoneId]
|
|
550
|
+
if (zone?.type !== 'zone' || zone.floor?.support !== 'open') return []
|
|
551
|
+
const polygon = zone.polygon
|
|
552
|
+
// Core's outward normal for an edge: the polygon's winding decides the side.
|
|
553
|
+
let signed = 0
|
|
554
|
+
for (const [i, p] of polygon.entries()) {
|
|
555
|
+
const q = polygon[(i + 1) % polygon.length]!
|
|
556
|
+
signed += p[0] * q[1] - q[0] * p[1]
|
|
557
|
+
}
|
|
558
|
+
const winding = Math.sign(signed)
|
|
559
|
+
const height = zone.floor.elevation ?? 0
|
|
560
|
+
return polygon.flatMap((start, edgeIndex) => {
|
|
561
|
+
const end = polygon[(edgeIndex + 1) % polygon.length]!
|
|
562
|
+
const dx = end[0] - start[0]
|
|
563
|
+
const dz = end[1] - start[1]
|
|
564
|
+
const length = Math.hypot(dx, dz)
|
|
565
|
+
if (length < 0.2) return []
|
|
566
|
+
const outward: Point = [(winding * dz) / length, (-winding * dx) / length]
|
|
567
|
+
const mid: Point = [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2]
|
|
568
|
+
return [
|
|
569
|
+
{
|
|
570
|
+
key: `${zoneId}:${edgeIndex}`,
|
|
571
|
+
edgeIndex,
|
|
572
|
+
position: [
|
|
573
|
+
mid[0] + outward[0] * MEZZANINE_ARROW_OFFSET,
|
|
574
|
+
mid[1] + outward[1] * MEZZANINE_ARROW_OFFSET,
|
|
575
|
+
],
|
|
576
|
+
outward,
|
|
577
|
+
line: start[0] * outward[0] + start[1] * outward[1],
|
|
578
|
+
height,
|
|
579
|
+
},
|
|
580
|
+
]
|
|
581
|
+
})
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/** Core's plan for pushing a mezzanine edge `distance` m outward (negative pulls it in). */
|
|
585
|
+
export function planMezzanineEdge(
|
|
586
|
+
nodes: StructureNodes,
|
|
587
|
+
zoneId: string,
|
|
588
|
+
edgeIndex: number,
|
|
589
|
+
distance: number,
|
|
590
|
+
) {
|
|
591
|
+
return setZoneEdges(nodes, { zoneId, edgeIndex, distance })
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* A mezzanine edge drag: the outline follows the arrow, grid-snapped by where
|
|
596
|
+
* the edge lands; a refused outline turns red with its label and release
|
|
597
|
+
* writes nothing. A valid release is one undo step.
|
|
598
|
+
*/
|
|
599
|
+
export function runMezzanineEdgeDrag({
|
|
600
|
+
handle,
|
|
601
|
+
zoneId,
|
|
602
|
+
levelId,
|
|
603
|
+
from,
|
|
604
|
+
along,
|
|
605
|
+
}: {
|
|
606
|
+
handle: MezzanineEdgeHandle
|
|
607
|
+
zoneId: string
|
|
608
|
+
levelId: string
|
|
609
|
+
from: number
|
|
610
|
+
along: (clientX: number, clientY: number) => number | null
|
|
611
|
+
}) {
|
|
612
|
+
const zone = useScene.getState().nodes[zoneId as AnyNodeId]
|
|
613
|
+
if (zone?.type !== 'zone') return null
|
|
614
|
+
const rest = zone.polygon.map(([x, z]) => [x, z] as Point)
|
|
615
|
+
let distance = 0
|
|
616
|
+
let valid = true
|
|
617
|
+
let outline = rest
|
|
618
|
+
const publish = (message?: string) =>
|
|
619
|
+
useRoomHandleDrag.setState({
|
|
620
|
+
drag: {
|
|
621
|
+
kind: 'mezzanine-edge',
|
|
622
|
+
zoneId,
|
|
623
|
+
levelId,
|
|
624
|
+
elevation: handle.height,
|
|
625
|
+
outline,
|
|
626
|
+
anchor: handle.position,
|
|
627
|
+
outward: handle.outward,
|
|
628
|
+
distance,
|
|
629
|
+
dimensions: mezzanineEdgeDimensions({
|
|
630
|
+
rest,
|
|
631
|
+
outline,
|
|
632
|
+
edgeIndex: handle.edgeIndex,
|
|
633
|
+
outward: handle.outward,
|
|
634
|
+
distance,
|
|
635
|
+
elevation: handle.height,
|
|
636
|
+
}),
|
|
637
|
+
message,
|
|
638
|
+
},
|
|
639
|
+
})
|
|
640
|
+
publish()
|
|
641
|
+
return runRoomHandleDrag({
|
|
642
|
+
label: MEZZANINE_EDGE_DRAG_LABEL,
|
|
643
|
+
nodeId: zoneId,
|
|
644
|
+
zoneId,
|
|
645
|
+
levelId,
|
|
646
|
+
requires: [zoneId],
|
|
647
|
+
sample: (x, y) => {
|
|
648
|
+
const at = along(x, y)
|
|
649
|
+
return at === null ? null : snapPushDistance(handle.line, at - from)
|
|
650
|
+
},
|
|
651
|
+
onValue: (next) => {
|
|
652
|
+
if (Math.abs(next - distance) < 1e-6) return
|
|
653
|
+
distance = next
|
|
654
|
+
sfxEmitter.emit('sfx:grid-snap')
|
|
655
|
+
const plan = planMezzanineEdge(useScene.getState().nodes, zoneId, handle.edgeIndex, distance)
|
|
656
|
+
const conflict = plan.conflicts?.[0]
|
|
657
|
+
valid = !conflict
|
|
658
|
+
const update = plan.changes.find((change) => change.op === 'update')
|
|
659
|
+
const polygon =
|
|
660
|
+
update?.op === 'update' ? (update.data as { polygon?: Point[] }).polygon : undefined
|
|
661
|
+
outline = polygon ?? pushedOutline(rest, handle.edgeIndex, handle.outward, distance)
|
|
662
|
+
publish(
|
|
663
|
+
conflict
|
|
664
|
+
? (mezzanineConflictMessage(conflict.code) ?? MEZZANINE_FAILED_MESSAGE)
|
|
665
|
+
: undefined,
|
|
666
|
+
)
|
|
667
|
+
},
|
|
668
|
+
onCommit: () => {
|
|
669
|
+
if (!valid || Math.abs(distance) < 1e-6) return
|
|
670
|
+
applyRoomPlan(
|
|
671
|
+
planMezzanineEdge(useScene.getState().nodes, zoneId, handle.edgeIndex, distance),
|
|
672
|
+
)
|
|
673
|
+
},
|
|
674
|
+
onCancel: () => {},
|
|
675
|
+
})
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/** The outline with one edge moved along its outward normal (the refused preview). */
|
|
679
|
+
function pushedOutline(polygon: Point[], edgeIndex: number, outward: Point, distance: number) {
|
|
680
|
+
const next = polygon.map(([x, z]) => [x, z] as Point)
|
|
681
|
+
for (const index of [edgeIndex, (edgeIndex + 1) % next.length]) {
|
|
682
|
+
const [x, z] = next[index]!
|
|
683
|
+
next[index] = [x + outward[0] * distance, z + outward[1] * distance]
|
|
684
|
+
}
|
|
685
|
+
return next
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
export function commitRoomElevation(zoneId: string, elevation: number) {
|
|
689
|
+
return applyRoomPlan(planRoomElevation(useScene.getState().nodes, zoneId, elevation))
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// ── Drag ownership ────────────────────────────────────────────────────────────
|
|
693
|
+
|
|
694
|
+
type ActiveDrag = {
|
|
695
|
+
label: string
|
|
696
|
+
nodeId: string
|
|
697
|
+
zoneId?: string
|
|
698
|
+
levelId: string
|
|
699
|
+
/** Every node the drag depends on; losing one cancels it. */
|
|
700
|
+
requires: string[]
|
|
701
|
+
cancel: () => void
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
let active: ActiveDrag | null = null
|
|
705
|
+
|
|
706
|
+
/** The drag in flight, for the gesture owner's lifecycle checks. */
|
|
707
|
+
export function getActiveRoomHandleDrag() {
|
|
708
|
+
return active
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/** Cancels the drag in flight (previews cleared, scope released). Idempotent. */
|
|
712
|
+
export function cancelRoomHandleDrag() {
|
|
713
|
+
active?.cancel()
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Runs one handle drag on window listeners, independent of the handle's own
|
|
718
|
+
* mount (handles hide while any scope is active). `sample` maps a pointer to
|
|
719
|
+
* the drag's value (`spatial.sample` a controller ray, for a spatial pointer);
|
|
720
|
+
* release commits, Escape / ⌘Z / the gesture owner cancel. Cleanup runs
|
|
721
|
+
* exactly once whatever happens, and releases only this drag's own scope;
|
|
722
|
+
* nothing republishes a preview after it.
|
|
723
|
+
*/
|
|
724
|
+
export function runRoomHandleDrag({
|
|
725
|
+
label,
|
|
726
|
+
nodeId,
|
|
727
|
+
zoneId,
|
|
728
|
+
levelId,
|
|
729
|
+
requires,
|
|
730
|
+
sample,
|
|
731
|
+
spatial,
|
|
732
|
+
onValue,
|
|
733
|
+
onCommit,
|
|
734
|
+
onCancel,
|
|
735
|
+
}: {
|
|
736
|
+
label: string
|
|
737
|
+
nodeId: string
|
|
738
|
+
zoneId?: string
|
|
739
|
+
levelId: string
|
|
740
|
+
requires: string[]
|
|
741
|
+
sample: (clientX: number, clientY: number) => number | null
|
|
742
|
+
spatial?: { pointerId: SpatialPointerId; sample: (ray: Ray) => number | null }
|
|
743
|
+
onValue: (value: number) => void
|
|
744
|
+
onCommit: () => void
|
|
745
|
+
onCancel: () => void
|
|
746
|
+
}) {
|
|
747
|
+
active?.cancel()
|
|
748
|
+
document.body.style.cursor = 'grabbing'
|
|
749
|
+
sfxEmitter.emit('sfx:item-pick')
|
|
750
|
+
useViewer.getState().setInputDragging(true)
|
|
751
|
+
let done = false
|
|
752
|
+
let frame = 0
|
|
753
|
+
let pending: PointerEvent | null = null
|
|
754
|
+
let releaseSpatial: (() => void) | null = null
|
|
755
|
+
const flush = () => {
|
|
756
|
+
frame = 0
|
|
757
|
+
const event = pending
|
|
758
|
+
pending = null
|
|
759
|
+
if (done || !event) return
|
|
760
|
+
const value = sample(event.clientX, event.clientY)
|
|
761
|
+
if (value !== null) onValue(value)
|
|
762
|
+
}
|
|
763
|
+
const onMove = (event: PointerEvent) => {
|
|
764
|
+
pending = event
|
|
765
|
+
if (!frame) frame = requestAnimationFrame(flush) || 0
|
|
766
|
+
}
|
|
767
|
+
let releasing = false
|
|
768
|
+
const cleanup = () => {
|
|
769
|
+
if (done) return
|
|
770
|
+
done = true
|
|
771
|
+
if (frame) cancelAnimationFrame(frame)
|
|
772
|
+
frame = 0
|
|
773
|
+
pending = null
|
|
774
|
+
window.removeEventListener('pointermove', onMove)
|
|
775
|
+
window.removeEventListener('pointerup', onUp, true)
|
|
776
|
+
window.removeEventListener('pointercancel', onAbort)
|
|
777
|
+
window.removeEventListener('keydown', onKeyDown, true)
|
|
778
|
+
releaseSpatial?.()
|
|
779
|
+
releaseSpatial = null
|
|
780
|
+
if (document.body.style.cursor === 'grabbing') document.body.style.cursor = ''
|
|
781
|
+
// The release is read in capture, before the scene's own pointerup (which
|
|
782
|
+
// selects what is under the pointer unless input is dragging): keep input
|
|
783
|
+
// dragging until that event is through — its bubbling back to the window,
|
|
784
|
+
// or the next task if something below stops it.
|
|
785
|
+
if (releasing) {
|
|
786
|
+
let released = false
|
|
787
|
+
const release = () => {
|
|
788
|
+
if (released) return
|
|
789
|
+
released = true
|
|
790
|
+
window.removeEventListener('pointerup', release)
|
|
791
|
+
useViewer.getState().setInputDragging(false)
|
|
792
|
+
}
|
|
793
|
+
window.addEventListener('pointerup', release)
|
|
794
|
+
setTimeout(release, 0)
|
|
795
|
+
} else useViewer.getState().setInputDragging(false)
|
|
796
|
+
useRoomHandleDrag.setState({ drag: null })
|
|
797
|
+
if (active === controller) active = null
|
|
798
|
+
}
|
|
799
|
+
// The lifecycle owner claims and releases the scope, and cancels on a mode,
|
|
800
|
+
// level or selection change, a lost scope, a required node deleted or any
|
|
801
|
+
// history command.
|
|
802
|
+
const owner = beginGesture({
|
|
803
|
+
kind: 'room-handle-drag',
|
|
804
|
+
scope: { kind: 'handle-drag', nodeId, handle: label },
|
|
805
|
+
stale: () => {
|
|
806
|
+
const nodes = useScene.getState().nodes
|
|
807
|
+
return requires.some((id) => !nodes[id as AnyNodeId])
|
|
808
|
+
},
|
|
809
|
+
onCancel: () => {
|
|
810
|
+
try {
|
|
811
|
+
onCancel()
|
|
812
|
+
} finally {
|
|
813
|
+
cleanup()
|
|
814
|
+
}
|
|
815
|
+
},
|
|
816
|
+
})
|
|
817
|
+
const onUp = (event?: PointerEvent) => {
|
|
818
|
+
if (done) return
|
|
819
|
+
// Only a release aimed at something below the window can reach the scene.
|
|
820
|
+
releasing = !!event && event.target !== window
|
|
821
|
+
if (event) {
|
|
822
|
+
pending = event
|
|
823
|
+
flush()
|
|
824
|
+
}
|
|
825
|
+
swallowNextClick()
|
|
826
|
+
sfxEmitter.emit('sfx:item-place')
|
|
827
|
+
owner.finish(() => {
|
|
828
|
+
try {
|
|
829
|
+
onCommit()
|
|
830
|
+
} finally {
|
|
831
|
+
cleanup()
|
|
832
|
+
}
|
|
833
|
+
})
|
|
834
|
+
}
|
|
835
|
+
const onAbort = () => {
|
|
836
|
+
if (!done) owner.cancel()
|
|
837
|
+
}
|
|
838
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
839
|
+
if (event.defaultPrevented || isTypingTarget(event.target)) return
|
|
840
|
+
if (event.key !== 'Escape' && !isHistoryShortcut(event)) return
|
|
841
|
+
event.preventDefault()
|
|
842
|
+
event.stopPropagation()
|
|
843
|
+
swallowNextClick()
|
|
844
|
+
onAbort()
|
|
845
|
+
}
|
|
846
|
+
const controller: ActiveDrag = { label, nodeId, zoneId, levelId, requires, cancel: onAbort }
|
|
847
|
+
active = controller
|
|
848
|
+
window.addEventListener('pointermove', onMove)
|
|
849
|
+
// Capture: the release belongs to this drag whatever is under the pointer.
|
|
850
|
+
// A scene control that stops the native pointerup (a slab hole's hit box,
|
|
851
|
+
// under an upper floor's height arrows) would otherwise swallow it, and the
|
|
852
|
+
// drag would stay live until the next click.
|
|
853
|
+
window.addEventListener('pointerup', onUp, true)
|
|
854
|
+
window.addEventListener('pointercancel', onAbort)
|
|
855
|
+
window.addEventListener('keydown', onKeyDown, true)
|
|
856
|
+
if (spatial)
|
|
857
|
+
releaseSpatial = spatialPointerInput.capture(spatial.pointerId, {
|
|
858
|
+
onMove: (ray) => {
|
|
859
|
+
const value = done ? null : spatial.sample(ray)
|
|
860
|
+
if (value !== null) onValue(value)
|
|
861
|
+
},
|
|
862
|
+
onRelease: () => onUp(),
|
|
863
|
+
onCancel: onAbort,
|
|
864
|
+
})
|
|
865
|
+
return controller
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* The one push/pull drag behind every wall arrow. `along` measures the pointer
|
|
870
|
+
* along `handle.outward` (level plan metres) and `from` is where the press
|
|
871
|
+
* landed on that axis. The push previews live — the wall, its neighbours and
|
|
872
|
+
* the room surfaces through overrides, created pieces as ghosts — and release
|
|
873
|
+
* commits it through `commitRoomPush` (core `setWallGeometry`) as one undo
|
|
874
|
+
* step: the whole wall with its neighbours for a `[0, 1]` span, else the span
|
|
875
|
+
* split off and moved alone.
|
|
876
|
+
*/
|
|
877
|
+
export function runWallPushDrag({
|
|
878
|
+
handle,
|
|
879
|
+
levelId,
|
|
880
|
+
zoneId,
|
|
881
|
+
from,
|
|
882
|
+
along,
|
|
883
|
+
spatial,
|
|
884
|
+
}: {
|
|
885
|
+
handle: WallPushHandle
|
|
886
|
+
levelId: string
|
|
887
|
+
/** The room whose arrow this is: the drag cancels if the room goes. */
|
|
888
|
+
zoneId?: string
|
|
889
|
+
from: number
|
|
890
|
+
along: (clientX: number, clientY: number) => number | null
|
|
891
|
+
spatial?: { pointerId: SpatialPointerId; along: (ray: Ray) => number | null }
|
|
892
|
+
}) {
|
|
893
|
+
const nodes = useScene.getState().nodes
|
|
894
|
+
const wall = nodes[handle.wallId as AnyNodeId]
|
|
895
|
+
if (wall?.type !== 'wall') return null
|
|
896
|
+
const base = getWallBaseElevationForNodes(wall, nodes)
|
|
897
|
+
const rooms = pushDimensionRooms(nodes, levelId, handle, base)
|
|
898
|
+
let distance = 0
|
|
899
|
+
let blocked = false
|
|
900
|
+
let ghosts: RoomPushGhost[] = []
|
|
901
|
+
const publish = (message?: string) =>
|
|
902
|
+
useRoomHandleDrag.setState({
|
|
903
|
+
drag: {
|
|
904
|
+
kind: 'push',
|
|
905
|
+
zoneId,
|
|
906
|
+
levelId,
|
|
907
|
+
wallId: handle.wallId,
|
|
908
|
+
anchor: handle.position,
|
|
909
|
+
height: handle.height,
|
|
910
|
+
base,
|
|
911
|
+
outward: handle.outward,
|
|
912
|
+
distance,
|
|
913
|
+
ghosts,
|
|
914
|
+
dimensions: wallPushDimensions({
|
|
915
|
+
rooms,
|
|
916
|
+
wall,
|
|
917
|
+
outward: handle.outward,
|
|
918
|
+
distance,
|
|
919
|
+
resolve: livePreviewNode,
|
|
920
|
+
}),
|
|
921
|
+
message,
|
|
922
|
+
},
|
|
923
|
+
})
|
|
924
|
+
const toDistance = (at: number | null) =>
|
|
925
|
+
at === null ? null : snapPushDistance(handle.line, at - from)
|
|
926
|
+
publish()
|
|
927
|
+
return runRoomHandleDrag({
|
|
928
|
+
label: WALL_PUSH_DRAG_LABEL,
|
|
929
|
+
nodeId: handle.wallId,
|
|
930
|
+
zoneId,
|
|
931
|
+
levelId,
|
|
932
|
+
requires: zoneId ? [zoneId, handle.wallId] : [handle.wallId],
|
|
933
|
+
sample: (x, y) => toDistance(along(x, y)),
|
|
934
|
+
spatial: spatial && {
|
|
935
|
+
pointerId: spatial.pointerId,
|
|
936
|
+
sample: (ray) => toDistance(spatial.along(ray)),
|
|
937
|
+
},
|
|
938
|
+
onValue: (next) => {
|
|
939
|
+
if (Math.abs(next - distance) < 1e-6) return
|
|
940
|
+
distance = next
|
|
941
|
+
sfxEmitter.emit('sfx:grid-snap')
|
|
942
|
+
const preview = previewRoomPush(handle, handle.outward, distance)
|
|
943
|
+
ghosts = preview.ghosts
|
|
944
|
+
blocked = !!preview.code
|
|
945
|
+
publish(preview.code ? "Can't move this wall there" : undefined)
|
|
946
|
+
},
|
|
947
|
+
onCommit: () => {
|
|
948
|
+
if (blocked) endRoomPushPreview()
|
|
949
|
+
else commitRoomPush(handle, handle.outward, distance)
|
|
950
|
+
},
|
|
951
|
+
onCancel: () => endRoomPushPreview(),
|
|
952
|
+
})
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/**
|
|
956
|
+
* The rooms either side of a pushed stretch, for its live dimensions, each at
|
|
957
|
+
* its own floor height (a room with no zone of its own at the wall's base).
|
|
958
|
+
*/
|
|
959
|
+
function pushDimensionRooms(nodes: Nodes, levelId: string, span: WallPushSpan, base: number) {
|
|
960
|
+
const index = getRoomSelectionIndex(levelId)
|
|
961
|
+
const records = index.update(nodes)
|
|
962
|
+
const topology = index.topology.getLevelTopology(levelId)
|
|
963
|
+
return wallPushDimensionRooms(topology?.rooms ?? [], span, (roomId) => {
|
|
964
|
+
const zoneId = records.find((record) => record.id === roomId)?.zoneId
|
|
965
|
+
return zoneId ? roomFloorElevation(nodes, zoneId) : base
|
|
966
|
+
})
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/** A node as the push preview draws it: the scene's, with its live override merged. */
|
|
970
|
+
function livePreviewNode(id: string): AnyNode | undefined {
|
|
971
|
+
const node = useScene.getState().nodes[id as AnyNodeId]
|
|
972
|
+
const override = useLiveNodeOverrides.getState().overrides.get(id)
|
|
973
|
+
return node && override ? ({ ...node, ...override } as AnyNode) : node
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/** Plan point on the wall at `t` — exported for tests. */
|
|
977
|
+
export function wallPointAt(wall: WallNode, t: number): Point {
|
|
978
|
+
const frame = getWallCurveFrameAt(wall, t)
|
|
979
|
+
return [frame.point.x, frame.point.y]
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/**
|
|
983
|
+
* The affordance a floor-plan `move-arrow` carries to be a wall push arrow;
|
|
984
|
+
* its payload is a `WallPushArrowPayload`. The floor plan starts
|
|
985
|
+
* `runFloorplanWallPush` for it instead of a registry affordance session.
|
|
986
|
+
*/
|
|
987
|
+
export const WALL_PUSH_AFFORDANCE = 'wall-push'
|
|
988
|
+
|
|
989
|
+
export type WallPushArrowPayload = { wallId: string; side: 'a' | 'b' }
|
|
990
|
+
|
|
991
|
+
type PlanPointer = (clientX: number, clientY: number) => readonly [number, number] | null
|
|
992
|
+
|
|
993
|
+
/** The pointer's plan position measured along an arrow's outward normal from its spot. */
|
|
994
|
+
function planAlong(position: Point, outward: Point, toPlan: PlanPointer) {
|
|
995
|
+
return (x: number, y: number) => {
|
|
996
|
+
const point = toPlan(x, y)
|
|
997
|
+
return point
|
|
998
|
+
? (point[0] - position[0]) * outward[0] + (point[1] - position[1]) * outward[1]
|
|
999
|
+
: null
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* A floor-plan wall arrow: the 3D arrow's handle (`wallPushHandles`), drag
|
|
1005
|
+
* (`runWallPushDrag`) and commit, with the pointer read in plan coordinates.
|
|
1006
|
+
*/
|
|
1007
|
+
export function runFloorplanWallPush(
|
|
1008
|
+
payload: unknown,
|
|
1009
|
+
clientX: number,
|
|
1010
|
+
clientY: number,
|
|
1011
|
+
toPlan: PlanPointer = clientToPlan,
|
|
1012
|
+
) {
|
|
1013
|
+
const { wallId, side } = (payload ?? {}) as Partial<WallPushArrowPayload>
|
|
1014
|
+
const nodes = useScene.getState().nodes
|
|
1015
|
+
const wall = wallId ? nodes[wallId as AnyNodeId] : undefined
|
|
1016
|
+
if (wall?.type !== 'wall' || !wall.parentId) return null
|
|
1017
|
+
const handle = wallPushHandles(wall, nodes).find((h) => h.key === `${wall.id}:${side}`)
|
|
1018
|
+
if (!handle) return null
|
|
1019
|
+
return runFloorplanRoomPush({ handle, levelId: wall.parentId, clientX, clientY, toPlan })
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/**
|
|
1023
|
+
* A floor-plan room arrow (one per boundary span, `roomPushHandles`): the 3D
|
|
1024
|
+
* room arrow's drag and commit — one undo step, the live preview through the
|
|
1025
|
+
* same overrides and ghosts — with the pointer read in plan coordinates.
|
|
1026
|
+
*/
|
|
1027
|
+
export function runFloorplanRoomPush({
|
|
1028
|
+
handle,
|
|
1029
|
+
levelId,
|
|
1030
|
+
zoneId,
|
|
1031
|
+
clientX,
|
|
1032
|
+
clientY,
|
|
1033
|
+
toPlan = clientToPlan,
|
|
1034
|
+
}: {
|
|
1035
|
+
handle: WallPushHandle
|
|
1036
|
+
levelId: string
|
|
1037
|
+
zoneId?: string
|
|
1038
|
+
clientX: number
|
|
1039
|
+
clientY: number
|
|
1040
|
+
toPlan?: PlanPointer
|
|
1041
|
+
}) {
|
|
1042
|
+
const along = planAlong(handle.position, handle.outward, toPlan)
|
|
1043
|
+
const from = along(clientX, clientY)
|
|
1044
|
+
if (from === null) return null
|
|
1045
|
+
return runWallPushDrag({ handle, levelId, zoneId, from, along })
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
/** A floor-plan mezzanine edge arrow: the 3D edge drag with the pointer read in plan coordinates. */
|
|
1049
|
+
export function runFloorplanMezzanineEdge({
|
|
1050
|
+
handle,
|
|
1051
|
+
levelId,
|
|
1052
|
+
zoneId,
|
|
1053
|
+
clientX,
|
|
1054
|
+
clientY,
|
|
1055
|
+
toPlan = clientToPlan,
|
|
1056
|
+
}: {
|
|
1057
|
+
handle: MezzanineEdgeHandle
|
|
1058
|
+
levelId: string
|
|
1059
|
+
zoneId: string
|
|
1060
|
+
clientX: number
|
|
1061
|
+
clientY: number
|
|
1062
|
+
toPlan?: PlanPointer
|
|
1063
|
+
}) {
|
|
1064
|
+
const along = planAlong(handle.position, handle.outward, toPlan)
|
|
1065
|
+
const from = along(clientX, clientY)
|
|
1066
|
+
if (from === null) return null
|
|
1067
|
+
return runMezzanineEdgeDrag({ handle, zoneId, levelId, from, along })
|
|
1068
|
+
}
|