@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,394 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lot drop-in — the PURE half. Given the parcel service's answer and the
|
|
3
|
+
* mapped roads, compute the site node patch and a summary of what was
|
|
4
|
+
* decided (front edge, setbacks, notes). No store, no network, so every
|
|
5
|
+
* rule here is unit-tested; `drop-in.ts` does the fetching and writing.
|
|
6
|
+
*
|
|
7
|
+
* Rules:
|
|
8
|
+
* - The lot ring, address, parcel provenance and zoning come straight from
|
|
9
|
+
* the parcel `resolve` answer; a new ring invalidates any front edge picked on
|
|
10
|
+
* the old one.
|
|
11
|
+
* - The FRONT edge is the lot edge the real street fronts
|
|
12
|
+
* (`detectFrontEdgeFromRoads`: parallel, outside, nearest, addressed
|
|
13
|
+
* street wins on a corner). No road data → left undefined, which the site
|
|
14
|
+
* plan resolves to the most north-facing edge, and the notes say so.
|
|
15
|
+
* - With the parcel fabric's frontage (Pascal Map), the front is the fronting
|
|
16
|
+
* edge on the addressed street (`frontageFront`), else the longest fronting
|
|
17
|
+
* edge; the note says which rule decided.
|
|
18
|
+
* - Setbacks: when the site has none, the planning defaults
|
|
19
|
+
* (`DEFAULT_SETBACKS_FT` — front 20 ft, side 5 ft, rear 15 ft) are
|
|
20
|
+
* written with a `setbacksSource` that says they are defaults. Existing
|
|
21
|
+
* setbacks are never overwritten.
|
|
22
|
+
* - `northRotation` is 0: the parcel frame is x east / z south, so plan up
|
|
23
|
+
* is true north.
|
|
24
|
+
*/
|
|
25
|
+
import type { SiteNode, SiteSetbacks } from '@pascal-app/core'
|
|
26
|
+
import {
|
|
27
|
+
detectFrontEdgeFromRoads,
|
|
28
|
+
type FrontEdgeMatch,
|
|
29
|
+
type RoadCenterline,
|
|
30
|
+
} from '../floorplan/site-plan/front-edge'
|
|
31
|
+
import { edgeLength, METRES_PER_FOOT, type Pt } from '../floorplan/site-plan/geometry'
|
|
32
|
+
import { sameStreet } from '../floorplan/site-plan/street-name'
|
|
33
|
+
import { cleanLotRing, describeRingCleanup } from './clean-ring'
|
|
34
|
+
import { detectFrontEdgeFromFrontage, type FrontageMatch } from './dossier'
|
|
35
|
+
|
|
36
|
+
export const DEFAULT_SETBACKS_FT = { front: 20, side: 5, rear: 15 } as const
|
|
37
|
+
export const DEFAULT_SETBACKS_M: SiteSetbacks = {
|
|
38
|
+
front: DEFAULT_SETBACKS_FT.front * METRES_PER_FOOT,
|
|
39
|
+
side: DEFAULT_SETBACKS_FT.side * METRES_PER_FOOT,
|
|
40
|
+
rear: DEFAULT_SETBACKS_FT.rear * METRES_PER_FOOT,
|
|
41
|
+
}
|
|
42
|
+
export const DEFAULT_SETBACKS_SOURCE =
|
|
43
|
+
'Planning defaults (front 20 ft, side 5 ft, rear 15 ft; typical single-family yards) — confirm with the zoning district.'
|
|
44
|
+
|
|
45
|
+
/** OSM highway classes a house can front: streets, not alleys, driveways or sidewalks. */
|
|
46
|
+
export const STREET_CLASSES: ReadonlySet<string> = new Set([
|
|
47
|
+
'motorway',
|
|
48
|
+
'trunk',
|
|
49
|
+
'primary',
|
|
50
|
+
'secondary',
|
|
51
|
+
'tertiary',
|
|
52
|
+
'residential',
|
|
53
|
+
'living_street',
|
|
54
|
+
'unclassified',
|
|
55
|
+
])
|
|
56
|
+
|
|
57
|
+
/** The parcel provider's `resolve` answer (the fields the drop-in reads). */
|
|
58
|
+
export interface ParcelResolveData {
|
|
59
|
+
ok: boolean
|
|
60
|
+
error?: string
|
|
61
|
+
apn?: string
|
|
62
|
+
county?: string
|
|
63
|
+
state?: string
|
|
64
|
+
zip?: string
|
|
65
|
+
zoning?: string
|
|
66
|
+
lotAreaSqFt?: number
|
|
67
|
+
originLngLat?: [number, number]
|
|
68
|
+
geocodedBy?: string
|
|
69
|
+
matchPrecision?: string
|
|
70
|
+
notes?: string[]
|
|
71
|
+
polygonM?: [number, number][]
|
|
72
|
+
address?: { street?: string; city?: string; state?: string; zip?: string } | null
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A road from the parcel provider's `roads` (centerline in the lot's frame, metres). */
|
|
76
|
+
export interface LotRoad extends RoadCenterline {
|
|
77
|
+
id?: string
|
|
78
|
+
klass?: string
|
|
79
|
+
widthM?: number
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** What the user typed or picked. Coordinates skip geocoding when present. */
|
|
83
|
+
export interface DropInInput {
|
|
84
|
+
address?: string
|
|
85
|
+
latitude?: number
|
|
86
|
+
longitude?: number
|
|
87
|
+
state?: string
|
|
88
|
+
street?: string
|
|
89
|
+
city?: string
|
|
90
|
+
zip?: string
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* What the Pascal Map dossier adds to a lot patch (lot drop-in, dossier.ts):
|
|
95
|
+
* the frontage segments the front edge is read from, the zoning setbacks
|
|
96
|
+
* with their citation, and the facts the site keeps for the sheets.
|
|
97
|
+
*/
|
|
98
|
+
export interface DossierExtras {
|
|
99
|
+
/** Frontage segments in plan metres — the lot edges that touch no neighbour. */
|
|
100
|
+
frontageSegmentsM?: readonly [readonly [number, number], readonly [number, number]][]
|
|
101
|
+
/** Zoning setbacks, metres — null when the code's rule is conditional. */
|
|
102
|
+
setbacks?: { front: number; side: number; rear: number } | null
|
|
103
|
+
setbacksSource?: string
|
|
104
|
+
/** The code's verbatim condition when a number cannot say it. */
|
|
105
|
+
dimensionalNote?: string | null
|
|
106
|
+
zone?: string
|
|
107
|
+
/** The geometry-free record the site node keeps. */
|
|
108
|
+
facts: SiteNode['dossier']
|
|
109
|
+
/** One status line, e.g. "Pascal Map: 9 sections answered (parcel not here)". */
|
|
110
|
+
line: string
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface LotSummary {
|
|
114
|
+
apn: string
|
|
115
|
+
county: string
|
|
116
|
+
state: string
|
|
117
|
+
lotAreaSqFt: number
|
|
118
|
+
/** The street-facing edge index, or null when the north-facing fallback stands. */
|
|
119
|
+
frontEdge: number | null
|
|
120
|
+
frontStreet: string | null
|
|
121
|
+
frontEdgeSource: string
|
|
122
|
+
/** How many lot edges front a street / water per the parcel fabric (corner lots ≥ 2); absent without frontage. */
|
|
123
|
+
frontingEdges?: number
|
|
124
|
+
/** The dossier's own status line, when one was read. */
|
|
125
|
+
dossierLine?: string
|
|
126
|
+
setbacksDefaulted: boolean
|
|
127
|
+
roadsFound: number
|
|
128
|
+
notes: string[]
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** A trailing "ST zip" (US) or "STATE postcode" (AU), optionally after the city in the same part. */
|
|
132
|
+
const STATE_ZIP_TAIL = /^(?:(.*?)\s+)?([A-Z]{2}|NSW|VIC|QLD|TAS|ACT)(?:\s+(\d{4,5}(?:-\d{4})?))?$/i
|
|
133
|
+
const COUNTRY_PART = /^(usa?|u\.s\.a?\.?|united states(?: of america)?|australia)$/i
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The typed "street, city, ST zip" split into its parts — what the address
|
|
137
|
+
* says when the resolver answered no situs line (the parcel route never
|
|
138
|
+
* does). Without a comma it is all street.
|
|
139
|
+
*/
|
|
140
|
+
export function splitTypedAddress(typed: string | undefined): {
|
|
141
|
+
street?: string
|
|
142
|
+
city?: string
|
|
143
|
+
state?: string
|
|
144
|
+
zip?: string
|
|
145
|
+
} {
|
|
146
|
+
const parts = String(typed ?? '')
|
|
147
|
+
.split(',')
|
|
148
|
+
.map((p) => p.trim())
|
|
149
|
+
.filter(Boolean)
|
|
150
|
+
if (parts.length > 1 && COUNTRY_PART.test(parts[parts.length - 1] as string)) parts.pop()
|
|
151
|
+
if (parts.length === 0) return {}
|
|
152
|
+
if (parts.length === 1) return { street: parts[0] }
|
|
153
|
+
const tail = STATE_ZIP_TAIL.exec(parts[parts.length - 1] as string)
|
|
154
|
+
if (!tail) return { street: parts.slice(0, -1).join(', '), city: parts[parts.length - 1] }
|
|
155
|
+
const [, cityInTail, state, zip] = tail
|
|
156
|
+
const streetParts = cityInTail ? parts.slice(0, -1) : parts.slice(0, -2)
|
|
157
|
+
const city = cityInTail ?? (parts.length > 2 ? parts[parts.length - 2] : undefined)
|
|
158
|
+
return {
|
|
159
|
+
street: (streetParts.length > 0 ? streetParts : parts.slice(0, 1)).join(', '),
|
|
160
|
+
...(city ? { city } : {}),
|
|
161
|
+
state: (state as string).toUpperCase(),
|
|
162
|
+
...(zip ? { zip } : {}),
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const streetOf = (input: DropInInput, data: ParcelResolveData): string | undefined =>
|
|
167
|
+
input.street ?? data.address?.street ?? splitTypedAddress(input.address).street
|
|
168
|
+
|
|
169
|
+
/** The front-edge decision, as text for the parcel notes and the panel. */
|
|
170
|
+
export function describeFrontEdge(match: FrontEdgeMatch | null, roadsFound: number): string {
|
|
171
|
+
if (match) {
|
|
172
|
+
const name = match.name ? `"${match.name}"` : 'an unnamed street'
|
|
173
|
+
return `Front edge ${match.index + 1}: fronts ${name} (OpenStreetMap${match.named ? ', the addressed street' : ', nearest street'}).`
|
|
174
|
+
}
|
|
175
|
+
return roadsFound > 0
|
|
176
|
+
? 'Front edge: no mapped street fronts a lot edge — the most north-facing edge stands; pick the street side in the Site panel.'
|
|
177
|
+
: 'Front edge: no road data — the most north-facing edge stands; pick the street side in the Site panel.'
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Under this share of the longest frontage the addressed street's fronting
|
|
182
|
+
* edge is a sliver of the lot (a corner cut, a flag's pole), not its face.
|
|
183
|
+
*/
|
|
184
|
+
export const ADDRESSED_FRONTAGE_MIN_SHARE = 0.5
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The front among the parcel fabric's fronting edges: the one on the
|
|
188
|
+
* addressed street (a corner or through lot is addressed on its front
|
|
189
|
+
* street) — `streetNames` are the mapped street along each edge — unless it
|
|
190
|
+
* is under half the longest frontage; else the longest. `rule` says which
|
|
191
|
+
* decided, for the note.
|
|
192
|
+
*/
|
|
193
|
+
export function frontageFront(
|
|
194
|
+
ring: readonly Pt[],
|
|
195
|
+
frontage: FrontageMatch,
|
|
196
|
+
streetNames: Readonly<Record<string, string>>,
|
|
197
|
+
addressStreet: string | null | undefined,
|
|
198
|
+
): { index: number; name: string; named: boolean; rule: string } {
|
|
199
|
+
const nameOf = (i: number) => streetNames[String(i)] ?? ''
|
|
200
|
+
const addressed = frontage.edges
|
|
201
|
+
.filter((i) => sameStreet(addressStreet, nameOf(i)))
|
|
202
|
+
.sort((a, b) => edgeLength(ring, b) - edgeLength(ring, a))[0]
|
|
203
|
+
if (
|
|
204
|
+
addressed !== undefined &&
|
|
205
|
+
edgeLength(ring, addressed) >= frontage.lengthM * ADDRESSED_FRONTAGE_MIN_SHARE
|
|
206
|
+
)
|
|
207
|
+
return {
|
|
208
|
+
index: addressed,
|
|
209
|
+
name: nameOf(addressed),
|
|
210
|
+
named: true,
|
|
211
|
+
rule: "the addressed street's frontage",
|
|
212
|
+
}
|
|
213
|
+
const rule =
|
|
214
|
+
addressed !== undefined
|
|
215
|
+
? `the longest frontage — the addressed street's edge ${addressed + 1} is ${edgeLength(ring, addressed).toFixed(1)} m, under half the longest ${frontage.lengthM.toFixed(1)} m`
|
|
216
|
+
: frontage.edges.length === 1
|
|
217
|
+
? 'the only frontage'
|
|
218
|
+
: frontage.edges.some((i) => nameOf(i))
|
|
219
|
+
? 'the longest frontage — no fronting street is the addressed street'
|
|
220
|
+
: 'the longest frontage — no mapped street names to match the address'
|
|
221
|
+
return { index: frontage.index, name: nameOf(frontage.index), named: false, rule }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The site patch for a resolved parcel. `null` when the answer has no usable
|
|
226
|
+
* ring. `now` stamps `parcel.resolvedAt` (injectable for tests).
|
|
227
|
+
*/
|
|
228
|
+
export function sitePatchFromParcel(
|
|
229
|
+
site: (Pick<SiteNode, 'setbacks' | 'zone'> & { metadata?: unknown }) | null | undefined,
|
|
230
|
+
input: DropInInput,
|
|
231
|
+
data: ParcelResolveData,
|
|
232
|
+
roads: readonly LotRoad[] | null,
|
|
233
|
+
now: string = new Date().toISOString(),
|
|
234
|
+
dossier?: DossierExtras | null,
|
|
235
|
+
): { patch: Partial<SiteNode>; summary: LotSummary } | null {
|
|
236
|
+
const raw = data.polygonM
|
|
237
|
+
if (!data.ok || !raw || raw.length < 3) return null
|
|
238
|
+
// The registry ring, made fit for planning (curb returns squared, surplus
|
|
239
|
+
// vertices gone) — see clean-ring.ts for why the raw ring cannot be used.
|
|
240
|
+
const cleaned = cleanLotRing(raw as readonly Pt[])
|
|
241
|
+
if (cleaned.points.length < 3) return null
|
|
242
|
+
const polygon = cleaned.points.map((p) => [p[0], p[1]] as [number, number])
|
|
243
|
+
const cleanupNote = describeRingCleanup(raw.length, cleaned)
|
|
244
|
+
|
|
245
|
+
const streets = (roads ?? []).filter((r) => !r.klass || STREET_CLASSES.has(r.klass))
|
|
246
|
+
const roadMatch = detectFrontEdgeFromRoads(
|
|
247
|
+
polygon as readonly Pt[],
|
|
248
|
+
streets,
|
|
249
|
+
streetOf(input, data),
|
|
250
|
+
)
|
|
251
|
+
// The parcel FABRIC beats the road match: the edges shared with no
|
|
252
|
+
// neighbour are the street (or water) edges, and the one on the addressed
|
|
253
|
+
// street (else the longest) is the front; the road match names the
|
|
254
|
+
// streets and stands in without a frontage; north-facing is the last resort.
|
|
255
|
+
const frontage = dossier?.frontageSegmentsM?.length
|
|
256
|
+
? detectFrontEdgeFromFrontage(polygon as readonly Pt[], dossier.frontageSegmentsM)
|
|
257
|
+
: null
|
|
258
|
+
const fabricFront = frontage
|
|
259
|
+
? frontageFront(
|
|
260
|
+
polygon as readonly Pt[],
|
|
261
|
+
frontage,
|
|
262
|
+
roadMatch?.streetNames ?? {},
|
|
263
|
+
streetOf(input, data),
|
|
264
|
+
)
|
|
265
|
+
: null
|
|
266
|
+
const match = fabricFront
|
|
267
|
+
? { index: fabricFront.index, distance: 0, name: fabricFront.name, named: fabricFront.named }
|
|
268
|
+
: roadMatch
|
|
269
|
+
// (site-annotations reads the street back from this note up to its ')')
|
|
270
|
+
const frontNote =
|
|
271
|
+
frontage && fabricFront
|
|
272
|
+
? `Front edge: edge ${fabricFront.index + 1} of the parcel fabric's frontage (${frontage.frontingEdges} fronting edge${frontage.frontingEdges === 1 ? '' : 's'}${fabricFront.name ? `, ${fabricFront.name}` : ''}) — ${fabricFront.rule} (Pascal Map).`
|
|
273
|
+
: describeFrontEdge(roadMatch, roads?.length ?? 0)
|
|
274
|
+
|
|
275
|
+
// Setbacks: the site's own stay; else the zoning code's numbers with
|
|
276
|
+
// their citation; else the default, with the code's condition printed
|
|
277
|
+
// beside it when the dossier carried one.
|
|
278
|
+
const zoned = !site?.setbacks && dossier?.setbacks ? dossier.setbacks : null
|
|
279
|
+
const setbacksDefaulted = !site?.setbacks && !zoned
|
|
280
|
+
const notes = [...(data.notes ?? []), ...(cleanupNote ? [cleanupNote] : []), frontNote]
|
|
281
|
+
if (zoned) notes.push(`Setbacks: ${dossier?.setbacksSource ?? 'zoning code via Pascal Map'}`)
|
|
282
|
+
if (setbacksDefaulted) notes.push(`Setbacks: ${DEFAULT_SETBACKS_SOURCE}`)
|
|
283
|
+
if (dossier?.dimensionalNote)
|
|
284
|
+
notes.push(`Zoning condition (verbatim, verify): ${dossier.dimensionalNote}`)
|
|
285
|
+
if (dossier?.line) notes.push(dossier.line)
|
|
286
|
+
|
|
287
|
+
// the street edges: the parcel fabric's frontage when it answered, else
|
|
288
|
+
// every edge a mapped road runs along
|
|
289
|
+
const streetEdges: number[] =
|
|
290
|
+
frontage && frontage.edges.length > 0 ? frontage.edges : (roadMatch?.streetEdges ?? [])
|
|
291
|
+
// the street each of those edges runs along, from the mapped roads (the
|
|
292
|
+
// site plan labels every street edge with its own street, not the address's)
|
|
293
|
+
const streetNames: Record<string, string> = {}
|
|
294
|
+
for (const i of streetEdges) {
|
|
295
|
+
const name = roadMatch?.streetNames?.[String(i)]
|
|
296
|
+
if (name) streetNames[String(i)] = name
|
|
297
|
+
}
|
|
298
|
+
const typed = splitTypedAddress(input.address)
|
|
299
|
+
const state = data.state || input.state || data.address?.state || typed.state
|
|
300
|
+
const patch: Partial<SiteNode> = {
|
|
301
|
+
address: {
|
|
302
|
+
street: streetOf(input, data),
|
|
303
|
+
city: input.city ?? data.address?.city ?? typed.city,
|
|
304
|
+
state,
|
|
305
|
+
zip: data.zip || input.zip || data.address?.zip || typed.zip,
|
|
306
|
+
},
|
|
307
|
+
parcel: {
|
|
308
|
+
apn: data.apn,
|
|
309
|
+
county: data.county,
|
|
310
|
+
layer: data.geocodedBy,
|
|
311
|
+
lotAreaSqFt: data.lotAreaSqFt,
|
|
312
|
+
notes,
|
|
313
|
+
originLngLat: data.originLngLat,
|
|
314
|
+
resolvedAt: now,
|
|
315
|
+
source: 'gis-parcel',
|
|
316
|
+
state,
|
|
317
|
+
},
|
|
318
|
+
polygon: { points: polygon, type: 'polygon' },
|
|
319
|
+
// A new lot invalidates a front-edge index picked on the old ring; the
|
|
320
|
+
// street decides the new one.
|
|
321
|
+
frontEdge: match ? match.index : undefined,
|
|
322
|
+
// every street edge (a corner lot has two or more) and the corner
|
|
323
|
+
// clear-vision triangle's leg (measured from the right of way) — the
|
|
324
|
+
// common residential 25 ft, verify locally
|
|
325
|
+
...(streetEdges.length > 0 ? { streetEdges } : {}),
|
|
326
|
+
...(streetEdges.length >= 2 ? { sightTriangleFt: 25 } : {}),
|
|
327
|
+
// The parcel frame is x east / z south: plan up is true north.
|
|
328
|
+
northRotation: 0,
|
|
329
|
+
...((dossier?.zone ?? data.zoning) && !site?.zone
|
|
330
|
+
? { zone: dossier?.zone ?? data.zoning }
|
|
331
|
+
: {}),
|
|
332
|
+
...(zoned
|
|
333
|
+
? {
|
|
334
|
+
setbacks: { ...zoned },
|
|
335
|
+
setbacksSource: dossier?.setbacksSource ?? 'zoning code via Pascal Map',
|
|
336
|
+
}
|
|
337
|
+
: setbacksDefaulted
|
|
338
|
+
? { setbacks: { ...DEFAULT_SETBACKS_M }, setbacksSource: DEFAULT_SETBACKS_SOURCE }
|
|
339
|
+
: {}),
|
|
340
|
+
// always written: undefined clears the previous lot's dossier when this lookup read none
|
|
341
|
+
dossier: dossier?.facts,
|
|
342
|
+
// the street each street edge runs along (`metadata.streetNames`, by
|
|
343
|
+
// edge index) — the site plan names every street edge with its own
|
|
344
|
+
// street; the previous lot's names never carry over
|
|
345
|
+
metadata: (() => {
|
|
346
|
+
const { streetNames: _previous, ...rest } = (
|
|
347
|
+
site?.metadata && typeof site.metadata === 'object' && !Array.isArray(site.metadata)
|
|
348
|
+
? site.metadata
|
|
349
|
+
: {}
|
|
350
|
+
) as Record<string, unknown>
|
|
351
|
+
return Object.keys(streetNames).length > 0 ? { ...rest, streetNames } : rest
|
|
352
|
+
})(),
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return {
|
|
356
|
+
patch,
|
|
357
|
+
summary: {
|
|
358
|
+
apn: data.apn ?? '',
|
|
359
|
+
county: data.county ?? '',
|
|
360
|
+
state: state ?? '',
|
|
361
|
+
lotAreaSqFt: data.lotAreaSqFt ?? 0,
|
|
362
|
+
frontEdge: match ? match.index : null,
|
|
363
|
+
frontStreet: match ? match.name || null : null,
|
|
364
|
+
frontEdgeSource: frontage
|
|
365
|
+
? 'frontage'
|
|
366
|
+
: match
|
|
367
|
+
? `osm:${match.name || 'unnamed'}`
|
|
368
|
+
: 'north-facing',
|
|
369
|
+
...(frontage ? { frontingEdges: frontage.frontingEdges } : {}),
|
|
370
|
+
...(dossier?.line ? { dossierLine: dossier.line } : {}),
|
|
371
|
+
setbacksDefaulted,
|
|
372
|
+
roadsFound: roads?.length ?? 0,
|
|
373
|
+
notes,
|
|
374
|
+
},
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** One line for a status row: "Lot set — APN 123 · 5,300 sq ft · Sacramento · fronts Castro Way · default setbacks". */
|
|
379
|
+
export function describeLotSummary(summary: LotSummary, extra: string[] = []): string {
|
|
380
|
+
const parts = [
|
|
381
|
+
summary.apn ? `APN ${summary.apn}` : '',
|
|
382
|
+
summary.lotAreaSqFt ? `${Math.round(summary.lotAreaSqFt).toLocaleString('en-US')} sq ft` : '',
|
|
383
|
+
summary.county,
|
|
384
|
+
summary.frontEdge !== null
|
|
385
|
+
? summary.frontEdgeSource === 'frontage'
|
|
386
|
+
? `fronts ${summary.frontStreet || 'the street'} (edge ${summary.frontEdge + 1}, parcel frontage)`
|
|
387
|
+
: `fronts ${summary.frontStreet ?? 'an unnamed street'} (edge ${summary.frontEdge + 1})`
|
|
388
|
+
: 'front edge: most north-facing (no road match)',
|
|
389
|
+
summary.dossierLine ?? '',
|
|
390
|
+
summary.setbacksDefaulted ? 'setbacks defaulted 20 / 5 / 15 ft' : '',
|
|
391
|
+
...extra,
|
|
392
|
+
].filter(Boolean)
|
|
393
|
+
return `Lot set — ${parts.join(' · ')}`
|
|
394
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The parcel service the lot tools read — injected by the host. The editor
|
|
3
|
+
* ships no parcel backend: a host that has one calls `setParcelProvider` at
|
|
4
|
+
* bootstrap, and until it does the address lookup is hidden and `dropInLot`
|
|
5
|
+
* answers that no service is available.
|
|
6
|
+
*/
|
|
7
|
+
import { useSyncExternalStore } from 'react'
|
|
8
|
+
|
|
9
|
+
export type ParcelEndpoint = 'autocomplete' | 'resolve' | 'roads' | 'elevation' | 'dossier'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Answers one parcel call with the endpoint's JSON. `body` is the request:
|
|
13
|
+
* `{ q }` for `autocomplete`, the posted JSON for the others.
|
|
14
|
+
*/
|
|
15
|
+
export type ParcelProvider = (
|
|
16
|
+
endpoint: ParcelEndpoint,
|
|
17
|
+
body: Record<string, unknown>,
|
|
18
|
+
) => Promise<unknown>
|
|
19
|
+
|
|
20
|
+
let provider: ParcelProvider | null = null
|
|
21
|
+
const listeners = new Set<() => void>()
|
|
22
|
+
|
|
23
|
+
export function setParcelProvider(next: ParcelProvider | null): void {
|
|
24
|
+
provider = next
|
|
25
|
+
for (const listener of listeners) listener()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function getParcelProvider(): ParcelProvider | null {
|
|
29
|
+
return provider
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function subscribe(listener: () => void): () => void {
|
|
33
|
+
listeners.add(listener)
|
|
34
|
+
return () => {
|
|
35
|
+
listeners.delete(listener)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function useParcelProvider(): ParcelProvider | null {
|
|
40
|
+
return useSyncExternalStore(subscribe, getParcelProvider, getParcelProvider)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const NO_PARCEL_SERVICE = 'No parcel service is available.'
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { decodeTerrainField, heightAt } from '@pascal-app/core'
|
|
3
|
+
import type { ParcelProvider } from './parcel-provider'
|
|
4
|
+
import {
|
|
5
|
+
coarseHeightAt,
|
|
6
|
+
DEFAULT_GRID_N,
|
|
7
|
+
describeTerrainSample,
|
|
8
|
+
fieldFromSamples,
|
|
9
|
+
gridOver,
|
|
10
|
+
localMetresToLngLat,
|
|
11
|
+
MIN_RELIEF_M,
|
|
12
|
+
type Pt,
|
|
13
|
+
sampleLotTerrain,
|
|
14
|
+
} from './terrain'
|
|
15
|
+
|
|
16
|
+
// a 50 × 107 ft lot, x across, z into the lot
|
|
17
|
+
const LOT: Pt[] = [
|
|
18
|
+
[0, 0],
|
|
19
|
+
[15.24, 0],
|
|
20
|
+
[15.24, 32.6],
|
|
21
|
+
[0, 32.6],
|
|
22
|
+
]
|
|
23
|
+
const ORIGIN: [number, number] = [-121.4944, 38.5816]
|
|
24
|
+
const FT = 0.3048
|
|
25
|
+
|
|
26
|
+
/** A fake elevation route: the ground is a plane, `slopeFtPerM` feet per metre of x, `base` ft, optional holes. */
|
|
27
|
+
function fakeProvider(slopeFtPerM: number, base = 30, holeEvery = 0, fail = false): ParcelProvider {
|
|
28
|
+
return async (_endpoint, request) => {
|
|
29
|
+
const body = request as { points: { lat: number; lng: number }[] }
|
|
30
|
+
if (fail) return { ok: false, error: 'USGS down' }
|
|
31
|
+
// invert the projection the way the sampler projected: lng → x metres
|
|
32
|
+
const results = body.points.map((p, i) => {
|
|
33
|
+
const xM = (p.lng - ORIGIN[0]) * 364000 * Math.cos((ORIGIN[1] * Math.PI) / 180) * FT
|
|
34
|
+
const elevation = holeEvery > 0 && i % holeEvery === 3 ? null : base + slopeFtPerM * xM
|
|
35
|
+
return { lat: p.lat, lng: p.lng, elevation }
|
|
36
|
+
})
|
|
37
|
+
return { ok: true, results }
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('grid and projection', () => {
|
|
42
|
+
test('the grid pads the lot bbox and runs row-major; the projection inverts the plan frame', () => {
|
|
43
|
+
const g = gridOver(LOT, 5)
|
|
44
|
+
expect(g.n).toBe(5)
|
|
45
|
+
expect(g.points).toHaveLength(25)
|
|
46
|
+
expect(g.x0).toBeCloseTo(-15.24 * 0.08, 6)
|
|
47
|
+
expect(g.x1).toBeCloseTo(15.24 * 1.08, 6)
|
|
48
|
+
expect(g.points[0]).toEqual([g.x0, g.z0])
|
|
49
|
+
expect(g.points[4]![0]).toBeCloseTo(g.x1, 9)
|
|
50
|
+
expect(g.points[5]![1]).toBeCloseTo(g.z0 + (g.z1 - g.z0) / 4, 9)
|
|
51
|
+
const ll = localMetresToLngLat([100 * FT, 200 * FT], ORIGIN)
|
|
52
|
+
// 100 ft east, 200 ft south of the origin
|
|
53
|
+
expect(ll.lat).toBeCloseTo(ORIGIN[1] - 200 / 364000, 9)
|
|
54
|
+
expect(ll.lng).toBeCloseTo(
|
|
55
|
+
ORIGIN[0] + 100 / (364000 * Math.cos((ORIGIN[1] * Math.PI) / 180)),
|
|
56
|
+
9,
|
|
57
|
+
)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('bilinear reads interpolate the coarse grid and fill holes', () => {
|
|
61
|
+
const g = gridOver(LOT, 3)
|
|
62
|
+
const elev = g.points.map((p) => p[0]) // elevation = x
|
|
63
|
+
expect(coarseHeightAt(g, elev, 7, 10, 0)).toBeCloseTo(7, 6)
|
|
64
|
+
const holed = elev.map((v, i) => (i === 4 ? null : v))
|
|
65
|
+
// the centre sample is a hole → the fill (the mean) stands in for it
|
|
66
|
+
const mean = elev.reduce((s, v) => s + v, 0) / elev.length
|
|
67
|
+
expect(coarseHeightAt(g, holed, (g.x0 + g.x1) / 2, (g.z0 + g.z1) / 2, mean)).toBeCloseTo(
|
|
68
|
+
mean,
|
|
69
|
+
6,
|
|
70
|
+
)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('the heightfield follows the samples relative to the datum', () => {
|
|
74
|
+
const g = gridOver(LOT, 5)
|
|
75
|
+
const elevM = g.points.map((p) => 0.1 * p[0]) // 10 % grade in x, metres
|
|
76
|
+
const field = fieldFromSamples(g, elevM, 0.1 * 7.62, 0)
|
|
77
|
+
expect(heightAt(field, 7.62, 10)).toBeCloseTo(0, 2)
|
|
78
|
+
expect(heightAt(field, 0, 10)).toBeCloseTo(-0.762, 2)
|
|
79
|
+
expect(heightAt(field, 15, 5)).toBeCloseTo(0.738, 2)
|
|
80
|
+
expect(field.cols).toBeLessThanOrEqual(129)
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
describe('sampleLotTerrain', () => {
|
|
85
|
+
test('a sloping lot writes a heightfield with the datum at the lot centre and says how much fall', async () => {
|
|
86
|
+
const r = await sampleLotTerrain(LOT, ORIGIN, { provider: fakeProvider(0.3), now: () => 't' })
|
|
87
|
+
expect(r.ok).toBe(true)
|
|
88
|
+
expect(r.terrain).toBeDefined()
|
|
89
|
+
expect(r.summary?.flat).toBe(false)
|
|
90
|
+
expect(r.summary?.grid).toBe(DEFAULT_GRID_N)
|
|
91
|
+
expect(r.summary?.sampled).toBe(81)
|
|
92
|
+
// the datum is the ground at the lot centre (x = 7.62 m): 30 + 0.3 × 7.62 ft
|
|
93
|
+
expect(r.summary?.datumFt).toBeCloseTo(30 + 0.3 * 7.62, 4)
|
|
94
|
+
const field = decodeTerrainField(r.terrain)
|
|
95
|
+
expect(field).not.toBeNull()
|
|
96
|
+
// 0.3 ft per metre of x: 7.62 m west of the centre the ground is 2.29 ft lower
|
|
97
|
+
expect(heightAt(field!, 0, 16.3)).toBeCloseTo(-0.3 * 7.62 * FT, 2)
|
|
98
|
+
expect(heightAt(field!, 7.62, 16.3)).toBeCloseTo(0, 2)
|
|
99
|
+
expect(heightAt(field!, 15.24, 16.3)).toBeCloseTo(0.3 * 7.62 * FT, 2)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('a flat lot writes nothing; holes are counted; failures say why', async () => {
|
|
103
|
+
const flat = await sampleLotTerrain(LOT, ORIGIN, { provider: fakeProvider(0.001) })
|
|
104
|
+
expect(flat.ok).toBe(true)
|
|
105
|
+
expect(flat.terrain).toBeUndefined()
|
|
106
|
+
expect(flat.summary?.flat).toBe(true)
|
|
107
|
+
expect(flat.summary!.reliefFt * FT).toBeLessThan(MIN_RELIEF_M)
|
|
108
|
+
const holed = await sampleLotTerrain(LOT, ORIGIN, { provider: fakeProvider(0.3, 30, 7) })
|
|
109
|
+
expect(holed.ok).toBe(true)
|
|
110
|
+
expect(holed.summary!.holes).toBeGreaterThan(0)
|
|
111
|
+
expect(holed.summary!.sampled + holed.summary!.holes).toBe(81)
|
|
112
|
+
const down = await sampleLotTerrain(LOT, ORIGIN, { provider: fakeProvider(0.3, 30, 0, true) })
|
|
113
|
+
expect(down.ok).toBe(false)
|
|
114
|
+
expect(down.reason).toBe('USGS down')
|
|
115
|
+
const thrown = await sampleLotTerrain(LOT, ORIGIN, {
|
|
116
|
+
provider: async () => {
|
|
117
|
+
throw new Error('offline')
|
|
118
|
+
},
|
|
119
|
+
})
|
|
120
|
+
expect(thrown.ok).toBe(false)
|
|
121
|
+
expect(thrown.reason).toBe('offline')
|
|
122
|
+
expect((await sampleLotTerrain([[0, 0]], ORIGIN)).reason).toBe('no lot ring')
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test('the status fragment reads plainly', () => {
|
|
126
|
+
expect(describeTerrainSample(null, 'USGS down')).toBe(
|
|
127
|
+
'terrain read failed (USGS down) — the ground is flat until it is read',
|
|
128
|
+
)
|
|
129
|
+
expect(describeTerrainSample(null, 'skipped')).toBe('')
|
|
130
|
+
expect(
|
|
131
|
+
describeTerrainSample(
|
|
132
|
+
{
|
|
133
|
+
source: 'USGS EPQS',
|
|
134
|
+
grid: 9,
|
|
135
|
+
sampled: 79,
|
|
136
|
+
holes: 2,
|
|
137
|
+
datumFt: 30,
|
|
138
|
+
reliefFt: 4.56,
|
|
139
|
+
flat: false,
|
|
140
|
+
at: 't',
|
|
141
|
+
},
|
|
142
|
+
'',
|
|
143
|
+
),
|
|
144
|
+
).toBe("terrain: 4.6' of fall across the lot (USGS, 79 pts, 2 unread)")
|
|
145
|
+
expect(
|
|
146
|
+
describeTerrainSample(
|
|
147
|
+
{
|
|
148
|
+
source: 'USGS EPQS',
|
|
149
|
+
grid: 9,
|
|
150
|
+
sampled: 81,
|
|
151
|
+
holes: 0,
|
|
152
|
+
datumFt: 30,
|
|
153
|
+
reliefFt: 0.2,
|
|
154
|
+
flat: true,
|
|
155
|
+
at: 't',
|
|
156
|
+
},
|
|
157
|
+
'',
|
|
158
|
+
),
|
|
159
|
+
).toBe('ground flat within 2" (USGS)')
|
|
160
|
+
})
|
|
161
|
+
})
|