@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
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
nodeRegistry,
|
|
6
6
|
pauseSpaceDetection,
|
|
7
7
|
resumeSpaceDetection,
|
|
8
|
+
toggleNodeMechanism,
|
|
8
9
|
useScene,
|
|
9
10
|
} from '@pascal-app/core'
|
|
10
11
|
import { cancelPerfAction, markPerfAction, useViewer } from '@pascal-app/viewer'
|
|
@@ -23,16 +24,20 @@ import {
|
|
|
23
24
|
rotateGroupPatches,
|
|
24
25
|
} from '../components/editor/group-transform-shared'
|
|
25
26
|
import { steppedRotation } from '../components/tools/item/placement-math'
|
|
27
|
+
import { keyCyclableContinuationContext } from '../lib/continuation'
|
|
26
28
|
import { resolveDirectManipulationNode } from '../lib/direct-manipulation'
|
|
27
|
-
import {
|
|
29
|
+
import { cancelGestures } from '../lib/gesture-lifecycle'
|
|
28
30
|
import { guideEmitter } from '../lib/guide-events'
|
|
29
31
|
import { isHistoryShortcut, runRedo, runUndo, shouldCancelDraftOnHistoryJump } from '../lib/history'
|
|
30
32
|
import { isActive } from '../lib/interaction/scope'
|
|
33
|
+
import { paintRegionModeActive } from '../lib/paint-region-mode'
|
|
34
|
+
import { popRoomSelection } from '../lib/room-selection-commands'
|
|
31
35
|
import { copySelectedNodesToEditorClipboard } from '../lib/scene-clipboard'
|
|
32
36
|
import { sfxEmitter } from '../lib/sfx-bus'
|
|
37
|
+
import { openSidebarPanel } from '../lib/sidebar-panel'
|
|
33
38
|
import { activeSiteNode, clampBrushRadius } from '../lib/terrain-sculpt'
|
|
34
39
|
import { leaveUnitFocus } from '../lib/units'
|
|
35
|
-
import {
|
|
40
|
+
import { selectWallDrawVariant } from '../lib/wall-draw-variant'
|
|
36
41
|
import useDeleteConfirmation from '../store/use-delete-confirmation'
|
|
37
42
|
import useEditor, { getActiveContinuationContext, getActiveSnapContext } from '../store/use-editor'
|
|
38
43
|
import useInteractionScope, {
|
|
@@ -108,9 +113,13 @@ export const markToolCancelConsumed = () => {
|
|
|
108
113
|
// preset/item placement rely on this — they pass no coordinator onCancel, and
|
|
109
114
|
// it is the mode switch unmounting them that destroys the draft.
|
|
110
115
|
const exitToSelectAfterUnconsumedCancel = () => {
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
116
|
+
const editor = useEditor.getState()
|
|
117
|
+
if (
|
|
118
|
+
editor.mode === 'select' &&
|
|
119
|
+
useInteractionScope.getState().scope.kind === 'idle' &&
|
|
120
|
+
popRoomSelection()
|
|
121
|
+
)
|
|
122
|
+
return
|
|
114
123
|
useInteractionScope
|
|
115
124
|
.getState()
|
|
116
125
|
.endIf(
|
|
@@ -119,14 +128,8 @@ const exitToSelectAfterUnconsumedCancel = () => {
|
|
|
119
128
|
(sc.kind === 'reshaping' && sc.reshape === 'hole'),
|
|
120
129
|
)
|
|
121
130
|
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
useEditor.getState().setStructureLayer('elements')
|
|
125
|
-
useEditor.getState().armToolMode({ mode: 'select' })
|
|
126
|
-
} else {
|
|
127
|
-
// Return to the default select tool while keeping the active building/level context.
|
|
128
|
-
useEditor.getState().armToolMode({ mode: 'select' })
|
|
129
|
-
}
|
|
131
|
+
// Back to the select tool, keeping the active building/level context.
|
|
132
|
+
useEditor.getState().armToolMode({ mode: 'select' })
|
|
130
133
|
|
|
131
134
|
useEditor.getState().setFloorplanSelectionTool('click')
|
|
132
135
|
|
|
@@ -160,6 +163,11 @@ const cancelInteractionForHistoryShortcut = () => {
|
|
|
160
163
|
guideEmitter.emit('guide:cancel-reference-scale')
|
|
161
164
|
return true
|
|
162
165
|
}
|
|
166
|
+
// A live gesture reads ⌘Z as "abort", never as a jump under the pointer.
|
|
167
|
+
if (cancelGestures('history')) {
|
|
168
|
+
cancelPerfAction()
|
|
169
|
+
return true
|
|
170
|
+
}
|
|
163
171
|
const activeScope = useInteractionScope.getState().scope
|
|
164
172
|
if (shouldCancelDraftOnHistoryJump()) return false
|
|
165
173
|
if (activeScope.kind === 'mesh-editing' && activeScope.phase === 'selecting') return false
|
|
@@ -191,8 +199,42 @@ export const runHistoryShortcut = (direction: 'undo' | 'redo') => {
|
|
|
191
199
|
return true
|
|
192
200
|
}
|
|
193
201
|
|
|
194
|
-
/**
|
|
195
|
-
|
|
202
|
+
/**
|
|
203
|
+
* B: the wall tool on Rectangle — the quickest room — with the Build panel
|
|
204
|
+
* showing, so the lit tile says what is in hand. The panel's tiles still pick
|
|
205
|
+
* another shape for the session. The panel opens before the tool is armed: a
|
|
206
|
+
* host may drop the armed tool when the panel changes.
|
|
207
|
+
*/
|
|
208
|
+
export const armWallToolFromShortcut = () => {
|
|
209
|
+
selectWallDrawVariant('rectangle')
|
|
210
|
+
openSidebarPanel(['build'])
|
|
211
|
+
useEditor.getState().armToolMode({ mode: 'build', tool: 'wall' })
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** 1: site, 2: the building — each back on the select tool. Returns whether `key` was one. */
|
|
215
|
+
export const switchPhaseFromShortcut = (key: string): boolean => {
|
|
216
|
+
const phase = key === '1' ? 'site' : key === '2' ? 'building' : null
|
|
217
|
+
if (!phase) return false
|
|
218
|
+
useEditor.getState().setPhase(phase)
|
|
219
|
+
useEditor.getState().armToolMode({ mode: 'select' })
|
|
220
|
+
return true
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** F: the item tool with the items catalog showing, opened first as B's is. */
|
|
224
|
+
export const openItemCatalogFromShortcut = () => {
|
|
225
|
+
openSidebarPanel(['items'])
|
|
226
|
+
useEditor.getState().setActiveSidebarPanel('items')
|
|
227
|
+
useEditor.getState().armToolMode({ mode: 'build', tool: 'item' })
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** P: paint mode, with the Paint panel showing (the Build panel where a host has no Paint tab). */
|
|
231
|
+
export const armPaintFromShortcut = () => {
|
|
232
|
+
useEditor.getState().armMaterialPaint()
|
|
233
|
+
openSidebarPanel(['paint', 'build'])
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Whether an armed tool owns the rotation keys (`R` / `T`) instead of the selection. */
|
|
237
|
+
export const isToolOwnedRotation = () => {
|
|
196
238
|
const editor = useEditor.getState()
|
|
197
239
|
const moving = getMovingNode()
|
|
198
240
|
if (
|
|
@@ -212,10 +254,7 @@ export const isToolOwnedRotation = (key: 'r' | 't' = 'r') => {
|
|
|
212
254
|
// exist. Without this check, selecting an existing item in the 2D plan
|
|
213
255
|
// while the item tool is armed silently drops the global rotate key.
|
|
214
256
|
(editor.tool === 'item' && editor.selectedItem !== null) ||
|
|
215
|
-
editor.tool === 'lean-to-extension'
|
|
216
|
-
// R toggles the wall tool between line and rectangle drawing; T stays
|
|
217
|
-
// the selection's.
|
|
218
|
-
(editor.tool === 'wall' && key === 'r'))
|
|
257
|
+
editor.tool === 'lean-to-extension')
|
|
219
258
|
)
|
|
220
259
|
}
|
|
221
260
|
|
|
@@ -238,6 +277,16 @@ export function blocksSnappingShortcut(
|
|
|
238
277
|
return target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable
|
|
239
278
|
}
|
|
240
279
|
|
|
280
|
+
/** E on one selected node: the kind's own E action, else its mechanism. False when neither applies. */
|
|
281
|
+
export function runNodeInteraction(node: AnyNode): boolean {
|
|
282
|
+
const action = nodeRegistry.get(node.type)?.keyboardActions?.e
|
|
283
|
+
if (action?.appliesTo(node)) {
|
|
284
|
+
action.run(node)
|
|
285
|
+
return true
|
|
286
|
+
}
|
|
287
|
+
return toggleNodeMechanism(node)
|
|
288
|
+
}
|
|
289
|
+
|
|
241
290
|
export const useKeyboard = ({
|
|
242
291
|
isVersionPreviewMode = false,
|
|
243
292
|
disabled = false,
|
|
@@ -309,7 +358,12 @@ export const useKeyboard = ({
|
|
|
309
358
|
return
|
|
310
359
|
}
|
|
311
360
|
|
|
312
|
-
if (
|
|
361
|
+
if (
|
|
362
|
+
e.key === 'Shift' &&
|
|
363
|
+
!e.repeat &&
|
|
364
|
+
useEditor.getState().mode === 'material-paint' &&
|
|
365
|
+
!paintRegionModeActive('material-paint')
|
|
366
|
+
) {
|
|
313
367
|
// In paint mode Shift cycles the application scope (this surface →
|
|
314
368
|
// whole item / all matching / room) — the paint-mode analogue of the
|
|
315
369
|
// snapping-mode cycle below. The scope chip mirrors this key.
|
|
@@ -361,7 +415,10 @@ export const useKeyboard = ({
|
|
|
361
415
|
const current = useEditor.getState().getContinuation('fence')
|
|
362
416
|
useEditor
|
|
363
417
|
.getState()
|
|
364
|
-
.setContinuation(
|
|
418
|
+
.setContinuation(
|
|
419
|
+
'fence',
|
|
420
|
+
current === 'curved' ? 'freehand' : current === 'freehand' ? 'continuous' : 'curved',
|
|
421
|
+
)
|
|
365
422
|
sfxEmitter.emit('sfx:grid-snap')
|
|
366
423
|
return
|
|
367
424
|
}
|
|
@@ -375,12 +432,13 @@ export const useKeyboard = ({
|
|
|
375
432
|
!e.shiftKey &&
|
|
376
433
|
!e.altKey
|
|
377
434
|
) {
|
|
378
|
-
|
|
435
|
+
// The wall's drawing mode is picked in the Build panel, not cycled here.
|
|
436
|
+
const context = keyCyclableContinuationContext(getActiveContinuationContext())
|
|
379
437
|
if (context) {
|
|
380
438
|
e.preventDefault()
|
|
381
439
|
if (context === 'fence') {
|
|
382
440
|
const current = useEditor.getState().getContinuation('fence')
|
|
383
|
-
if (current !== 'curved') {
|
|
441
|
+
if (current !== 'curved' && current !== 'freehand') {
|
|
384
442
|
useEditor
|
|
385
443
|
.getState()
|
|
386
444
|
.setContinuation('fence', current === 'single' ? 'continuous' : 'single')
|
|
@@ -407,37 +465,21 @@ export const useKeyboard = ({
|
|
|
407
465
|
// Only switch to select mode if no tool had an active mid-action to cancel.
|
|
408
466
|
// (e.g. mid-wall draw or mid-slab polygon should only cancel the action, not exit the tool)
|
|
409
467
|
cancelActiveTool()
|
|
410
|
-
} else if (e.
|
|
411
|
-
e.preventDefault()
|
|
412
|
-
useEditor.getState().setPhase('site')
|
|
413
|
-
useEditor.getState().armToolMode({ mode: 'select' })
|
|
414
|
-
} else if (e.key === '2' && !e.metaKey && !e.ctrlKey) {
|
|
468
|
+
} else if (!e.metaKey && !e.ctrlKey && switchPhaseFromShortcut(e.key)) {
|
|
415
469
|
e.preventDefault()
|
|
416
|
-
useEditor.getState().setPhase('structure')
|
|
417
|
-
useEditor.getState().armToolMode({ mode: 'select' })
|
|
418
|
-
} else if (e.key === '3' && !e.metaKey && !e.ctrlKey) {
|
|
419
|
-
e.preventDefault()
|
|
420
|
-
useEditor.getState().setPhase('furnish')
|
|
421
|
-
useEditor.getState().armToolMode({ mode: 'select' })
|
|
422
470
|
} else if (e.key === 'f' && !e.metaKey && !e.ctrlKey) {
|
|
423
471
|
if (isVersionPreviewMode) return
|
|
424
472
|
if (isToolOwnedCanopyForm()) return
|
|
425
473
|
e.preventDefault()
|
|
426
|
-
|
|
427
|
-
useEditor.getState().armToolMode({ mode: 'build', tool: 'item' })
|
|
428
|
-
useEditor.getState().setActiveSidebarPanel('items')
|
|
474
|
+
openItemCatalogFromShortcut()
|
|
429
475
|
} else if (e.key === 'z' && !e.metaKey && !e.ctrlKey) {
|
|
430
476
|
if (isVersionPreviewMode) return
|
|
431
477
|
e.preventDefault()
|
|
432
|
-
useEditor.getState().setPhase('structure')
|
|
433
|
-
useEditor.getState().setStructureLayer('zones')
|
|
434
478
|
useEditor.getState().armToolMode({ mode: 'build', tool: 'zone' })
|
|
435
479
|
} else if (e.key === 'm' && !e.metaKey && !e.ctrlKey) {
|
|
436
480
|
if (isVersionPreviewMode) return
|
|
437
481
|
e.preventDefault()
|
|
438
482
|
const editor = useEditor.getState()
|
|
439
|
-
editor.setPhase('structure')
|
|
440
|
-
editor.setStructureLayer('elements')
|
|
441
483
|
editor.setToolDefaults('measurement', { kind: editor.lastMeasurementKind })
|
|
442
484
|
editor.armToolMode({ mode: 'build', tool: 'measurement' })
|
|
443
485
|
}
|
|
@@ -448,9 +490,7 @@ export const useKeyboard = ({
|
|
|
448
490
|
} else if (e.key === 'b' && !e.metaKey && !e.ctrlKey) {
|
|
449
491
|
if (isVersionPreviewMode) return
|
|
450
492
|
e.preventDefault()
|
|
451
|
-
|
|
452
|
-
useEditor.getState().setStructureLayer('elements')
|
|
453
|
-
useEditor.getState().armToolMode({ mode: 'build', tool: 'wall' })
|
|
493
|
+
armWallToolFromShortcut()
|
|
454
494
|
} else if (e.key === 'x' && !e.metaKey && !e.ctrlKey) {
|
|
455
495
|
if (isVersionPreviewMode) return
|
|
456
496
|
e.preventDefault()
|
|
@@ -458,9 +498,7 @@ export const useKeyboard = ({
|
|
|
458
498
|
} else if (e.key === 'p' && !e.metaKey && !e.ctrlKey) {
|
|
459
499
|
if (isVersionPreviewMode) return
|
|
460
500
|
e.preventDefault()
|
|
461
|
-
|
|
462
|
-
useEditor.getState().setStructureLayer('elements')
|
|
463
|
-
useEditor.getState().armMaterialPaint()
|
|
501
|
+
armPaintFromShortcut()
|
|
464
502
|
} else if (e.key === 'g' && !e.metaKey && !e.ctrlKey) {
|
|
465
503
|
if (isVersionPreviewMode) return
|
|
466
504
|
e.preventDefault()
|
|
@@ -632,7 +670,7 @@ export const useKeyboard = ({
|
|
|
632
670
|
} else if (
|
|
633
671
|
(e.key === 't' || e.key === 'T') &&
|
|
634
672
|
!isVersionPreviewMode &&
|
|
635
|
-
!isToolOwnedRotation(
|
|
673
|
+
!isToolOwnedRotation() &&
|
|
636
674
|
canRunGlobalRotationShortcut()
|
|
637
675
|
) {
|
|
638
676
|
// Rotate selected node counter-clockwise
|
|
@@ -692,34 +730,14 @@ export const useKeyboard = ({
|
|
|
692
730
|
}
|
|
693
731
|
}
|
|
694
732
|
} else if ((e.key === 'e' || e.key === 'E') && !isVersionPreviewMode) {
|
|
695
|
-
//
|
|
696
|
-
// which now flips the opening (side + π rotation).
|
|
733
|
+
// E runs the kind's interaction or mechanism (doors and windows open and
|
|
734
|
+
// close). Moved off R, which now flips the opening (side + π rotation).
|
|
697
735
|
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
|
|
698
736
|
if (selectedNodeIds.length === 1) {
|
|
699
737
|
const node = useScene.getState().nodes[selectedNodeIds[0]!]
|
|
700
|
-
|
|
701
|
-
if (node && registryE?.appliesTo(node)) {
|
|
738
|
+
if (node && runNodeInteraction(node)) {
|
|
702
739
|
// Registry-driven E interaction. Same shape as the R/T arms.
|
|
703
740
|
e.preventDefault()
|
|
704
|
-
registryE.run(node)
|
|
705
|
-
sfxEmitter.emit('sfx:item-rotate')
|
|
706
|
-
} else if (node?.type === 'door' && node.openingKind !== 'opening') {
|
|
707
|
-
e.preventDefault()
|
|
708
|
-
toggleDoorOpenState(node.id)
|
|
709
|
-
sfxEmitter.emit('sfx:item-rotate')
|
|
710
|
-
} else if (
|
|
711
|
-
node?.type === 'window' &&
|
|
712
|
-
node.openingKind !== 'opening' &&
|
|
713
|
-
(node.windowType === 'sliding' ||
|
|
714
|
-
node.windowType === 'casement' ||
|
|
715
|
-
node.windowType === 'awning' ||
|
|
716
|
-
node.windowType === 'hopper' ||
|
|
717
|
-
node.windowType === 'single-hung' ||
|
|
718
|
-
node.windowType === 'double-hung' ||
|
|
719
|
-
node.windowType === 'louvered')
|
|
720
|
-
) {
|
|
721
|
-
e.preventDefault()
|
|
722
|
-
toggleWindowOpenState(node.id)
|
|
723
741
|
sfxEmitter.emit('sfx:item-rotate')
|
|
724
742
|
}
|
|
725
743
|
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { type AnyNode, DoorNode, type OpenWallEnd, WallNode } from '@pascal-app/core'
|
|
3
|
+
import { analyseOpenWallEnds } from './use-open-wall-ends'
|
|
4
|
+
|
|
5
|
+
const wall = (id: string, start: [number, number], end: [number, number]) =>
|
|
6
|
+
({
|
|
7
|
+
id,
|
|
8
|
+
type: 'wall',
|
|
9
|
+
object: 'node',
|
|
10
|
+
parentId: 'level_a',
|
|
11
|
+
visible: true,
|
|
12
|
+
children: [],
|
|
13
|
+
metadata: {},
|
|
14
|
+
start,
|
|
15
|
+
end,
|
|
16
|
+
thickness: 0.1,
|
|
17
|
+
}) as unknown as WallNode
|
|
18
|
+
|
|
19
|
+
// A 4 x 4 room whose last corner is left 4 cm open (bodies are 0.1 thick).
|
|
20
|
+
const walls = () => [
|
|
21
|
+
wall('wall_1', [0, 0], [4, 0]),
|
|
22
|
+
wall('wall_2', [4, 0], [4, 4]),
|
|
23
|
+
wall('wall_3', [4, 4], [0, 4]),
|
|
24
|
+
wall('wall_4', [0, 4], [0, 0.09]),
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
describe('open wall end analysis shared by the floor plan and 3D view', () => {
|
|
28
|
+
test('finds the open corner', () => {
|
|
29
|
+
const ends = analyseOpenWallEnds('level_a', walls())
|
|
30
|
+
expect(ends.some((end) => end.wallId === 'wall_4' && end.end === 'end')).toBe(true)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('two views reading the same walls get one result', () => {
|
|
34
|
+
const shared = walls()
|
|
35
|
+
const first = analyseOpenWallEnds('level_a', [...shared])
|
|
36
|
+
expect(analyseOpenWallEnds('level_a', [...shared])).toBe(first)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('a changed wall is analysed again', () => {
|
|
40
|
+
const shared = walls()
|
|
41
|
+
const first = analyseOpenWallEnds('level_a', shared)
|
|
42
|
+
const closed = [...shared.slice(0, 3), wall('wall_4', [0, 4], [0, 0])]
|
|
43
|
+
const next = analyseOpenWallEnds('level_a', closed)
|
|
44
|
+
expect(next).not.toBe(first)
|
|
45
|
+
expect(next.some((end) => end.wallId === 'wall_4')).toBe(false)
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
describe('the open-end preview reads what the walls host', () => {
|
|
50
|
+
// An 88° near-miss T: the source wall stops 9 cm short of the long target wall.
|
|
51
|
+
const level = 'level_t'
|
|
52
|
+
const tWall = (id: string, start: [number, number], end: [number, number], thickness = 0.1) =>
|
|
53
|
+
WallNode.parse({ id, parentId: level, start, end, thickness })
|
|
54
|
+
const scene = () => {
|
|
55
|
+
const tip: [number, number] = [2 + (3 - 0.09) / Math.tan((88 * Math.PI) / 180), 0.09]
|
|
56
|
+
const source = tWall('wall_source', [2, 3], tip, 0.01)
|
|
57
|
+
return {
|
|
58
|
+
source,
|
|
59
|
+
walls: [
|
|
60
|
+
source,
|
|
61
|
+
tWall('wall_target', [-20, 0], [30, 0], 0.01),
|
|
62
|
+
tWall('wall_anchor', [2, 3], [-20, 3]),
|
|
63
|
+
tWall('wall_left', [-20, 3], [-20, 0]),
|
|
64
|
+
],
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const sourceEnd = (ends: OpenWallEnd[]) =>
|
|
68
|
+
ends.find((end) => end.wallId === 'wall_source' && end.end === 'end')
|
|
69
|
+
|
|
70
|
+
test('without an opening the preview squares the corner', () => {
|
|
71
|
+
const point = sourceEnd(analyseOpenWallEnds(level, scene().walls))?.candidate?.point
|
|
72
|
+
expect(point?.[0]).toBeCloseTo(2, 6)
|
|
73
|
+
expect(point?.[1]).toBeCloseTo(0, 6)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('a door flush with the anchored end keeps the join straight, and only the door changing re-runs it', () => {
|
|
77
|
+
const { source, walls } = scene()
|
|
78
|
+
const door = DoorNode.parse({
|
|
79
|
+
id: 'door_source',
|
|
80
|
+
parentId: source.id,
|
|
81
|
+
wallId: source.id,
|
|
82
|
+
position: [0.4, 1.05, 0],
|
|
83
|
+
width: 0.8,
|
|
84
|
+
})
|
|
85
|
+
const hosted: AnyNode[] = [
|
|
86
|
+
{ ...source, children: [door.id] } as AnyNode,
|
|
87
|
+
...walls.slice(1),
|
|
88
|
+
door as AnyNode,
|
|
89
|
+
]
|
|
90
|
+
const first = analyseOpenWallEnds(level, hosted)
|
|
91
|
+
const point = sourceEnd(first)?.candidate?.point
|
|
92
|
+
expect(point?.[0]).toBeCloseTo(2 + 3 / Math.tan((88 * Math.PI) / 180), 6)
|
|
93
|
+
expect(point?.[1]).toBeCloseTo(0, 6)
|
|
94
|
+
|
|
95
|
+
const narrower = { ...door, width: 0.6 } as AnyNode
|
|
96
|
+
const next = analyseOpenWallEnds(level, [...hosted.slice(0, -1), narrower])
|
|
97
|
+
expect(next).not.toBe(first)
|
|
98
|
+
})
|
|
99
|
+
})
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { type AnyNode, findOpenWallEnds, type OpenWallEnd, useScene } from '@pascal-app/core'
|
|
4
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
5
|
+
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
6
|
+
import { useShallow } from 'zustand/react/shallow'
|
|
7
|
+
import {
|
|
8
|
+
collapseMutualOpenWallEnds,
|
|
9
|
+
joinOpenWallEnd,
|
|
10
|
+
openWallEndKey,
|
|
11
|
+
visibleOpenWallEnds,
|
|
12
|
+
} from '../lib/floorplan/open-wall-ends'
|
|
13
|
+
import { sfxEmitter } from '../lib/sfx-bus'
|
|
14
|
+
import useEditor from '../store/use-editor'
|
|
15
|
+
import { useFloorplanDraftPreview } from '../store/use-floorplan-draft-preview'
|
|
16
|
+
import useInteractionScope from '../store/use-interaction-scope'
|
|
17
|
+
|
|
18
|
+
type Boundary = Extract<AnyNode, { type: 'wall' | 'separator' }>
|
|
19
|
+
|
|
20
|
+
const NO_ENDS: OpenWallEnd[] = []
|
|
21
|
+
|
|
22
|
+
const isBoundary = (node: AnyNode): node is Boundary =>
|
|
23
|
+
node.type === 'wall' || node.type === 'separator'
|
|
24
|
+
|
|
25
|
+
let lastAnalysis: {
|
|
26
|
+
levelId: string
|
|
27
|
+
nodes: readonly AnyNode[]
|
|
28
|
+
ends: OpenWallEnd[]
|
|
29
|
+
} | null = null
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The room-graph pass behind the open-end markers, shared by the floor plan
|
|
33
|
+
* and the 3D view: split view mounts both, and they must not pay for it twice.
|
|
34
|
+
* `nodes` is the level's walls and separators plus what the walls host: an
|
|
35
|
+
* opening can stop core squaring a corner, and the preview must land where
|
|
36
|
+
* Join walls will. Recomputed only when one of those nodes changes (compared
|
|
37
|
+
* by reference), never per frame or on unrelated edits.
|
|
38
|
+
*/
|
|
39
|
+
export function analyseOpenWallEnds(levelId: string, nodes: readonly AnyNode[]): OpenWallEnd[] {
|
|
40
|
+
const last = lastAnalysis
|
|
41
|
+
if (
|
|
42
|
+
last &&
|
|
43
|
+
last.levelId === levelId &&
|
|
44
|
+
last.nodes.length === nodes.length &&
|
|
45
|
+
last.nodes.every((node, index) => node === nodes[index])
|
|
46
|
+
) {
|
|
47
|
+
return last.ends
|
|
48
|
+
}
|
|
49
|
+
const ends = findOpenWallEnds(Object.fromEntries(nodes.map((node) => [node.id, node])), levelId)
|
|
50
|
+
lastAnalysis = { levelId, nodes, ends }
|
|
51
|
+
return ends
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Moving, placing, reshaping or dragging a handle: the markers step aside. */
|
|
55
|
+
export function useOpenWallEndsSuppressed(): boolean {
|
|
56
|
+
return useInteractionScope(
|
|
57
|
+
(state) =>
|
|
58
|
+
state.scope.kind === 'moving' ||
|
|
59
|
+
state.scope.kind === 'placing' ||
|
|
60
|
+
state.scope.kind === 'reshaping' ||
|
|
61
|
+
state.scope.kind === 'handle-drag',
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type OpenWallEndsState = {
|
|
66
|
+
/** The wall tool (any variant) or Divide is active. */
|
|
67
|
+
drawing: boolean
|
|
68
|
+
/** A wall or divide segment is mid-draft: markers stay visible but out of the way. */
|
|
69
|
+
drafting: boolean
|
|
70
|
+
/** Ends to mark, mutual pairs collapsed to the end that slides straight. */
|
|
71
|
+
ends: OpenWallEnd[]
|
|
72
|
+
/** Walls on the active level that bound no room. */
|
|
73
|
+
roomlessWallIds: string[]
|
|
74
|
+
/** Whether the overlay is up at all (drawing, or a near miss to show). */
|
|
75
|
+
shown: boolean
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Open wall ends on the active level and when to show them. On while walls or
|
|
80
|
+
* rooms are being drawn; otherwise only when a wall that bounds no room has a
|
|
81
|
+
* near miss.
|
|
82
|
+
*/
|
|
83
|
+
export function useOpenWallEnds(): OpenWallEndsState {
|
|
84
|
+
const levelId = useViewer((state) => state.selection.levelId)
|
|
85
|
+
const mode = useEditor((state) => state.mode)
|
|
86
|
+
const tool = useEditor((state) => state.tool)
|
|
87
|
+
const spaces = useEditor(
|
|
88
|
+
useShallow((state) => Object.values(state.spaces).filter((space) => space.levelId === levelId)),
|
|
89
|
+
)
|
|
90
|
+
const isDividing = useInteractionScope((state) => state.scope.kind === 'room-divide')
|
|
91
|
+
const dividingDraft = useInteractionScope(
|
|
92
|
+
(state) => state.scope.kind === 'room-divide' && state.scope.points.length > 0,
|
|
93
|
+
)
|
|
94
|
+
const wallDrafting = useFloorplanDraftPreview(
|
|
95
|
+
(state) =>
|
|
96
|
+
state.wallDraftStart !== null ||
|
|
97
|
+
state.wallRectangleDraftStart !== null ||
|
|
98
|
+
state.wallPolygonDraftPoints.length > 0,
|
|
99
|
+
)
|
|
100
|
+
// Walls and separators first, then every node a wall hosts — one flat list,
|
|
101
|
+
// so a door moving re-runs the analysis but an unrelated item edit does not.
|
|
102
|
+
const analysisNodes = useScene(
|
|
103
|
+
useShallow((state) => {
|
|
104
|
+
const level = levelId ? state.nodes[levelId as AnyNode['id']] : undefined
|
|
105
|
+
if (level?.type !== 'level') return [] as AnyNode[]
|
|
106
|
+
const boundaries = level.children
|
|
107
|
+
.map((id) => state.nodes[id])
|
|
108
|
+
.filter((node): node is Boundary => !!node && isBoundary(node))
|
|
109
|
+
const hosted = boundaries.flatMap((node) =>
|
|
110
|
+
node.type === 'wall'
|
|
111
|
+
? node.children
|
|
112
|
+
.map((id) => state.nodes[id])
|
|
113
|
+
.filter((child): child is AnyNode => child !== undefined)
|
|
114
|
+
: [],
|
|
115
|
+
)
|
|
116
|
+
return [...boundaries, ...hosted]
|
|
117
|
+
}),
|
|
118
|
+
)
|
|
119
|
+
const boundaries = useMemo(() => analysisNodes.filter(isBoundary), [analysisNodes])
|
|
120
|
+
|
|
121
|
+
const drawing = (mode === 'build' && tool === 'wall') || isDividing
|
|
122
|
+
const roomWallIds = useMemo(() => {
|
|
123
|
+
const ids = new Set<string>()
|
|
124
|
+
for (const space of spaces) for (const wallId of space.wallIds) ids.add(wallId)
|
|
125
|
+
return ids
|
|
126
|
+
}, [spaces])
|
|
127
|
+
const roomlessWallIds = useMemo(
|
|
128
|
+
() =>
|
|
129
|
+
boundaries
|
|
130
|
+
.filter((node) => node.type === 'wall' && !roomWallIds.has(node.id))
|
|
131
|
+
.map((node) => node.id as string),
|
|
132
|
+
[boundaries, roomWallIds],
|
|
133
|
+
)
|
|
134
|
+
const hasWalls = boundaries.some((node) => node.type === 'wall')
|
|
135
|
+
const shouldAnalyse = !!levelId && hasWalls && (drawing || roomlessWallIds.length > 0)
|
|
136
|
+
const openEnds = useMemo(
|
|
137
|
+
() => (shouldAnalyse && levelId ? analyseOpenWallEnds(levelId, analysisNodes) : NO_ENDS),
|
|
138
|
+
[shouldAnalyse, levelId, analysisNodes],
|
|
139
|
+
)
|
|
140
|
+
const ends = useMemo(() => {
|
|
141
|
+
const walls = new Map(boundaries.map((node) => [node.id as string, node]))
|
|
142
|
+
return collapseMutualOpenWallEnds(
|
|
143
|
+
visibleOpenWallEnds(openEnds, roomWallIds, drawing),
|
|
144
|
+
(wallId) => {
|
|
145
|
+
const wall = walls.get(wallId)
|
|
146
|
+
return wall?.type === 'wall'
|
|
147
|
+
? [wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]]
|
|
148
|
+
: null
|
|
149
|
+
},
|
|
150
|
+
)
|
|
151
|
+
}, [openEnds, roomWallIds, drawing, boundaries])
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
drawing,
|
|
155
|
+
drafting: wallDrafting || dividingDraft,
|
|
156
|
+
ends,
|
|
157
|
+
roomlessWallIds,
|
|
158
|
+
shown: drawing || ends.length > 0,
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const HOVER_RELEASE_MS = 250
|
|
163
|
+
|
|
164
|
+
function clearReleaseTimer(timer: { current: ReturnType<typeof setTimeout> | null }) {
|
|
165
|
+
if (timer.current) clearTimeout(timer.current)
|
|
166
|
+
timer.current = null
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Hover / pin / join for one view's markers — the floor plan and the 3D view
|
|
171
|
+
* each keep their own, so hovering a dot in one does not pop a pill in the
|
|
172
|
+
* other. A pinned pill closes on Escape or a press outside any
|
|
173
|
+
* `[data-open-wall-end]` element.
|
|
174
|
+
*/
|
|
175
|
+
export function useOpenWallEndFocus(ends: readonly OpenWallEnd[], drafting: boolean) {
|
|
176
|
+
const [hoveredKey, setHoveredKey] = useState<string | null>(null)
|
|
177
|
+
const [pinnedKey, setPinnedKey] = useState<string | null>(null)
|
|
178
|
+
const [refusal, setRefusal] = useState<{ key: string; message: string } | null>(null)
|
|
179
|
+
const releaseTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
180
|
+
|
|
181
|
+
const clearTimer = () => clearReleaseTimer(releaseTimer)
|
|
182
|
+
useEffect(() => () => clearReleaseTimer(releaseTimer), [])
|
|
183
|
+
|
|
184
|
+
const activeKey = pinnedKey ?? hoveredKey
|
|
185
|
+
const activeEnd = drafting
|
|
186
|
+
? null
|
|
187
|
+
: (ends.find((end) => end.candidate && openWallEndKey(end) === activeKey) ?? null)
|
|
188
|
+
|
|
189
|
+
useEffect(() => {
|
|
190
|
+
if (!pinnedKey) return
|
|
191
|
+
const onPointerDown = (event: PointerEvent) => {
|
|
192
|
+
const target = event.target instanceof Element ? event.target : null
|
|
193
|
+
if (target?.closest('[data-open-wall-end]')) return
|
|
194
|
+
setPinnedKey(null)
|
|
195
|
+
}
|
|
196
|
+
window.addEventListener('pointerdown', onPointerDown, true)
|
|
197
|
+
return () => window.removeEventListener('pointerdown', onPointerDown, true)
|
|
198
|
+
}, [pinnedKey])
|
|
199
|
+
|
|
200
|
+
useEffect(() => {
|
|
201
|
+
if (!activeEnd) return
|
|
202
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
203
|
+
if (event.key !== 'Escape') return
|
|
204
|
+
event.stopPropagation()
|
|
205
|
+
clearReleaseTimer(releaseTimer)
|
|
206
|
+
setPinnedKey(null)
|
|
207
|
+
setHoveredKey(null)
|
|
208
|
+
setRefusal(null)
|
|
209
|
+
}
|
|
210
|
+
window.addEventListener('keydown', onKeyDown, true)
|
|
211
|
+
return () => window.removeEventListener('keydown', onKeyDown, true)
|
|
212
|
+
}, [activeEnd])
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
activeKey,
|
|
216
|
+
activeEnd,
|
|
217
|
+
refusal: activeEnd && refusal?.key === openWallEndKey(activeEnd) ? refusal.message : null,
|
|
218
|
+
keepHover: (key: string) => {
|
|
219
|
+
clearTimer()
|
|
220
|
+
setHoveredKey(key)
|
|
221
|
+
},
|
|
222
|
+
releaseHover: () => {
|
|
223
|
+
clearTimer()
|
|
224
|
+
releaseTimer.current = setTimeout(() => setHoveredKey(null), HOVER_RELEASE_MS)
|
|
225
|
+
},
|
|
226
|
+
togglePin: (key: string) => setPinnedKey((current) => (current === key ? null : key)),
|
|
227
|
+
pin: (key: string) => setPinnedKey(key),
|
|
228
|
+
join: (end: OpenWallEnd) => {
|
|
229
|
+
const key = openWallEndKey(end)
|
|
230
|
+
const message = joinOpenWallEnd(end)
|
|
231
|
+
setRefusal(message ? { key, message } : null)
|
|
232
|
+
if (message) return
|
|
233
|
+
sfxEmitter.emit('sfx:structure-build')
|
|
234
|
+
setPinnedKey(null)
|
|
235
|
+
setHoveredKey(null)
|
|
236
|
+
},
|
|
237
|
+
}
|
|
238
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { type AnyNode, registerNode } from '@pascal-app/core'
|
|
3
|
+
import { placementNoticeOf, transientDraftId } from './use-placement-notice'
|
|
4
|
+
|
|
5
|
+
// The editor asks a node's definition for its placement notice; it names no kind.
|
|
6
|
+
function registerKind(placementNotice?: unknown): string {
|
|
7
|
+
const kind = `test:notice-${crypto.randomUUID()}`
|
|
8
|
+
registerNode({
|
|
9
|
+
kind,
|
|
10
|
+
schemaVersion: 1,
|
|
11
|
+
category: 'furnish',
|
|
12
|
+
defaults: () => ({}),
|
|
13
|
+
capabilities: {},
|
|
14
|
+
...(placementNotice ? { placementNotice } : {}),
|
|
15
|
+
} as never)
|
|
16
|
+
return kind
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const nodeOf = (id: string, type: string, metadata?: Record<string, unknown>) =>
|
|
20
|
+
({ id, type, parentId: 'level_a', metadata: metadata ?? {} }) as unknown as AnyNode
|
|
21
|
+
|
|
22
|
+
describe('the placement notice of the node in hand', () => {
|
|
23
|
+
test("comes from the node's definition, at its live pose", () => {
|
|
24
|
+
const kind = registerKind((node: AnyNode, ctx: { live?: { position: number[] } }) => ({
|
|
25
|
+
line: `${node.id} at ${ctx.live?.position.join(',') ?? 'rest'}`,
|
|
26
|
+
}))
|
|
27
|
+
const nodes = { a: nodeOf('a', kind) }
|
|
28
|
+
expect(placementNoticeOf(nodes, 'a')).toEqual({ line: 'a at rest' })
|
|
29
|
+
expect(placementNoticeOf(nodes, 'a', { position: [1, 0, 2], rotation: 0 })).toEqual({
|
|
30
|
+
line: 'a at 1,0,2',
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('is null for a kind that declares none, and for a node not in the scene', () => {
|
|
35
|
+
const nodes = { b: nodeOf('b', registerKind()) }
|
|
36
|
+
expect(placementNoticeOf(nodes, 'b')).toBeNull()
|
|
37
|
+
expect(placementNoticeOf(nodes, 'missing')).toBeNull()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test("a tool's transient draft is found whatever its kind", () => {
|
|
41
|
+
const nodes = {
|
|
42
|
+
kept: nodeOf('kept', registerKind()),
|
|
43
|
+
draft: nodeOf('draft', registerKind(), { isTransient: true }),
|
|
44
|
+
}
|
|
45
|
+
expect(transientDraftId(nodes) as string | null).toBe('draft')
|
|
46
|
+
expect(transientDraftId({ kept: nodes.kept })).toBeNull()
|
|
47
|
+
})
|
|
48
|
+
})
|