@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,218 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AnyNode,
|
|
3
|
+
type AnyNodeId,
|
|
4
|
+
area,
|
|
5
|
+
cutFloorOpening,
|
|
6
|
+
type FloorOpeningHint,
|
|
7
|
+
generateId,
|
|
8
|
+
type LevelNode,
|
|
9
|
+
type MultiPolygon,
|
|
10
|
+
type StructurePlan,
|
|
11
|
+
structureChangeBatch,
|
|
12
|
+
useScene,
|
|
13
|
+
} from '@pascal-app/core'
|
|
14
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
15
|
+
import { create } from 'zustand'
|
|
16
|
+
import { getRoomSelectionIndex } from '../hooks/use-selected-room'
|
|
17
|
+
import useEditor from '../store/use-editor'
|
|
18
|
+
import {
|
|
19
|
+
CROSSING_MESSAGE,
|
|
20
|
+
clipFloorRegion,
|
|
21
|
+
floorRegionAxisAngle,
|
|
22
|
+
floorRegionSelfIntersects,
|
|
23
|
+
} from './floor-region-geometry'
|
|
24
|
+
import {
|
|
25
|
+
cancelFloorRegion,
|
|
26
|
+
type FloorDraftCommit,
|
|
27
|
+
type FloorDraftPurpose,
|
|
28
|
+
type FloorRegionRoom,
|
|
29
|
+
useFloorRegionDraft,
|
|
30
|
+
} from './floor-region-session'
|
|
31
|
+
import { type FloorRegionPoint, floorRegionSnapTargets } from './floor-region-snap'
|
|
32
|
+
import { beginGesture, type GestureHandle } from './gesture-lifecycle'
|
|
33
|
+
import { resolveRoomAssemblyHeights } from './room-assembly-overlay'
|
|
34
|
+
import { roomFloorElevation } from './room-handle-drag'
|
|
35
|
+
import { sfxEmitter } from './sfx-bus'
|
|
36
|
+
|
|
37
|
+
// "Cut opening": draws an opening on a room's floor (or its ceiling) with the
|
|
38
|
+
// floor region draft, rectangle or polygon, snapped to the room's wall faces
|
|
39
|
+
// and its other openings. One outline is one `floor-opening` node (core's
|
|
40
|
+
// `cutFloorOpening`), one undo step; the reconciler cuts the surfaces. The new
|
|
41
|
+
// opening is then selected so its shape and switch are a click away, and Done
|
|
42
|
+
// or Delete returns to the room.
|
|
43
|
+
|
|
44
|
+
export const OPENING_DRAFT_HANDLE = 'floor-opening-draft'
|
|
45
|
+
export const OPENING_OUTSIDE_MESSAGE = 'Draw the opening inside the room.'
|
|
46
|
+
export const OPENING_TOO_SMALL_MESSAGE = 'Too small for an opening.'
|
|
47
|
+
export const OPENING_FAILED_MESSAGE = "Can't cut an opening here."
|
|
48
|
+
/** Smallest opening worth cutting, m². */
|
|
49
|
+
const MIN_OPENING_AREA = 0.04
|
|
50
|
+
|
|
51
|
+
export type OpeningShape = 'rectangle' | 'polygon'
|
|
52
|
+
export type OpeningSurface = 'floor' | 'ceiling'
|
|
53
|
+
|
|
54
|
+
export const useOpeningDraft = create<{
|
|
55
|
+
host: (FloorRegionRoom & { drawnOn: OpeningSurface }) | null
|
|
56
|
+
shape: OpeningShape
|
|
57
|
+
/** Hints the last cut returned (a manual ceiling below that is not cut). */
|
|
58
|
+
hints: FloorOpeningHint[]
|
|
59
|
+
}>(() => ({ host: null, shape: 'rectangle', hints: [] }))
|
|
60
|
+
|
|
61
|
+
/** The plan for an outline, or why it cannot be cut. */
|
|
62
|
+
export function planFloorOpening(
|
|
63
|
+
nodes: Record<string, AnyNode>,
|
|
64
|
+
host: FloorRegionRoom,
|
|
65
|
+
drawnOn: OpeningSurface,
|
|
66
|
+
polygon: readonly FloorRegionPoint[],
|
|
67
|
+
):
|
|
68
|
+
| { plan: StructurePlan & { openingIds: string[]; hints: FloorOpeningHint[] } }
|
|
69
|
+
| { message: string } {
|
|
70
|
+
const outline = polygon.map(([x, z]): [number, number] => [x, z])
|
|
71
|
+
if (floorRegionSelfIntersects(outline)) return { message: CROSSING_MESSAGE }
|
|
72
|
+
if (area([{ outer: outline, holes: [] }]) < MIN_OPENING_AREA)
|
|
73
|
+
return { message: OPENING_TOO_SMALL_MESSAGE }
|
|
74
|
+
if (!clipFloorRegion(polygon, host.clear)) return { message: OPENING_OUTSIDE_MESSAGE }
|
|
75
|
+
try {
|
|
76
|
+
const plan = cutFloorOpening(nodes, {
|
|
77
|
+
zoneId: host.zoneId,
|
|
78
|
+
polygon: outline,
|
|
79
|
+
drawnOn,
|
|
80
|
+
mintId: generateId,
|
|
81
|
+
})
|
|
82
|
+
const conflict = plan.conflicts?.[0]
|
|
83
|
+
return conflict ? { message: conflict.message || OPENING_FAILED_MESSAGE } : { plan }
|
|
84
|
+
} catch {
|
|
85
|
+
return { message: OPENING_FAILED_MESSAGE }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let tool: GestureHandle | null = null
|
|
90
|
+
|
|
91
|
+
function reset() {
|
|
92
|
+
if (useOpeningDraft.getState().host) useOpeningDraft.setState({ host: null })
|
|
93
|
+
const { draft } = useFloorRegionDraft.getState()
|
|
94
|
+
if (draft?.room.purpose === OPENING) useFloorRegionDraft.setState({ draft: null, hover: null })
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function commitOpening(room: FloorRegionRoom, polygon: readonly FloorRegionPoint[]) {
|
|
98
|
+
const host = useOpeningDraft.getState().host
|
|
99
|
+
const drawnOn = host?.drawnOn ?? 'floor'
|
|
100
|
+
const result = planFloorOpening(useScene.getState().nodes, room, drawnOn, polygon)
|
|
101
|
+
if ('message' in result) return { ok: false, message: result.message } as FloorDraftCommit
|
|
102
|
+
const apply = () =>
|
|
103
|
+
useScene.getState().applyNodeChanges(structureChangeBatch(result.plan.changes))
|
|
104
|
+
const handle = tool
|
|
105
|
+
tool = null
|
|
106
|
+
try {
|
|
107
|
+
if (handle?.active) handle.finish(apply)
|
|
108
|
+
else apply()
|
|
109
|
+
} finally {
|
|
110
|
+
reset()
|
|
111
|
+
}
|
|
112
|
+
sfxEmitter.emit('sfx:structure-build')
|
|
113
|
+
useOpeningDraft.setState({ hints: result.plan.hints })
|
|
114
|
+
const openingId = result.plan.openingIds[0]
|
|
115
|
+
if (openingId) useViewer.getState().setSelection({ selectedIds: [openingId as AnyNodeId] })
|
|
116
|
+
return { ok: true } as const
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function hostGone(host: FloorRegionRoom) {
|
|
120
|
+
const scene = useScene.getState()
|
|
121
|
+
const zone = scene.nodes[host.zoneId as AnyNodeId]
|
|
122
|
+
return scene.readOnly || zone?.type !== 'zone' || zone.parentId !== host.levelId
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function beginTool(host: FloorRegionRoom & { drawnOn: OpeningSurface }) {
|
|
126
|
+
const handle = beginGesture({
|
|
127
|
+
kind: 'floor-opening-draft',
|
|
128
|
+
scope: { kind: 'handle-drag', nodeId: host.zoneId, handle: OPENING_DRAFT_HANDLE },
|
|
129
|
+
stale: () => hostGone(host),
|
|
130
|
+
onCancel: () => {
|
|
131
|
+
if (tool === handle) tool = null
|
|
132
|
+
reset()
|
|
133
|
+
},
|
|
134
|
+
})
|
|
135
|
+
tool = handle
|
|
136
|
+
useOpeningDraft.setState({ host, hints: [] })
|
|
137
|
+
return handle
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const OPENING: FloorDraftPurpose = {
|
|
141
|
+
persistent: true,
|
|
142
|
+
own: (room) => {
|
|
143
|
+
if (tool?.active) return tool
|
|
144
|
+
const host = useOpeningDraft.getState().host
|
|
145
|
+
return beginTool({ ...room, drawnOn: host?.drawnOn ?? 'floor' })
|
|
146
|
+
},
|
|
147
|
+
commit: commitOpening,
|
|
148
|
+
fill: '#f59e0b',
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The room an opening is drawn in: its clear floor bounds and snaps the
|
|
153
|
+
* outline (with the level's other openings); the draft plane is its floor, or
|
|
154
|
+
* its ceiling for a ceiling opening. A mezzanine draws on its own deck.
|
|
155
|
+
*/
|
|
156
|
+
export function describeOpeningHost(
|
|
157
|
+
nodes: Record<string, AnyNode>,
|
|
158
|
+
zoneId: string,
|
|
159
|
+
drawnOn: OpeningSurface,
|
|
160
|
+
): (FloorRegionRoom & { drawnOn: OpeningSurface }) | null {
|
|
161
|
+
const zone = nodes[zoneId]
|
|
162
|
+
if (zone?.type !== 'zone' || zone.spaceRole !== 'room' || !zone.parentId) return null
|
|
163
|
+
if (zone.floor?.support === 'open' && drawnOn !== 'floor') return null
|
|
164
|
+
const record = getRoomSelectionIndex(zone.parentId)
|
|
165
|
+
.update(nodes as ReturnType<typeof useScene.getState>['nodes'])
|
|
166
|
+
.find((room) => room.zoneId === zoneId)
|
|
167
|
+
if (!record) return null
|
|
168
|
+
const clear: MultiPolygon = record.clearPolygon
|
|
169
|
+
const others = Object.values(nodes).flatMap((node) =>
|
|
170
|
+
node.type === 'floor-opening' && node.parentId === zone.parentId ? [node.polygon] : [],
|
|
171
|
+
)
|
|
172
|
+
const heights = resolveRoomAssemblyHeights(record, nodes)
|
|
173
|
+
const elevation =
|
|
174
|
+
drawnOn === 'ceiling'
|
|
175
|
+
? (heights.ceiling?.y ?? heights.floorY + 2.5)
|
|
176
|
+
: roomFloorElevation(nodes, zoneId)
|
|
177
|
+
return {
|
|
178
|
+
zoneId,
|
|
179
|
+
levelId: zone.parentId,
|
|
180
|
+
clear,
|
|
181
|
+
elevation,
|
|
182
|
+
angle: floorRegionAxisAngle(clear),
|
|
183
|
+
targets: floorRegionSnapTargets(clear, others),
|
|
184
|
+
purpose: OPENING,
|
|
185
|
+
drawnOn,
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Arms "Cut opening" in a room, on its floor or its ceiling. False when it cannot. */
|
|
190
|
+
export function startOpeningDraft(
|
|
191
|
+
zoneId: string,
|
|
192
|
+
drawnOn: OpeningSurface = 'floor',
|
|
193
|
+
shape: OpeningShape = 'rectangle',
|
|
194
|
+
): boolean {
|
|
195
|
+
const nodes = useScene.getState().nodes
|
|
196
|
+
if (useScene.getState().readOnly) return false
|
|
197
|
+
const host = describeOpeningHost(nodes, zoneId, drawnOn)
|
|
198
|
+
if (!host) return false
|
|
199
|
+
cancelOpeningDraft()
|
|
200
|
+
cancelFloorRegion()
|
|
201
|
+
useOpeningDraft.setState({ shape })
|
|
202
|
+
if (useEditor.getState().mode !== 'select') useEditor.getState().setMode('select')
|
|
203
|
+
if (useViewer.getState().selection.levelId !== host.levelId)
|
|
204
|
+
useViewer.getState().setSelection({ levelId: host.levelId as LevelNode['id'] })
|
|
205
|
+
beginTool(host)
|
|
206
|
+
return true
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function cancelOpeningDraft() {
|
|
210
|
+
const current = tool
|
|
211
|
+
tool = null
|
|
212
|
+
if (current?.active) current.cancel()
|
|
213
|
+
else reset()
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function openingDraftActive() {
|
|
217
|
+
return !!useOpeningDraft.getState().host
|
|
218
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
type AnyNode,
|
|
4
|
+
BuildingNode,
|
|
5
|
+
createMezzanine,
|
|
6
|
+
createZone,
|
|
7
|
+
type FloorOpeningNode,
|
|
8
|
+
generateId,
|
|
9
|
+
initSpaceDetectionSync,
|
|
10
|
+
LevelNode,
|
|
11
|
+
structureChangeBatch,
|
|
12
|
+
useScene,
|
|
13
|
+
} from '@pascal-app/core'
|
|
14
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
15
|
+
import useEditor from '../store/use-editor'
|
|
16
|
+
import { installImmediateAnimationFrames } from '../test-utils/immediate-animation-frames'
|
|
17
|
+
import {
|
|
18
|
+
deleteFloorOpening,
|
|
19
|
+
finishFloorEdit,
|
|
20
|
+
finishFloorOpening,
|
|
21
|
+
roomSurfaceOpenings,
|
|
22
|
+
startFloorEdit,
|
|
23
|
+
useFloorEditSession,
|
|
24
|
+
} from './floor-edit-session'
|
|
25
|
+
import {
|
|
26
|
+
cancelOpeningDraft,
|
|
27
|
+
describeOpeningHost,
|
|
28
|
+
OPENING_OUTSIDE_MESSAGE,
|
|
29
|
+
OPENING_TOO_SMALL_MESSAGE,
|
|
30
|
+
openingDraftActive,
|
|
31
|
+
planFloorOpening,
|
|
32
|
+
startOpeningDraft,
|
|
33
|
+
useOpeningDraft,
|
|
34
|
+
} from './floor-opening-draft'
|
|
35
|
+
import { applyRoomPlan } from './room-structure-commands'
|
|
36
|
+
|
|
37
|
+
const GROUND = 'level_openings_ground'
|
|
38
|
+
const UPPER = 'level_openings_upper'
|
|
39
|
+
let stop = () => {}
|
|
40
|
+
let restoreFrames = () => {}
|
|
41
|
+
const nodes = () => useScene.getState().nodes as Record<string, AnyNode>
|
|
42
|
+
const history = () => useScene.temporal.getState().pastStates.length
|
|
43
|
+
const groundCeilingHoles = () =>
|
|
44
|
+
Object.values(nodes()).flatMap((n) =>
|
|
45
|
+
n.type === 'ceiling' && n.parentId === GROUND ? (n.holes ?? []) : [],
|
|
46
|
+
)
|
|
47
|
+
const upperPlateHoles = () =>
|
|
48
|
+
Object.values(nodes()).flatMap((n) =>
|
|
49
|
+
n.type === 'slab' && n.parentId === UPPER ? (n.holes ?? []) : [],
|
|
50
|
+
)
|
|
51
|
+
const openings = () =>
|
|
52
|
+
Object.values(nodes()).filter((n): n is FloorOpeningNode => n.type === 'floor-opening')
|
|
53
|
+
|
|
54
|
+
const rect = (x0: number, z0: number, x1: number, z1: number): [number, number][] => [
|
|
55
|
+
[x0, z0],
|
|
56
|
+
[x1, z0],
|
|
57
|
+
[x1, z1],
|
|
58
|
+
[x0, z1],
|
|
59
|
+
]
|
|
60
|
+
const room = (levelId: string, name: string) => {
|
|
61
|
+
const plan = createZone(nodes(), {
|
|
62
|
+
levelId,
|
|
63
|
+
polygon: rect(0, 0, 8, 5),
|
|
64
|
+
enclose: true,
|
|
65
|
+
mintId: generateId,
|
|
66
|
+
name,
|
|
67
|
+
})
|
|
68
|
+
applyRoomPlan(plan)
|
|
69
|
+
return plan.zoneId
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let groundRoom: string
|
|
73
|
+
let upperRoom: string
|
|
74
|
+
|
|
75
|
+
beforeEach(() => {
|
|
76
|
+
restoreFrames = installImmediateAnimationFrames()
|
|
77
|
+
const building = BuildingNode.parse({ id: 'building_openings', children: [GROUND, UPPER] })
|
|
78
|
+
const ground = LevelNode.parse({ id: GROUND, parentId: building.id, level: 0 })
|
|
79
|
+
const upper = LevelNode.parse({ id: UPPER, parentId: building.id, level: 1 })
|
|
80
|
+
useScene.setState({
|
|
81
|
+
nodes: { [building.id]: building, [ground.id]: ground, [upper.id]: upper },
|
|
82
|
+
rootNodeIds: [building.id],
|
|
83
|
+
dirtyNodes: new Set(),
|
|
84
|
+
materials: {},
|
|
85
|
+
collections: {},
|
|
86
|
+
readOnly: false,
|
|
87
|
+
})
|
|
88
|
+
useScene.temporal.getState().resume()
|
|
89
|
+
stop = initSpaceDetectionSync(useScene, { getState: () => ({ spaces: {}, setSpaces: () => {} }) })
|
|
90
|
+
groundRoom = room(GROUND, 'Hall')
|
|
91
|
+
upperRoom = room(UPPER, 'Landing')
|
|
92
|
+
useScene.temporal.getState().clear()
|
|
93
|
+
useEditor.setState({ phase: 'building', mode: 'select', room: null })
|
|
94
|
+
useViewer.getState().setSelection({ buildingId: building.id, levelId: UPPER, selectedIds: [] })
|
|
95
|
+
})
|
|
96
|
+
afterEach(() => {
|
|
97
|
+
cancelOpeningDraft()
|
|
98
|
+
finishFloorEdit()
|
|
99
|
+
stop()
|
|
100
|
+
restoreFrames()
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
const cutVoid = () => {
|
|
104
|
+
const host = describeOpeningHost(nodes(), upperRoom, 'floor')!
|
|
105
|
+
const result = planFloorOpening(nodes(), host, 'floor', rect(2, 1, 4, 2))
|
|
106
|
+
if ('message' in result) throw Error(result.message)
|
|
107
|
+
useScene.getState().applyNodeChanges(structureChangeBatch(result.plan.changes))
|
|
108
|
+
return result.plan.openingIds[0]!
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
describe('Cut opening (point 8)', () => {
|
|
112
|
+
test('an outline outside the room or too small is refused, in words', () => {
|
|
113
|
+
const host = describeOpeningHost(nodes(), upperRoom, 'floor')!
|
|
114
|
+
expect(planFloorOpening(nodes(), host, 'floor', rect(20, 20, 22, 22))).toEqual({
|
|
115
|
+
message: OPENING_OUTSIDE_MESSAGE,
|
|
116
|
+
})
|
|
117
|
+
expect(planFloorOpening(nodes(), host, 'floor', rect(1, 1, 1.1, 1.1))).toEqual({
|
|
118
|
+
message: OPENING_TOO_SMALL_MESSAGE,
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('a floor opening over a room below also cuts its ceiling, in one undo step', () => {
|
|
123
|
+
const id = cutVoid()
|
|
124
|
+
expect(history()).toBe(1)
|
|
125
|
+
const opening = nodes()[id] as FloorOpeningNode
|
|
126
|
+
expect(opening).toMatchObject({ parentId: UPPER, drawnOn: 'floor', cutsAdjacent: true })
|
|
127
|
+
expect(roomSurfaceOpenings(nodes(), upperRoom, 'floor').map((o) => o.id as string)).toEqual([
|
|
128
|
+
id,
|
|
129
|
+
])
|
|
130
|
+
expect(roomSurfaceOpenings(nodes(), groundRoom, 'ceiling').map((o) => o.id as string)).toEqual([
|
|
131
|
+
id,
|
|
132
|
+
])
|
|
133
|
+
expect(groundCeilingHoles()).toHaveLength(1)
|
|
134
|
+
expect(upperPlateHoles()).toHaveLength(1)
|
|
135
|
+
useScene.temporal.getState().undo()
|
|
136
|
+
expect(openings()).toEqual([])
|
|
137
|
+
expect(groundCeilingHoles()).toEqual([])
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
test('with its switch off, only the floor is cut', () => {
|
|
141
|
+
const id = cutVoid()
|
|
142
|
+
useScene.getState().updateNode(id as FloorOpeningNode['id'], { cutsAdjacent: false })
|
|
143
|
+
expect(roomSurfaceOpenings(nodes(), upperRoom, 'floor')).toHaveLength(1)
|
|
144
|
+
expect(roomSurfaceOpenings(nodes(), groundRoom, 'ceiling')).toEqual([])
|
|
145
|
+
expect(groundCeilingHoles()).toEqual([])
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('a ceiling opening on the top storey cuts only the ceiling', () => {
|
|
149
|
+
const host = describeOpeningHost(nodes(), upperRoom, 'ceiling')!
|
|
150
|
+
const result = planFloorOpening(nodes(), host, 'ceiling', rect(2, 1, 4, 2))
|
|
151
|
+
if ('message' in result) throw Error(result.message)
|
|
152
|
+
useScene.getState().applyNodeChanges(structureChangeBatch(result.plan.changes))
|
|
153
|
+
const [opening] = openings()
|
|
154
|
+
expect(opening).toMatchObject({ drawnOn: 'ceiling', cutsAdjacent: false })
|
|
155
|
+
expect(roomSurfaceOpenings(nodes(), upperRoom, 'ceiling')).toHaveLength(1)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
test('a hatch in a mezzanine cuts only the mezzanine', () => {
|
|
159
|
+
const plan = createMezzanine(nodes(), {
|
|
160
|
+
hostZoneId: groundRoom,
|
|
161
|
+
polygon: rect(1, 1, 5, 4),
|
|
162
|
+
mintId: generateId,
|
|
163
|
+
})
|
|
164
|
+
applyRoomPlan(plan)
|
|
165
|
+
const host = describeOpeningHost(nodes(), plan.zoneId, 'floor')!
|
|
166
|
+
const result = planFloorOpening(nodes(), host, 'floor', rect(2, 2, 3, 3))
|
|
167
|
+
if ('message' in result) throw Error(result.message)
|
|
168
|
+
useScene.getState().applyNodeChanges(structureChangeBatch(result.plan.changes))
|
|
169
|
+
const [hatch] = openings()
|
|
170
|
+
expect(hatch).toMatchObject({ hostZoneId: plan.zoneId, cutsAdjacent: false })
|
|
171
|
+
expect(roomSurfaceOpenings(nodes(), plan.zoneId, 'floor').map((o) => o.id)).toEqual([hatch!.id])
|
|
172
|
+
expect(roomSurfaceOpenings(nodes(), groundRoom, 'floor')).toEqual([])
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
test('the shape is picked before drawing, not toggled during it', () => {
|
|
176
|
+
expect(startOpeningDraft(upperRoom, 'floor', 'polygon')).toBe(true)
|
|
177
|
+
expect(useOpeningDraft.getState().shape).toBe('polygon')
|
|
178
|
+
cancelOpeningDraft()
|
|
179
|
+
expect(startOpeningDraft(upperRoom, 'floor')).toBe(true)
|
|
180
|
+
expect(useOpeningDraft.getState().shape).toBe('rectangle')
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test('the tool arms on the room and drops cleanly on cancel, writing nothing', () => {
|
|
184
|
+
expect(startOpeningDraft(upperRoom, 'floor')).toBe(true)
|
|
185
|
+
expect(openingDraftActive()).toBe(true)
|
|
186
|
+
cancelOpeningDraft()
|
|
187
|
+
expect(openingDraftActive()).toBe(false)
|
|
188
|
+
expect(history()).toBe(0)
|
|
189
|
+
})
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
describe('Edit floor, Done and Delete return to the room', () => {
|
|
193
|
+
test('Edit floor is a session on the room; Done ends it on the room', () => {
|
|
194
|
+
useEditor.getState().selectRoom({ levelId: UPPER, zoneId: upperRoom })
|
|
195
|
+
expect(startFloorEdit(upperRoom)).toBe(true)
|
|
196
|
+
expect(useFloorEditSession.getState().session?.zoneId).toBe(upperRoom)
|
|
197
|
+
expect(finishFloorEdit()).toBe(true)
|
|
198
|
+
expect(useFloorEditSession.getState().session).toBeNull()
|
|
199
|
+
expect(useEditor.getState().room).toEqual({ levelId: UPPER, zoneId: upperRoom })
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
test('Done in the opening panel and Delete both land on the room', () => {
|
|
203
|
+
const id = cutVoid()
|
|
204
|
+
useViewer.getState().setSelection({ selectedIds: [id as FloorOpeningNode['id']] })
|
|
205
|
+
expect(finishFloorOpening(id)).toBe(true)
|
|
206
|
+
expect(useViewer.getState().selection.selectedIds).toEqual([])
|
|
207
|
+
expect(useEditor.getState().room).toEqual({ levelId: UPPER, zoneId: upperRoom })
|
|
208
|
+
|
|
209
|
+
useViewer.getState().setSelection({ selectedIds: [id as FloorOpeningNode['id']] })
|
|
210
|
+
const before = history()
|
|
211
|
+
expect(deleteFloorOpening(id)).toBe(true)
|
|
212
|
+
expect(openings()).toEqual([])
|
|
213
|
+
expect(history()).toBe(before + 1)
|
|
214
|
+
expect(useEditor.getState().room).toEqual({ levelId: UPPER, zoneId: upperRoom })
|
|
215
|
+
})
|
|
216
|
+
})
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { area, containsPoint, intersection, type MultiPolygon } from '@pascal-app/core'
|
|
2
|
+
import type { FloorRegionPoint } from './floor-region-snap'
|
|
3
|
+
|
|
4
|
+
// Pure geometry for "Paint part of the floor": which room a plan point is in,
|
|
5
|
+
// the box a rectangle drag draws, crossing edges, and the clip to the room.
|
|
6
|
+
|
|
7
|
+
/** Smaller than this (m²) is a click, not a region. */
|
|
8
|
+
export const MIN_FLOOR_REGION_AREA = 0.01
|
|
9
|
+
export const CROSSING_MESSAGE = "Edges can't cross"
|
|
10
|
+
export const OUTSIDE_MESSAGE = 'Draw inside the room'
|
|
11
|
+
|
|
12
|
+
/** The first room (in the given order) whose clear floor holds the point. */
|
|
13
|
+
/**
|
|
14
|
+
* The room whose clear floor holds `point`. Rooms can stack (a mezzanine over
|
|
15
|
+
* its host): with `elevationOf`, the topmost floor wins, as the plan shows it
|
|
16
|
+
* and as a 3D ray from above would land.
|
|
17
|
+
*/
|
|
18
|
+
export function floorRegionRoomAt<T extends { clearPolygon: MultiPolygon }>(
|
|
19
|
+
rooms: readonly T[],
|
|
20
|
+
point: FloorRegionPoint,
|
|
21
|
+
elevationOf?: (room: T) => number,
|
|
22
|
+
): T | null {
|
|
23
|
+
let best: T | null = null
|
|
24
|
+
let bestElevation = Number.NEGATIVE_INFINITY
|
|
25
|
+
for (const room of rooms) {
|
|
26
|
+
if (!containsPoint(room.clearPolygon, point)) continue
|
|
27
|
+
if (!elevationOf) return room
|
|
28
|
+
const elevation = elevationOf(room)
|
|
29
|
+
if (elevation > bestElevation) {
|
|
30
|
+
best = room
|
|
31
|
+
bestElevation = elevation
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return best
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The rectangle frame of a room: the direction of its longest boundary edge,
|
|
39
|
+
* folded into [-45°, 45°) so an axis-aligned room reads exactly 0 and a box
|
|
40
|
+
* drawn in a room at an angle follows the room.
|
|
41
|
+
*/
|
|
42
|
+
export function floorRegionAxisAngle(clear: MultiPolygon): number {
|
|
43
|
+
let longest = 0
|
|
44
|
+
let angle = 0
|
|
45
|
+
for (const { outer } of clear) {
|
|
46
|
+
for (let i = 0; i < outer.length; i++) {
|
|
47
|
+
const a = outer[i]!
|
|
48
|
+
const b = outer[(i + 1) % outer.length]!
|
|
49
|
+
const length = Math.hypot(b[0] - a[0], b[1] - a[1])
|
|
50
|
+
if (length > longest + 1e-9) {
|
|
51
|
+
longest = length
|
|
52
|
+
angle = Math.atan2(b[1] - a[1], b[0] - a[0])
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const quarter = Math.PI / 2
|
|
57
|
+
let folded = angle - Math.round(angle / quarter) * quarter
|
|
58
|
+
if (folded >= Math.PI / 4) folded -= quarter
|
|
59
|
+
return Math.abs(folded) < 1e-9 ? 0 : folded
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The box with opposite corners `start` and `end`, its sides along `angle` and across it. */
|
|
63
|
+
export function floorRegionRectangle(
|
|
64
|
+
start: FloorRegionPoint,
|
|
65
|
+
end: FloorRegionPoint,
|
|
66
|
+
angle: number,
|
|
67
|
+
): FloorRegionPoint[] {
|
|
68
|
+
const dx = end[0] - start[0]
|
|
69
|
+
const dz = end[1] - start[1]
|
|
70
|
+
if (angle === 0)
|
|
71
|
+
return [
|
|
72
|
+
[start[0], start[1]],
|
|
73
|
+
[end[0], start[1]],
|
|
74
|
+
[end[0], end[1]],
|
|
75
|
+
[start[0], end[1]],
|
|
76
|
+
]
|
|
77
|
+
const ux = Math.cos(angle)
|
|
78
|
+
const uz = Math.sin(angle)
|
|
79
|
+
const along = dx * ux + dz * uz
|
|
80
|
+
const across = -dx * uz + dz * ux
|
|
81
|
+
const corner = (u: number, v: number): FloorRegionPoint => [
|
|
82
|
+
start[0] + u * ux - v * uz,
|
|
83
|
+
start[1] + u * uz + v * ux,
|
|
84
|
+
]
|
|
85
|
+
return [corner(0, 0), corner(along, 0), corner(along, across), corner(0, across)]
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function orientation(a: FloorRegionPoint, b: FloorRegionPoint, c: FloorRegionPoint) {
|
|
89
|
+
return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const EPSILON = 1e-9
|
|
93
|
+
|
|
94
|
+
function onSegment(a: FloorRegionPoint, b: FloorRegionPoint, p: FloorRegionPoint) {
|
|
95
|
+
return (
|
|
96
|
+
Math.min(a[0], b[0]) - EPSILON <= p[0] &&
|
|
97
|
+
p[0] <= Math.max(a[0], b[0]) + EPSILON &&
|
|
98
|
+
Math.min(a[1], b[1]) - EPSILON <= p[1] &&
|
|
99
|
+
p[1] <= Math.max(a[1], b[1]) + EPSILON
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Whether two segments touch or cross (end points included). */
|
|
104
|
+
export function segmentsTouch(
|
|
105
|
+
a: FloorRegionPoint,
|
|
106
|
+
b: FloorRegionPoint,
|
|
107
|
+
c: FloorRegionPoint,
|
|
108
|
+
d: FloorRegionPoint,
|
|
109
|
+
): boolean {
|
|
110
|
+
const o1 = orientation(a, b, c)
|
|
111
|
+
const o2 = orientation(a, b, d)
|
|
112
|
+
const o3 = orientation(c, d, a)
|
|
113
|
+
const o4 = orientation(c, d, b)
|
|
114
|
+
const s = (value: number) => (Math.abs(value) < EPSILON ? 0 : Math.sign(value))
|
|
115
|
+
if (s(o1) * s(o2) < 0 && s(o3) * s(o4) < 0) return true
|
|
116
|
+
return (
|
|
117
|
+
(s(o1) === 0 && onSegment(a, b, c)) ||
|
|
118
|
+
(s(o2) === 0 && onSegment(a, b, d)) ||
|
|
119
|
+
(s(o3) === 0 && onSegment(c, d, a)) ||
|
|
120
|
+
(s(o4) === 0 && onSegment(c, d, b))
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Whether a segment folds back along its neighbour (shared end, overlapping). */
|
|
125
|
+
function foldsBack(shared: FloorRegionPoint, previous: FloorRegionPoint, next: FloorRegionPoint) {
|
|
126
|
+
const cross = orientation(shared, previous, next)
|
|
127
|
+
const dot =
|
|
128
|
+
(previous[0] - shared[0]) * (next[0] - shared[0]) +
|
|
129
|
+
(previous[1] - shared[1]) * (next[1] - shared[1])
|
|
130
|
+
return Math.abs(cross) < EPSILON && dot > 0
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Whether adding `next` after the open path `points` makes an edge cross (or
|
|
135
|
+
* fold back onto) an earlier one. With `closing`, `next` is the first point and
|
|
136
|
+
* the new edge closes the ring.
|
|
137
|
+
*/
|
|
138
|
+
export function floorRegionEdgeCrosses(
|
|
139
|
+
points: readonly FloorRegionPoint[],
|
|
140
|
+
next: FloorRegionPoint,
|
|
141
|
+
closing = false,
|
|
142
|
+
): boolean {
|
|
143
|
+
const last = points.at(-1)
|
|
144
|
+
if (!last) return false
|
|
145
|
+
if (points.length >= 2 && foldsBack(last, points.at(-2)!, next)) return true
|
|
146
|
+
if (closing && points.length >= 2 && foldsBack(points[0]!, points[1]!, last)) return true
|
|
147
|
+
// Edges i → i+1 that do not share an end with the new edge.
|
|
148
|
+
const firstEdge = closing ? 1 : 0
|
|
149
|
+
for (let i = firstEdge; i < points.length - 2; i++)
|
|
150
|
+
if (segmentsTouch(points[i]!, points[i + 1]!, last, next)) return true
|
|
151
|
+
return false
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Whether a closed ring has two edges that cross. */
|
|
155
|
+
export function floorRegionSelfIntersects(ring: readonly FloorRegionPoint[]): boolean {
|
|
156
|
+
for (let i = 1; i < ring.length; i++)
|
|
157
|
+
if (floorRegionEdgeCrosses(ring.slice(0, i), ring[i]!)) return true
|
|
158
|
+
return ring.length >= 3 && floorRegionEdgeCrosses(ring, ring[0]!, true)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export type FloorRegionClip = {
|
|
162
|
+
/** What gets stored: the clipped outline, or the drawn one when the room splits it. */
|
|
163
|
+
polygon: FloorRegionPoint[]
|
|
164
|
+
/** The painted pieces (what the floor will show). */
|
|
165
|
+
pieces: MultiPolygon
|
|
166
|
+
area: number
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Clips a drawn outline to the room's clear floor. One piece stores its outer
|
|
171
|
+
* ring (the plate partition clips any hole, like a column, again at render).
|
|
172
|
+
* When the room splits the outline into several pieces (a box across a
|
|
173
|
+
* U-shaped room's notch), the drawn outline is stored as is: the renderer
|
|
174
|
+
* intersects every region with the room, so all pieces paint in one region and
|
|
175
|
+
* one undo step. Nothing inside the room, or less than a click's worth, is null.
|
|
176
|
+
*/
|
|
177
|
+
export function clipFloorRegion(
|
|
178
|
+
polygon: readonly FloorRegionPoint[],
|
|
179
|
+
clear: MultiPolygon,
|
|
180
|
+
): FloorRegionClip | null {
|
|
181
|
+
if (polygon.length < 3) return null
|
|
182
|
+
const ring = polygon.map(([x, z]) => [x, z] as FloorRegionPoint)
|
|
183
|
+
const pieces = intersection(ring, clear)
|
|
184
|
+
const clipped = area(pieces)
|
|
185
|
+
if (clipped < MIN_FLOOR_REGION_AREA) return null
|
|
186
|
+
return {
|
|
187
|
+
polygon: pieces.length === 1 ? pieces[0]!.outer.map(([x, z]) => [x, z]) : ring,
|
|
188
|
+
pieces,
|
|
189
|
+
area: clipped,
|
|
190
|
+
}
|
|
191
|
+
}
|