@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,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lot terrain from USGS elevations: an N×N grid of points over the lot's
|
|
3
|
+
* bounding box (padded a touch so the ground runs past the lines) goes to
|
|
4
|
+
* the parcel provider's `elevation` (USGS EPQS, feet), the readings come
|
|
5
|
+
* back relative to a DATUM — the ground at the lot's centre, so the site
|
|
6
|
+
* plane y = 0 is the ground where the house will stand — and are written
|
|
7
|
+
* into the site's heightfield (`site.terrain`, the same field the sculpt
|
|
8
|
+
* tool edits and every placement / raycast / drape reads).
|
|
9
|
+
*
|
|
10
|
+
* Honesty: a web-service DEM is preliminary — the sample record on the
|
|
11
|
+
* site (`metadata.terrainSample`: source, grid, holes, datum, relief) says
|
|
12
|
+
* what was read; a lot flatter than `MIN_RELIEF_M` writes NO terrain and
|
|
13
|
+
* keeps the flat-ground fast path; every failure returns a reason and
|
|
14
|
+
* writes nothing.
|
|
15
|
+
*/
|
|
16
|
+
import {
|
|
17
|
+
commitTerrainField,
|
|
18
|
+
createTerrainField,
|
|
19
|
+
quantize,
|
|
20
|
+
type TerrainData,
|
|
21
|
+
type TerrainField,
|
|
22
|
+
} from '@pascal-app/core'
|
|
23
|
+
import { getParcelProvider, NO_PARCEL_SERVICE, type ParcelProvider } from './parcel-provider'
|
|
24
|
+
|
|
25
|
+
export type Pt = readonly [number, number]
|
|
26
|
+
|
|
27
|
+
/** apps/editor/lib/parcel/project.ts — the plan frame's degree scale. */
|
|
28
|
+
export const FEET_PER_DEG_LAT = 364000
|
|
29
|
+
const METRES_PER_FOOT = 0.3048
|
|
30
|
+
const DEG2RAD = Math.PI / 180
|
|
31
|
+
/** Grid points per side (81 points — well under the route's 256 cap). */
|
|
32
|
+
export const DEFAULT_GRID_N = 9
|
|
33
|
+
/** The lot bbox is padded by this fraction so the mesh runs past the lines. */
|
|
34
|
+
export const PAD_FRAC = 0.08
|
|
35
|
+
/** A lot with less fall than this across its samples is flat: no terrain written. */
|
|
36
|
+
export const MIN_RELIEF_M = 0.15
|
|
37
|
+
/** Heightfield resolution: the bbox split into ~64 cells per side, clamped. */
|
|
38
|
+
const FIELD_CELLS = 64
|
|
39
|
+
const MIN_SPACING_M = 0.5
|
|
40
|
+
const MAX_SPACING_M = 4
|
|
41
|
+
|
|
42
|
+
export interface TerrainSampleSummary {
|
|
43
|
+
source: 'USGS EPQS'
|
|
44
|
+
/** Grid side (n × n points). */
|
|
45
|
+
grid: number
|
|
46
|
+
/** Points with a reading / without one. */
|
|
47
|
+
sampled: number
|
|
48
|
+
holes: number
|
|
49
|
+
/** Absolute elevation of the site plane (the datum), feet. */
|
|
50
|
+
datumFt: number
|
|
51
|
+
/** Highest − lowest reading, feet. */
|
|
52
|
+
reliefFt: number
|
|
53
|
+
/** True when the lot read flat and no heightfield was written. */
|
|
54
|
+
flat: boolean
|
|
55
|
+
at: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface TerrainSampleResult {
|
|
59
|
+
ok: boolean
|
|
60
|
+
reason?: string
|
|
61
|
+
/** The heightfield to write to `site.terrain` (absent when flat). */
|
|
62
|
+
terrain?: TerrainData
|
|
63
|
+
summary?: TerrainSampleSummary
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface SampleGrid {
|
|
67
|
+
n: number
|
|
68
|
+
x0: number
|
|
69
|
+
z0: number
|
|
70
|
+
x1: number
|
|
71
|
+
z1: number
|
|
72
|
+
/** Row-major, `r * n + c`. */
|
|
73
|
+
points: Pt[]
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Local plan metres (the site frame) → `{ lat, lng }`, the inverse of `ringsToPlanFeet`. */
|
|
77
|
+
export function localMetresToLngLat(
|
|
78
|
+
p: Pt,
|
|
79
|
+
originLngLat: readonly [number, number],
|
|
80
|
+
): { lat: number; lng: number } {
|
|
81
|
+
const [oLng, oLat] = originLngLat
|
|
82
|
+
const ftPerDegLng = FEET_PER_DEG_LAT * Math.cos(oLat * DEG2RAD)
|
|
83
|
+
const xFt = p[0] / METRES_PER_FOOT
|
|
84
|
+
const zFt = p[1] / METRES_PER_FOOT
|
|
85
|
+
return { lng: oLng + xFt / ftPerDegLng, lat: oLat - zFt / FEET_PER_DEG_LAT }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The n × n sample grid over the ring's padded bounding box. */
|
|
89
|
+
export function gridOver(ring: readonly Pt[], n = DEFAULT_GRID_N, pad = PAD_FRAC): SampleGrid {
|
|
90
|
+
let minX = Number.POSITIVE_INFINITY
|
|
91
|
+
let minZ = Number.POSITIVE_INFINITY
|
|
92
|
+
let maxX = Number.NEGATIVE_INFINITY
|
|
93
|
+
let maxZ = Number.NEGATIVE_INFINITY
|
|
94
|
+
for (const p of ring) {
|
|
95
|
+
minX = Math.min(minX, p[0])
|
|
96
|
+
maxX = Math.max(maxX, p[0])
|
|
97
|
+
minZ = Math.min(minZ, p[1])
|
|
98
|
+
maxZ = Math.max(maxZ, p[1])
|
|
99
|
+
}
|
|
100
|
+
const padX = (maxX - minX) * pad
|
|
101
|
+
const padZ = (maxZ - minZ) * pad
|
|
102
|
+
const x0 = minX - padX
|
|
103
|
+
const x1 = maxX + padX
|
|
104
|
+
const z0 = minZ - padZ
|
|
105
|
+
const z1 = maxZ + padZ
|
|
106
|
+
const side = Math.max(2, Math.floor(n))
|
|
107
|
+
const points: Pt[] = []
|
|
108
|
+
for (let r = 0; r < side; r++) {
|
|
109
|
+
const z = z0 + ((z1 - z0) * r) / (side - 1)
|
|
110
|
+
for (let c = 0; c < side; c++) points.push([x0 + ((x1 - x0) * c) / (side - 1), z])
|
|
111
|
+
}
|
|
112
|
+
return { n: side, x0, z0, x1, z1, points }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Bilinear read of the coarse grid at a plan point; holes (null readings)
|
|
117
|
+
* read as `fill`. Clamped to the grid — the field never extrapolates.
|
|
118
|
+
*/
|
|
119
|
+
export function coarseHeightAt(
|
|
120
|
+
grid: SampleGrid,
|
|
121
|
+
elev: readonly (number | null)[],
|
|
122
|
+
x: number,
|
|
123
|
+
z: number,
|
|
124
|
+
fill: number,
|
|
125
|
+
): number {
|
|
126
|
+
const n = grid.n
|
|
127
|
+
const fx = grid.x1 === grid.x0 ? 0 : ((x - grid.x0) / (grid.x1 - grid.x0)) * (n - 1)
|
|
128
|
+
const fz = grid.z1 === grid.z0 ? 0 : ((z - grid.z0) / (grid.z1 - grid.z0)) * (n - 1)
|
|
129
|
+
const gx = Math.max(0, Math.min(n - 1, fx))
|
|
130
|
+
const gz = Math.max(0, Math.min(n - 1, fz))
|
|
131
|
+
const c0 = Math.min(n - 2, Math.floor(gx))
|
|
132
|
+
const r0 = Math.min(n - 2, Math.floor(gz))
|
|
133
|
+
const tx = gx - c0
|
|
134
|
+
const tz = gz - r0
|
|
135
|
+
const at = (r: number, c: number): number => {
|
|
136
|
+
const v = elev[r * n + c]
|
|
137
|
+
return v === null || v === undefined || !Number.isFinite(v) ? fill : v
|
|
138
|
+
}
|
|
139
|
+
const top = at(r0, c0) * (1 - tx) + at(r0, c0 + 1) * tx
|
|
140
|
+
const bottom = at(r0 + 1, c0) * (1 - tx) + at(r0 + 1, c0 + 1) * tx
|
|
141
|
+
return top * (1 - tz) + bottom * tz
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** A heightfield over the grid's extent, metres above the datum, bilinear from the coarse samples. */
|
|
145
|
+
export function fieldFromSamples(
|
|
146
|
+
grid: SampleGrid,
|
|
147
|
+
elevM: readonly (number | null)[],
|
|
148
|
+
datumM: number,
|
|
149
|
+
fillM: number,
|
|
150
|
+
): TerrainField {
|
|
151
|
+
const spanX = grid.x1 - grid.x0
|
|
152
|
+
const spanZ = grid.z1 - grid.z0
|
|
153
|
+
const spacing = Math.min(
|
|
154
|
+
MAX_SPACING_M,
|
|
155
|
+
Math.max(MIN_SPACING_M, Math.max(spanX, spanZ) / FIELD_CELLS),
|
|
156
|
+
)
|
|
157
|
+
const cols = Math.max(2, Math.ceil(spanX / spacing) + 1)
|
|
158
|
+
const rows = Math.max(2, Math.ceil(spanZ / spacing) + 1)
|
|
159
|
+
const field = createTerrainField({ origin: [grid.x0, grid.z0], spacing, cols, rows })
|
|
160
|
+
for (let r = 0; r < rows; r++) {
|
|
161
|
+
for (let c = 0; c < cols; c++) {
|
|
162
|
+
const x = grid.x0 + c * spacing
|
|
163
|
+
const z = grid.z0 + r * spacing
|
|
164
|
+
field.heights[r * cols + c] = quantize(
|
|
165
|
+
field,
|
|
166
|
+
coarseHeightAt(grid, elevM, x, z, fillM) - datumM,
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return field
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
type ElevationResponse = {
|
|
174
|
+
ok?: boolean
|
|
175
|
+
error?: string
|
|
176
|
+
results?: { lat?: number; lng?: number; elevation?: number | null }[]
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export interface SampleOptions {
|
|
180
|
+
/** Default = the provider the host set. */
|
|
181
|
+
provider?: ParcelProvider
|
|
182
|
+
/** Grid side, default 9. */
|
|
183
|
+
n?: number
|
|
184
|
+
/** Passed to the route (USGS is slow; the route caps it). */
|
|
185
|
+
deadlineMs?: number
|
|
186
|
+
/** Where the datum is read (site metres); default = the ring's centroid. */
|
|
187
|
+
datumAt?: Pt
|
|
188
|
+
now?: () => string
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Sample the lot. Resolves `{ ok: false, reason }` on every failure path and
|
|
193
|
+
* never throws; `ok: true` with no `terrain` means the lot read flat.
|
|
194
|
+
*/
|
|
195
|
+
export async function sampleLotTerrain(
|
|
196
|
+
ring: readonly Pt[],
|
|
197
|
+
originLngLat: readonly [number, number],
|
|
198
|
+
options: SampleOptions = {},
|
|
199
|
+
): Promise<TerrainSampleResult> {
|
|
200
|
+
if (ring.length < 3) return { ok: false, reason: 'no lot ring' }
|
|
201
|
+
const provider = options.provider ?? getParcelProvider()
|
|
202
|
+
if (!provider) return { ok: false, reason: NO_PARCEL_SERVICE }
|
|
203
|
+
const grid = gridOver(ring, options.n ?? DEFAULT_GRID_N)
|
|
204
|
+
const points = grid.points.map((p) => localMetresToLngLat(p, originLngLat))
|
|
205
|
+
let body: ElevationResponse
|
|
206
|
+
try {
|
|
207
|
+
body = (await provider('elevation', {
|
|
208
|
+
points,
|
|
209
|
+
deadlineMs: options.deadlineMs ?? 12000,
|
|
210
|
+
})) as ElevationResponse
|
|
211
|
+
} catch (error) {
|
|
212
|
+
return { ok: false, reason: error instanceof Error ? error.message : 'elevation lookup failed' }
|
|
213
|
+
}
|
|
214
|
+
if (!body.ok || !Array.isArray(body.results))
|
|
215
|
+
return { ok: false, reason: body.error ?? 'no elevations' }
|
|
216
|
+
const elevFt: (number | null)[] = grid.points.map((_, i) => {
|
|
217
|
+
const e = body.results?.[i]?.elevation
|
|
218
|
+
return typeof e === 'number' && Number.isFinite(e) ? e : null
|
|
219
|
+
})
|
|
220
|
+
const valid = elevFt.filter((e): e is number => e !== null)
|
|
221
|
+
if (valid.length < 4)
|
|
222
|
+
return { ok: false, reason: `too few elevations (${valid.length} of ${elevFt.length})` }
|
|
223
|
+
const mean = valid.reduce((s, v) => s + v, 0) / valid.length
|
|
224
|
+
const datumAt = options.datumAt ?? centroidOf(ring)
|
|
225
|
+
const datumFt = coarseHeightAt(grid, elevFt, datumAt[0], datumAt[1], mean)
|
|
226
|
+
const reliefFt = Math.max(...valid) - Math.min(...valid)
|
|
227
|
+
const summary: TerrainSampleSummary = {
|
|
228
|
+
source: 'USGS EPQS',
|
|
229
|
+
grid: grid.n,
|
|
230
|
+
sampled: valid.length,
|
|
231
|
+
holes: elevFt.length - valid.length,
|
|
232
|
+
datumFt,
|
|
233
|
+
reliefFt,
|
|
234
|
+
flat: reliefFt * METRES_PER_FOOT < MIN_RELIEF_M,
|
|
235
|
+
at: (options.now ?? (() => new Date().toISOString()))(),
|
|
236
|
+
}
|
|
237
|
+
if (summary.flat) return { ok: true, summary }
|
|
238
|
+
const elevM = elevFt.map((e) => (e === null ? null : e * METRES_PER_FOOT))
|
|
239
|
+
const field = fieldFromSamples(grid, elevM, datumFt * METRES_PER_FOOT, mean * METRES_PER_FOOT)
|
|
240
|
+
return { ok: true, terrain: commitTerrainField(field), summary }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function centroidOf(ring: readonly Pt[]): Pt {
|
|
244
|
+
let x = 0
|
|
245
|
+
let z = 0
|
|
246
|
+
for (const p of ring) {
|
|
247
|
+
x += p[0]
|
|
248
|
+
z += p[1]
|
|
249
|
+
}
|
|
250
|
+
return [x / ring.length, z / ring.length]
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** One status fragment for the drop-in message. */
|
|
254
|
+
export function describeTerrainSample(
|
|
255
|
+
summary: TerrainSampleSummary | null,
|
|
256
|
+
failure: string,
|
|
257
|
+
): string {
|
|
258
|
+
if (summary) {
|
|
259
|
+
if (summary.flat) return `ground flat within ${Math.round(summary.reliefFt * 12)}" (USGS)`
|
|
260
|
+
const holes = summary.holes > 0 ? `, ${summary.holes} unread` : ''
|
|
261
|
+
return `terrain: ${summary.reliefFt.toFixed(1)}' of fall across the lot (USGS, ${summary.sampled} pts${holes})`
|
|
262
|
+
}
|
|
263
|
+
if (failure && failure !== 'skipped')
|
|
264
|
+
return `terrain read failed (${failure}) — the ground is flat until it is read`
|
|
265
|
+
return ''
|
|
266
|
+
}
|
|
@@ -25,6 +25,8 @@ export function useMaterialCatalogModel(
|
|
|
25
25
|
selectedMaterialPreset?: string,
|
|
26
26
|
onSelectMaterialPreset?: (ref: string) => void,
|
|
27
27
|
disabled = false,
|
|
28
|
+
/** Paint mode arms the tab's first material; a finish row only writes on a tile click. */
|
|
29
|
+
selectOnCategoryChange = true,
|
|
28
30
|
) {
|
|
29
31
|
const [selectedCategory, setCategory] = useState<(typeof MATERIAL_CATEGORIES)[number]>(
|
|
30
32
|
MATERIAL_CATEGORIES[0],
|
|
@@ -57,7 +59,7 @@ export function useMaterialCatalogModel(
|
|
|
57
59
|
const setSelectedCategory = (category: typeof selectedCategory) => {
|
|
58
60
|
setCategory(category)
|
|
59
61
|
const first = itemsFor(category)[0]
|
|
60
|
-
if (first) select(first.id)
|
|
62
|
+
if (first && selectOnCategoryChange) select(first.id)
|
|
61
63
|
}
|
|
62
64
|
return {
|
|
63
65
|
selectedCategory,
|
|
@@ -10,14 +10,13 @@ import { useViewer } from '@pascal-app/viewer'
|
|
|
10
10
|
import { useEffect } from 'react'
|
|
11
11
|
import useEditor from '../store/use-editor'
|
|
12
12
|
import { buildResetSurfaceMaterialUpdates, resolvePaintTargetFromSelection } from './material-paint'
|
|
13
|
+
import { usePaintRegionMode } from './paint-region-mode'
|
|
13
14
|
|
|
14
15
|
export function useMaterialPaintPanelModel(enabled = true) {
|
|
15
16
|
const activePaintMaterial = useEditor((state) => state.activePaintMaterial)
|
|
16
17
|
const activePaintTarget = useEditor((state) => state.activePaintTarget)
|
|
17
18
|
const setActivePaintMaterial = useEditor((state) => state.armMaterialPaint)
|
|
18
19
|
const setActivePaintTarget = useEditor((state) => state.setActivePaintTarget)
|
|
19
|
-
const paintEraser = useEditor((state) => state.paintEraser)
|
|
20
|
-
const setPaintEraser = useEditor((state) => state.setPaintEraser)
|
|
21
20
|
const selectedId = useViewer((state) =>
|
|
22
21
|
state.selection.selectedIds.length === 1 ? (state.selection.selectedIds[0] ?? null) : null,
|
|
23
22
|
)
|
|
@@ -25,15 +24,20 @@ export function useMaterialPaintPanelModel(enabled = true) {
|
|
|
25
24
|
resolvePaintTargetFromSelection({ nodes: state.nodes, selectedId }),
|
|
26
25
|
)
|
|
27
26
|
const materials = useScene((state) => state.materials)
|
|
27
|
+
const paintEraser = usePaintRegionMode((state) => state.mode === 'erase')
|
|
28
|
+
const setPaintEraser = (on: boolean) => {
|
|
29
|
+
const paintMode = usePaintRegionMode.getState()
|
|
30
|
+
paintMode.setMode(on ? 'erase' : paintMode.drawMode)
|
|
31
|
+
}
|
|
28
32
|
const canResetSelection = paintTarget !== null
|
|
29
|
-
useEffect(() => {
|
|
30
|
-
if (enabled && paintTarget) setActivePaintTarget(paintTarget)
|
|
31
|
-
}, [enabled, paintTarget, setActivePaintTarget])
|
|
32
33
|
const resetSelection = () => {
|
|
33
34
|
const scene = useScene.getState()
|
|
34
35
|
const node = selectedId ? scene.nodes[selectedId as AnyNodeId] : undefined
|
|
35
36
|
if (node) scene.updateNodes(buildResetSurfaceMaterialUpdates(scene.nodes, node))
|
|
36
37
|
}
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (enabled && paintTarget) setActivePaintTarget(paintTarget)
|
|
40
|
+
}, [enabled, paintTarget, setActivePaintTarget])
|
|
37
41
|
const createCustomMaterial = () => {
|
|
38
42
|
const scene = useScene.getState()
|
|
39
43
|
const id = generateSceneMaterialId()
|
|
@@ -21,6 +21,8 @@ import {
|
|
|
21
21
|
type RoofSegmentNode,
|
|
22
22
|
type RoofSegmentSurfaceMaterialRole,
|
|
23
23
|
type RoofSurfaceMaterialRole,
|
|
24
|
+
type SceneMaterial,
|
|
25
|
+
type SceneMaterialId,
|
|
24
26
|
type ShelfNode,
|
|
25
27
|
type SlabNode,
|
|
26
28
|
type StairNode,
|
|
@@ -243,6 +245,7 @@ export function getEffectiveChimneyMaterial(
|
|
|
243
245
|
}
|
|
244
246
|
|
|
245
247
|
export function resolveActivePaintMaterialFromSelection(params: {
|
|
248
|
+
materials?: Record<SceneMaterialId, SceneMaterial>
|
|
246
249
|
nodes: Record<string, any>
|
|
247
250
|
selectedId: string | null
|
|
248
251
|
selectedMaterialTarget: {
|
|
@@ -274,6 +277,7 @@ export function resolveActivePaintMaterialFromSelection(params: {
|
|
|
274
277
|
node: selectedNode,
|
|
275
278
|
role: selectedMaterialTarget.role as string,
|
|
276
279
|
nodes,
|
|
280
|
+
materials: params.materials,
|
|
277
281
|
})
|
|
278
282
|
if (surface) {
|
|
279
283
|
const sourceTarget = (paintCap.materialTarget ?? selectedNode.type) as PaintableMaterialTarget
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
type AnyNode,
|
|
4
|
+
type AnyNodeId,
|
|
5
|
+
BuildingNode,
|
|
6
|
+
clearSceneHistory,
|
|
7
|
+
createMezzanine,
|
|
8
|
+
createZone,
|
|
9
|
+
generateId,
|
|
10
|
+
initSpaceDetectionSync,
|
|
11
|
+
LevelNode,
|
|
12
|
+
type Point,
|
|
13
|
+
useScene,
|
|
14
|
+
type ZoneNode,
|
|
15
|
+
} from '@pascal-app/core'
|
|
16
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
17
|
+
import { roomTransformSource } from '../components/editor/room-controls'
|
|
18
|
+
import { runHistoryShortcut } from '../hooks/use-keyboard'
|
|
19
|
+
import { getRoomSelectionIndex } from '../hooks/use-selected-room'
|
|
20
|
+
import useEditor from '../store/use-editor'
|
|
21
|
+
import useInteractionScope from '../store/use-interaction-scope'
|
|
22
|
+
import { cancelGestures, hasLiveGestures } from './gesture-lifecycle'
|
|
23
|
+
import {
|
|
24
|
+
MEZZANINE_NO_STAIR_MESSAGE,
|
|
25
|
+
MEZZANINE_OUTSIDE_MESSAGE,
|
|
26
|
+
MEZZANINE_OVERLAP_MESSAGE,
|
|
27
|
+
} from './mezzanine-messages'
|
|
28
|
+
import { addMezzanineStairs } from './mezzanine-stairs'
|
|
29
|
+
import {
|
|
30
|
+
mezzanineEdgeHandles,
|
|
31
|
+
planMezzanineEdge,
|
|
32
|
+
runMezzanineEdgeDrag,
|
|
33
|
+
useRoomHandleDrag,
|
|
34
|
+
} from './room-handle-drag'
|
|
35
|
+
import { applyRoomPlan } from './room-structure-commands'
|
|
36
|
+
import {
|
|
37
|
+
cancelRoomTransform,
|
|
38
|
+
commitRoomTransform,
|
|
39
|
+
previewRoomTransform,
|
|
40
|
+
rotateRoom,
|
|
41
|
+
startRoomTransform,
|
|
42
|
+
useRoomTransform,
|
|
43
|
+
} from './room-transform-session'
|
|
44
|
+
|
|
45
|
+
// A mezzanine picked up, turned, copied, pushed at an edge and given stairs:
|
|
46
|
+
// core refuses what leaves its host or overlaps another mezzanine, and every
|
|
47
|
+
// accepted action is one undo step that keeps the host link.
|
|
48
|
+
|
|
49
|
+
const LEVEL = 'level_mezzanine_actions'
|
|
50
|
+
const saved = {
|
|
51
|
+
window: globalThis.window,
|
|
52
|
+
document: globalThis.document,
|
|
53
|
+
raf: globalThis.requestAnimationFrame,
|
|
54
|
+
caf: globalThis.cancelAnimationFrame,
|
|
55
|
+
}
|
|
56
|
+
let hostId = ''
|
|
57
|
+
let mezzanineId = ''
|
|
58
|
+
let stop = () => {}
|
|
59
|
+
|
|
60
|
+
const nodes = () => useScene.getState().nodes as Record<string, AnyNode>
|
|
61
|
+
const zone = (id: string) => nodes()[id as AnyNodeId] as ZoneNode
|
|
62
|
+
const mezzanines = () =>
|
|
63
|
+
Object.values(nodes()).filter(
|
|
64
|
+
(node): node is ZoneNode => node.type === 'zone' && node.floor?.support === 'open',
|
|
65
|
+
)
|
|
66
|
+
const history = () => useScene.temporal.getState().pastStates.length
|
|
67
|
+
const bounds = (polygon: readonly Point[]) => {
|
|
68
|
+
const xs = polygon.map(([x]) => x)
|
|
69
|
+
const zs = polygon.map(([, z]) => z)
|
|
70
|
+
return [Math.min(...xs), Math.min(...zs), Math.max(...xs), Math.max(...zs)]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function addMezzanine(polygon: Point[]) {
|
|
74
|
+
const plan = createMezzanine(nodes(), { hostZoneId: hostId, polygon, mintId: generateId })
|
|
75
|
+
expect(plan.conflicts ?? []).toEqual([])
|
|
76
|
+
applyRoomPlan(plan)
|
|
77
|
+
return plan.zoneId
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function mezzanineRecord(id = mezzanineId) {
|
|
81
|
+
return getRoomSelectionIndex(LEVEL)
|
|
82
|
+
.update(nodes())
|
|
83
|
+
.find((room) => room.zoneId === id)!
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
beforeEach(() => {
|
|
87
|
+
cancelGestures('cancel')
|
|
88
|
+
globalThis.window = new EventTarget() as Window & typeof globalThis
|
|
89
|
+
globalThis.document = { body: { style: { cursor: '' } } } as unknown as Document
|
|
90
|
+
globalThis.requestAnimationFrame = ((callback: (time: number) => void) => {
|
|
91
|
+
callback(0)
|
|
92
|
+
return 0
|
|
93
|
+
}) as typeof requestAnimationFrame
|
|
94
|
+
globalThis.cancelAnimationFrame = (() => {}) as typeof cancelAnimationFrame
|
|
95
|
+
useInteractionScope.getState().end()
|
|
96
|
+
const building = BuildingNode.parse({ id: 'building_mezzanine_actions', children: [LEVEL] })
|
|
97
|
+
const level = LevelNode.parse({ id: LEVEL, parentId: building.id, height: 5 })
|
|
98
|
+
useScene.setState({
|
|
99
|
+
nodes: { [building.id]: building, [level.id]: level },
|
|
100
|
+
rootNodeIds: [building.id],
|
|
101
|
+
dirtyNodes: new Set(),
|
|
102
|
+
materials: {},
|
|
103
|
+
collections: {},
|
|
104
|
+
readOnly: false,
|
|
105
|
+
})
|
|
106
|
+
useScene.temporal.getState().resume()
|
|
107
|
+
stop = initSpaceDetectionSync(useScene, { getState: () => ({ spaces: {}, setSpaces: () => {} }) })
|
|
108
|
+
const host = createZone(nodes(), {
|
|
109
|
+
levelId: LEVEL,
|
|
110
|
+
polygon: [
|
|
111
|
+
[0, 0],
|
|
112
|
+
[8, 0],
|
|
113
|
+
[8, 6],
|
|
114
|
+
[0, 6],
|
|
115
|
+
],
|
|
116
|
+
enclose: true,
|
|
117
|
+
mintId: generateId,
|
|
118
|
+
})
|
|
119
|
+
applyRoomPlan(host)
|
|
120
|
+
hostId = host.zoneId
|
|
121
|
+
mezzanineId = addMezzanine([
|
|
122
|
+
[1, 1],
|
|
123
|
+
[4, 1],
|
|
124
|
+
[4, 3],
|
|
125
|
+
[1, 3],
|
|
126
|
+
])
|
|
127
|
+
useViewer.getState().setSelection({ buildingId: building.id, levelId: LEVEL, selectedIds: [] })
|
|
128
|
+
useEditor.setState({
|
|
129
|
+
phase: 'building',
|
|
130
|
+
mode: 'select',
|
|
131
|
+
room: { levelId: LEVEL, zoneId: mezzanineId },
|
|
132
|
+
gridSnapStep: 0.5,
|
|
133
|
+
})
|
|
134
|
+
clearSceneHistory()
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
afterEach(() => {
|
|
138
|
+
cancelRoomTransform()
|
|
139
|
+
cancelGestures('cancel')
|
|
140
|
+
stop()
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
afterAll(async () => {
|
|
144
|
+
await new Promise((resolve) => setTimeout(resolve, 350))
|
|
145
|
+
globalThis.window = saved.window
|
|
146
|
+
globalThis.document = saved.document
|
|
147
|
+
globalThis.requestAnimationFrame = saved.raf
|
|
148
|
+
globalThis.cancelAnimationFrame = saved.caf
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
describe('moving a mezzanine', () => {
|
|
152
|
+
test('inside its host it commits in one step, keeping the host link', () => {
|
|
153
|
+
expect(startRoomTransform('move', roomTransformSource(mezzanineRecord()))).toBe(true)
|
|
154
|
+
previewRoomTransform([2, 1], 0)
|
|
155
|
+
expect(useRoomTransform.getState().session).toMatchObject({ valid: true })
|
|
156
|
+
expect(commitRoomTransform()).toBe(true)
|
|
157
|
+
expect(bounds(zone(mezzanineId).polygon)).toEqual([3, 2, 6, 4])
|
|
158
|
+
expect(zone(mezzanineId).hostZoneId).toBe(hostId)
|
|
159
|
+
expect(history()).toBe(1)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
test('outside the host or onto another mezzanine it turns red with the mezzanine label', () => {
|
|
163
|
+
addMezzanine([
|
|
164
|
+
[5, 3.5],
|
|
165
|
+
[7.5, 3.5],
|
|
166
|
+
[7.5, 5.5],
|
|
167
|
+
[5, 5.5],
|
|
168
|
+
])
|
|
169
|
+
clearSceneHistory()
|
|
170
|
+
startRoomTransform('move', roomTransformSource(mezzanineRecord()))
|
|
171
|
+
previewRoomTransform([6, 0], 0)
|
|
172
|
+
expect(useRoomTransform.getState().session).toMatchObject({
|
|
173
|
+
valid: false,
|
|
174
|
+
message: MEZZANINE_OUTSIDE_MESSAGE,
|
|
175
|
+
})
|
|
176
|
+
expect(commitRoomTransform()).toBe(false)
|
|
177
|
+
previewRoomTransform([1.5, 1], 0)
|
|
178
|
+
expect(useRoomTransform.getState().session).toMatchObject({
|
|
179
|
+
valid: false,
|
|
180
|
+
message: MEZZANINE_OVERLAP_MESSAGE,
|
|
181
|
+
})
|
|
182
|
+
expect(commitRoomTransform()).toBe(false)
|
|
183
|
+
expect(history()).toBe(0)
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
describe('turning and copying a mezzanine', () => {
|
|
188
|
+
test('rotate left turns it about its centre in one step; a turn that leaves the room says why', () => {
|
|
189
|
+
expect(rotateRoom({ levelId: LEVEL, zoneId: mezzanineId }, 'left').ok).toBe(true)
|
|
190
|
+
const [minX, minZ, maxX, maxZ] = bounds(zone(mezzanineId).polygon)
|
|
191
|
+
expect(maxX! - minX!).toBeCloseTo(2)
|
|
192
|
+
expect(maxZ! - minZ!).toBeCloseTo(3)
|
|
193
|
+
expect(history()).toBe(1)
|
|
194
|
+
// A long thin mezzanine along the room cannot turn across it.
|
|
195
|
+
const long = addMezzanine([
|
|
196
|
+
[0.5, 4.2],
|
|
197
|
+
[7.5, 4.2],
|
|
198
|
+
[7.5, 5.5],
|
|
199
|
+
[0.5, 5.5],
|
|
200
|
+
])
|
|
201
|
+
clearSceneHistory()
|
|
202
|
+
const refused = rotateRoom({ levelId: LEVEL, zoneId: long }, 'right')
|
|
203
|
+
expect(refused).toMatchObject({ ok: false })
|
|
204
|
+
expect(useRoomTransform.getState().notice?.zoneId).toBe(long)
|
|
205
|
+
expect(history()).toBe(0)
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
test('duplicate places a copy hosted by the same room in one step', () => {
|
|
209
|
+
startRoomTransform('duplicate', roomTransformSource(mezzanineRecord()))
|
|
210
|
+
previewRoomTransform([0, 0], 0)
|
|
211
|
+
expect(useRoomTransform.getState().session).toMatchObject({
|
|
212
|
+
valid: false,
|
|
213
|
+
message: MEZZANINE_OVERLAP_MESSAGE,
|
|
214
|
+
})
|
|
215
|
+
previewRoomTransform([3.5, 0], 0)
|
|
216
|
+
expect(useRoomTransform.getState().session).toMatchObject({ valid: true })
|
|
217
|
+
expect(commitRoomTransform()).toBe(true)
|
|
218
|
+
expect(mezzanines()).toHaveLength(2)
|
|
219
|
+
expect(mezzanines().every((node) => node.hostZoneId === hostId)).toBe(true)
|
|
220
|
+
expect(history()).toBe(1)
|
|
221
|
+
})
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
describe('pushing a mezzanine edge', () => {
|
|
225
|
+
test('one arrow per edge, pointing out of the mezzanine', () => {
|
|
226
|
+
const handles = mezzanineEdgeHandles(nodes(), mezzanineId)
|
|
227
|
+
expect(handles).toHaveLength(4)
|
|
228
|
+
for (const handle of handles) {
|
|
229
|
+
const plan = planMezzanineEdge(nodes(), mezzanineId, handle.edgeIndex, 0.4)
|
|
230
|
+
expect(plan.conflicts ?? []).toEqual([])
|
|
231
|
+
const update = plan.changes.find((change) => change.op === 'update')
|
|
232
|
+
const polygon = (update as { data: { polygon: Point[] } }).data.polygon
|
|
233
|
+
// Pushing outward grows the plate.
|
|
234
|
+
const [a, b, c, d] = bounds(polygon)
|
|
235
|
+
expect((c! - a!) * (d! - b!)).toBeGreaterThan(3 * 2)
|
|
236
|
+
}
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
test('a drag previews the outline, refuses outside the room and commits in one step', () => {
|
|
240
|
+
const east = mezzanineEdgeHandles(nodes(), mezzanineId).find(
|
|
241
|
+
(handle) => handle.outward[0] > 0.9,
|
|
242
|
+
)!
|
|
243
|
+
let at = 0
|
|
244
|
+
runMezzanineEdgeDrag({
|
|
245
|
+
handle: east,
|
|
246
|
+
zoneId: mezzanineId,
|
|
247
|
+
levelId: LEVEL,
|
|
248
|
+
from: 0,
|
|
249
|
+
along: () => at,
|
|
250
|
+
})
|
|
251
|
+
const move = (value: number) => {
|
|
252
|
+
at = value
|
|
253
|
+
window.dispatchEvent(Object.assign(new Event('pointermove'), { clientX: 1, clientY: 1 }))
|
|
254
|
+
}
|
|
255
|
+
move(10)
|
|
256
|
+
expect(useRoomHandleDrag.getState().drag).toMatchObject({
|
|
257
|
+
kind: 'mezzanine-edge',
|
|
258
|
+
message: MEZZANINE_OUTSIDE_MESSAGE,
|
|
259
|
+
})
|
|
260
|
+
move(1)
|
|
261
|
+
const drag = useRoomHandleDrag.getState().drag as { message?: string; outline: Point[] }
|
|
262
|
+
expect(drag.message).toBeUndefined()
|
|
263
|
+
expect(bounds(drag.outline)[2]).toBeCloseTo(5)
|
|
264
|
+
// Nothing is written until release.
|
|
265
|
+
expect(bounds(zone(mezzanineId).polygon)[2]).toBeCloseTo(4)
|
|
266
|
+
window.dispatchEvent(Object.assign(new Event('pointerup'), { clientX: 1, clientY: 1 }))
|
|
267
|
+
expect(bounds(zone(mezzanineId).polygon)[2]).toBeCloseTo(5)
|
|
268
|
+
expect(history()).toBe(1)
|
|
269
|
+
expect(hasLiveGestures()).toBe(false)
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
test('a refused release writes nothing', () => {
|
|
273
|
+
const east = mezzanineEdgeHandles(nodes(), mezzanineId).find(
|
|
274
|
+
(handle) => handle.outward[0] > 0.9,
|
|
275
|
+
)!
|
|
276
|
+
let at = 0
|
|
277
|
+
runMezzanineEdgeDrag({
|
|
278
|
+
handle: east,
|
|
279
|
+
zoneId: mezzanineId,
|
|
280
|
+
levelId: LEVEL,
|
|
281
|
+
from: 0,
|
|
282
|
+
along: () => at,
|
|
283
|
+
})
|
|
284
|
+
at = 10
|
|
285
|
+
window.dispatchEvent(Object.assign(new Event('pointermove'), { clientX: 1, clientY: 1 }))
|
|
286
|
+
window.dispatchEvent(Object.assign(new Event('pointerup'), { clientX: 1, clientY: 1 }))
|
|
287
|
+
expect(bounds(zone(mezzanineId).polygon)[2]).toBeCloseTo(4)
|
|
288
|
+
expect(history()).toBe(0)
|
|
289
|
+
})
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
describe('adding stairs', () => {
|
|
293
|
+
test('places one stair up to the mezzanine and selects it, in one step', () => {
|
|
294
|
+
const result = addMezzanineStairs(mezzanineId)
|
|
295
|
+
expect(result.ok).toBe(true)
|
|
296
|
+
const stairId = (result as { stairId: string }).stairId
|
|
297
|
+
expect(nodes()[stairId as AnyNodeId]?.type).toBe('stair')
|
|
298
|
+
expect(useViewer.getState().selection.selectedIds).toEqual([stairId])
|
|
299
|
+
expect(useEditor.getState().room).toBeNull()
|
|
300
|
+
expect(history()).toBe(1)
|
|
301
|
+
// The railing opens where the flight arrives, in the same step.
|
|
302
|
+
const deck = Object.values(nodes()).find(
|
|
303
|
+
(node) => node.type === 'slab' && node.support === 'open',
|
|
304
|
+
) as { railing?: unknown[] }
|
|
305
|
+
expect(deck.railing).toHaveLength(5)
|
|
306
|
+
// Core emits the same numeric values that shared history records through JSON.
|
|
307
|
+
const hasNegativeZero = (value: unknown): boolean =>
|
|
308
|
+
Object.is(value, -0) ||
|
|
309
|
+
(Array.isArray(value)
|
|
310
|
+
? value.some(hasNegativeZero)
|
|
311
|
+
: !!value && typeof value === 'object' && Object.values(value).some(hasNegativeZero))
|
|
312
|
+
const stair = nodes()[stairId as AnyNodeId]!
|
|
313
|
+
expect(hasNegativeZero(stair)).toBe(false)
|
|
314
|
+
expect(
|
|
315
|
+
Object.values(nodes()).some((node) => node.parentId === stairId && hasNegativeZero(node)),
|
|
316
|
+
).toBe(false)
|
|
317
|
+
// One ⌘Z takes it away again.
|
|
318
|
+
expect(runHistoryShortcut('undo')).toBe(true)
|
|
319
|
+
expect(nodes()[stairId as AnyNodeId]).toBeUndefined()
|
|
320
|
+
expect(Object.values(nodes()).some((node) => node.type === 'stair')).toBe(false)
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
test('says "No room for stairs" when no edge has free floor for a flight', () => {
|
|
324
|
+
// The mezzanine fills the room: no host floor is left for a flight.
|
|
325
|
+
useScene.getState().updateNode(
|
|
326
|
+
mezzanineId as AnyNodeId,
|
|
327
|
+
{
|
|
328
|
+
polygon: [
|
|
329
|
+
[0.2, 0.2],
|
|
330
|
+
[7.8, 0.2],
|
|
331
|
+
[7.8, 5.8],
|
|
332
|
+
[0.2, 5.8],
|
|
333
|
+
],
|
|
334
|
+
} as never,
|
|
335
|
+
)
|
|
336
|
+
clearSceneHistory()
|
|
337
|
+
const result = addMezzanineStairs(mezzanineId)
|
|
338
|
+
expect(result).toEqual({ ok: false, message: MEZZANINE_NO_STAIR_MESSAGE })
|
|
339
|
+
expect(Object.values(nodes()).some((node) => node.type === 'stair')).toBe(false)
|
|
340
|
+
expect(history()).toBe(0)
|
|
341
|
+
})
|
|
342
|
+
})
|