@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,368 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getWallArcData,
|
|
3
|
+
getWallChordFrame,
|
|
4
|
+
getWallFaceOffsets,
|
|
5
|
+
type WallFace,
|
|
6
|
+
type WallFaceRegion,
|
|
7
|
+
type WallNode,
|
|
8
|
+
} from '@pascal-app/core'
|
|
9
|
+
import { Matrix4, Quaternion, Vector3 } from 'three'
|
|
10
|
+
import type { WallRegionBounds } from './paint-regions'
|
|
11
|
+
|
|
12
|
+
// Geometry for editing a selected wall's paint regions in place. Everything is
|
|
13
|
+
// in the wall mesh's local frame: x along the chord from the start (= region
|
|
14
|
+
// `u`), y up from the wall base, z toward the reference line's left (face a).
|
|
15
|
+
// A region's `v` is measured above its face's own base, so callers pass the
|
|
16
|
+
// face base as `baseAt(u)`.
|
|
17
|
+
|
|
18
|
+
export const REGION_MIN_SIZE = 0.02
|
|
19
|
+
export const REGION_EDGE_SNAP_TOLERANCE = 0.08
|
|
20
|
+
const EDGE_EPSILON = 1e-4
|
|
21
|
+
|
|
22
|
+
export type RegionEdge = 'u0' | 'u1' | 'v0' | 'v1'
|
|
23
|
+
export const REGION_EDGES: readonly RegionEdge[] = ['u0', 'u1', 'v0', 'v1']
|
|
24
|
+
|
|
25
|
+
export type WallFaceSurface =
|
|
26
|
+
| { kind: 'plane'; face: WallFace; length: number; z: number }
|
|
27
|
+
| {
|
|
28
|
+
kind: 'arc'
|
|
29
|
+
face: WallFace
|
|
30
|
+
length: number
|
|
31
|
+
cx: number
|
|
32
|
+
cz: number
|
|
33
|
+
radius: number
|
|
34
|
+
direction: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type WallSurfaceInput = Pick<
|
|
38
|
+
WallNode,
|
|
39
|
+
'start' | 'end' | 'curveOffset' | 'thickness' | 'justification'
|
|
40
|
+
>
|
|
41
|
+
|
|
42
|
+
/** The face's surface in wall-local coordinates: a vertical plane or a vertical cylinder. */
|
|
43
|
+
export function getWallFaceSurface(wall: WallSurfaceInput, face: WallFace): WallFaceSurface {
|
|
44
|
+
const chord = getWallChordFrame(wall)
|
|
45
|
+
const offset = getWallFaceOffsets(wall)[face]
|
|
46
|
+
const arc = getWallArcData(wall)
|
|
47
|
+
if (!arc) return { kind: 'plane', face, length: chord.length, z: offset }
|
|
48
|
+
const dx = arc.center.x - chord.start.x
|
|
49
|
+
const dy = arc.center.y - chord.start.y
|
|
50
|
+
// Left of a counter-clockwise arc points at its centre, so an offset toward
|
|
51
|
+
// the left shrinks the radius there.
|
|
52
|
+
return {
|
|
53
|
+
kind: 'arc',
|
|
54
|
+
face,
|
|
55
|
+
length: chord.length,
|
|
56
|
+
cx: dx * chord.tangent.x + dy * chord.tangent.y,
|
|
57
|
+
cz: dx * chord.normal.x + dy * chord.normal.y,
|
|
58
|
+
radius: Math.max(1e-6, arc.radius - arc.direction * offset),
|
|
59
|
+
direction: arc.direction,
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const Y_AXIS = new Vector3(0, 1, 0)
|
|
64
|
+
const UNIT_SCALE = new Vector3(1, 1, 1)
|
|
65
|
+
|
|
66
|
+
/** Wall-local → parent (level) matrix, the transform the wall mesh renders with. */
|
|
67
|
+
export function wallLocalMatrix(
|
|
68
|
+
wall: Pick<WallNode, 'start' | 'end'>,
|
|
69
|
+
baseElevation: number,
|
|
70
|
+
target = new Matrix4(),
|
|
71
|
+
): Matrix4 {
|
|
72
|
+
const angle = Math.atan2(wall.end[1] - wall.start[1], wall.end[0] - wall.start[0])
|
|
73
|
+
return target.compose(
|
|
74
|
+
new Vector3(wall.start[0], baseElevation, wall.start[1]),
|
|
75
|
+
new Quaternion().setFromAxisAngle(Y_AXIS, -angle),
|
|
76
|
+
UNIT_SCALE,
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export type FaceSurfacePoint = {
|
|
81
|
+
x: number
|
|
82
|
+
z: number
|
|
83
|
+
/** Outward normal of this face, wall-local XZ. */
|
|
84
|
+
nx: number
|
|
85
|
+
nz: number
|
|
86
|
+
/** Unit tangent toward increasing u, wall-local XZ. */
|
|
87
|
+
tx: number
|
|
88
|
+
tz: number
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The point on the face above chord station `u`, with its outward normal and tangent. */
|
|
92
|
+
export function faceSurfacePoint(surface: WallFaceSurface, u: number): FaceSurfacePoint {
|
|
93
|
+
const outward = surface.face === 'a' ? 1 : -1
|
|
94
|
+
if (surface.kind === 'plane') {
|
|
95
|
+
return { x: u, z: surface.z, nx: 0, nz: outward, tx: 1, tz: 0 }
|
|
96
|
+
}
|
|
97
|
+
const du = u - surface.cx
|
|
98
|
+
const root = Math.sqrt(Math.max(0, surface.radius * surface.radius - du * du))
|
|
99
|
+
const z = surface.cz - surface.direction * root
|
|
100
|
+
// Left normal of the curve: toward the centre on a counter-clockwise arc.
|
|
101
|
+
const lx = (surface.direction * (surface.cx - u)) / surface.radius
|
|
102
|
+
const lz = (surface.direction * (surface.cz - z)) / surface.radius
|
|
103
|
+
const length = Math.hypot(lx, lz) || 1
|
|
104
|
+
const nx = (lx / length) * outward
|
|
105
|
+
const nz = (lz / length) * outward
|
|
106
|
+
return { x: u, z, nx, nz, tx: lz / length, tz: -lx / length }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
type Vec3 = { x: number; y: number; z: number }
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Where a wall-local ray meets the face: `u` along the chord and local `y`.
|
|
113
|
+
* Null when the ray runs parallel to the face or misses it.
|
|
114
|
+
*/
|
|
115
|
+
export function intersectFaceSurface(
|
|
116
|
+
surface: WallFaceSurface,
|
|
117
|
+
origin: Vec3,
|
|
118
|
+
direction: Vec3,
|
|
119
|
+
): { u: number; y: number } | null {
|
|
120
|
+
if (surface.kind === 'plane') {
|
|
121
|
+
if (Math.abs(direction.z) < 1e-9) return null
|
|
122
|
+
const t = (surface.z - origin.z) / direction.z
|
|
123
|
+
if (!(t >= 0)) return null
|
|
124
|
+
return { u: origin.x + direction.x * t, y: origin.y + direction.y * t }
|
|
125
|
+
}
|
|
126
|
+
const ox = origin.x - surface.cx
|
|
127
|
+
const oz = origin.z - surface.cz
|
|
128
|
+
const a = direction.x * direction.x + direction.z * direction.z
|
|
129
|
+
if (a < 1e-12) return null
|
|
130
|
+
const b = 2 * (ox * direction.x + oz * direction.z)
|
|
131
|
+
const c = ox * ox + oz * oz - surface.radius * surface.radius
|
|
132
|
+
const discriminant = b * b - 4 * a * c
|
|
133
|
+
if (discriminant < 0) return null
|
|
134
|
+
const root = Math.sqrt(discriminant)
|
|
135
|
+
for (const t of [(-b - root) / (2 * a), (-b + root) / (2 * a)]) {
|
|
136
|
+
if (t < 0) continue
|
|
137
|
+
const z = origin.z + direction.z * t
|
|
138
|
+
// Only the branch the wall bulges along is the face.
|
|
139
|
+
if ((z - surface.cz) * -surface.direction < -1e-9) continue
|
|
140
|
+
return { u: origin.x + direction.x * t, y: origin.y + direction.y * t }
|
|
141
|
+
}
|
|
142
|
+
return null
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export type ResolvedRegionBounds = { u0: number; u1: number; v0: number; v1: number }
|
|
146
|
+
|
|
147
|
+
/** Region bounds with absent edges resolved to the face's edges. */
|
|
148
|
+
export function resolveRegionBounds(
|
|
149
|
+
region: Pick<WallFaceRegion, 'u0' | 'u1' | 'v0' | 'v1'>,
|
|
150
|
+
length: number,
|
|
151
|
+
faceHeight: number,
|
|
152
|
+
): ResolvedRegionBounds {
|
|
153
|
+
return {
|
|
154
|
+
u0: Math.max(0, region.u0 ?? 0),
|
|
155
|
+
u1: Math.min(length, region.u1 ?? length),
|
|
156
|
+
v0: Math.max(0, region.v0 ?? 0),
|
|
157
|
+
v1: Math.min(faceHeight, region.v1 ?? faceHeight),
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function regionBoundsOf(region: WallFaceRegion): WallRegionBounds {
|
|
162
|
+
const bounds: WallRegionBounds = {}
|
|
163
|
+
for (const edge of REGION_EDGES) if (region[edge] !== undefined) bounds[edge] = region[edge]
|
|
164
|
+
return bounds
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export type RegionBoundDragInput = {
|
|
168
|
+
region: WallFaceRegion
|
|
169
|
+
edge: RegionEdge
|
|
170
|
+
/** Unsnapped pointer value for the edge: metres along (u) or above the face base (v). */
|
|
171
|
+
raw: number
|
|
172
|
+
length: number
|
|
173
|
+
faceHeight: number
|
|
174
|
+
gridStep?: number | null
|
|
175
|
+
/** Openings' and other regions' bounds on this face (`wallRegionSnapTargets`); face edges are always targets. */
|
|
176
|
+
snapTargets?: readonly number[]
|
|
177
|
+
edgeSnap?: boolean
|
|
178
|
+
snapTolerance?: number
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The region's bounds after dragging one edge. Snaps to the grid, then (when
|
|
183
|
+
* edge snapping is on) to the face edges and other regions within tolerance;
|
|
184
|
+
* clamps so the region keeps REGION_MIN_SIZE and stays on the face. An edge
|
|
185
|
+
* dragged onto the face edge becomes absent, so it follows the wall.
|
|
186
|
+
*/
|
|
187
|
+
export function resolveRegionBoundDrag(input: RegionBoundDragInput): WallRegionBounds {
|
|
188
|
+
const { region, edge, raw, length, faceHeight } = input
|
|
189
|
+
const isU = edge[0] === 'u'
|
|
190
|
+
const extent = isU ? length : faceHeight
|
|
191
|
+
let value = raw
|
|
192
|
+
if (input.gridStep && input.gridStep > 0)
|
|
193
|
+
value = Math.round(raw / input.gridStep) * input.gridStep
|
|
194
|
+
if (input.edgeSnap) {
|
|
195
|
+
const tolerance = input.snapTolerance ?? REGION_EDGE_SNAP_TOLERANCE
|
|
196
|
+
let best: number | null = null
|
|
197
|
+
for (const target of [0, extent, ...(input.snapTargets ?? [])]) {
|
|
198
|
+
const distance = Math.abs(target - raw)
|
|
199
|
+
if (distance <= tolerance && (best === null || distance < Math.abs(best - raw))) {
|
|
200
|
+
best = target
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (best !== null) value = best
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const current = resolveRegionBounds(region, length, faceHeight)
|
|
207
|
+
const low = edge === 'u0' || edge === 'v0'
|
|
208
|
+
const other = current[isU ? (low ? 'u1' : 'u0') : low ? 'v1' : 'v0']
|
|
209
|
+
const min = low ? 0 : Math.min(extent, other + REGION_MIN_SIZE)
|
|
210
|
+
const max = low ? Math.max(0, other - REGION_MIN_SIZE) : extent
|
|
211
|
+
value = Math.min(max, Math.max(min, value))
|
|
212
|
+
|
|
213
|
+
const bounds = regionBoundsOf(region)
|
|
214
|
+
const atFaceEdge = low ? value <= EDGE_EPSILON : value >= extent - EDGE_EPSILON
|
|
215
|
+
if (atFaceEdge) delete bounds[edge]
|
|
216
|
+
else bounds[edge] = value
|
|
217
|
+
return bounds
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** The region list with one region's bounds replaced (absent = to the edge). */
|
|
221
|
+
export function withRegionBounds(
|
|
222
|
+
regions: readonly WallFaceRegion[],
|
|
223
|
+
regionId: string,
|
|
224
|
+
bounds: WallRegionBounds,
|
|
225
|
+
): WallFaceRegion[] {
|
|
226
|
+
return regions.map((region) => {
|
|
227
|
+
if (region.id !== regionId) return region
|
|
228
|
+
const { u0: _u0, u1: _u1, v0: _v0, v1: _v1, ...rest } = region
|
|
229
|
+
const next: WallFaceRegion = { ...rest }
|
|
230
|
+
for (const edge of REGION_EDGES) if (bounds[edge] !== undefined) next[edge] = bounds[edge]
|
|
231
|
+
return next
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export type RegionHandlePlacement = {
|
|
236
|
+
edge: RegionEdge
|
|
237
|
+
position: [number, number, number]
|
|
238
|
+
/** Yaw turning local +x onto the face tangent. */
|
|
239
|
+
yaw: number
|
|
240
|
+
/** Direction the handle points in the face plane: 0 = +u, π/2 = up. */
|
|
241
|
+
tipAngle: number
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const TIP_ANGLE: Record<RegionEdge, number> = {
|
|
245
|
+
u0: Math.PI,
|
|
246
|
+
u1: 0,
|
|
247
|
+
v0: -Math.PI / 2,
|
|
248
|
+
v1: Math.PI / 2,
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function yawOf(point: FaceSurfacePoint) {
|
|
252
|
+
return Math.atan2(-point.tz, point.tx)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* One handle per bounded edge: u edges at the region's mid-height, v edges at
|
|
257
|
+
* its mid-width, lifted `lift` off the face. `keep` holds an edge that is
|
|
258
|
+
* being dragged, so its handle survives being dragged onto the face edge.
|
|
259
|
+
*/
|
|
260
|
+
export function regionHandlePlacements(
|
|
261
|
+
surface: WallFaceSurface,
|
|
262
|
+
region: WallFaceRegion,
|
|
263
|
+
faceHeight: number,
|
|
264
|
+
baseAt: (u: number) => number,
|
|
265
|
+
keep: RegionEdge | null = null,
|
|
266
|
+
lift = 0.01,
|
|
267
|
+
): RegionHandlePlacement[] {
|
|
268
|
+
const bounds = resolveRegionBounds(region, surface.length, faceHeight)
|
|
269
|
+
const midU = (bounds.u0 + bounds.u1) / 2
|
|
270
|
+
const midV = (bounds.v0 + bounds.v1) / 2
|
|
271
|
+
const placements: RegionHandlePlacement[] = []
|
|
272
|
+
for (const edge of REGION_EDGES) {
|
|
273
|
+
if (region[edge] === undefined && edge !== keep) continue
|
|
274
|
+
const u = edge === 'u0' ? bounds.u0 : edge === 'u1' ? bounds.u1 : midU
|
|
275
|
+
const v = edge === 'v0' ? bounds.v0 : edge === 'v1' ? bounds.v1 : midV
|
|
276
|
+
const point = faceSurfacePoint(surface, u)
|
|
277
|
+
placements.push({
|
|
278
|
+
edge,
|
|
279
|
+
position: [point.x + point.nx * lift, baseAt(u) + v, point.z + point.nz * lift],
|
|
280
|
+
yaw: yawOf(point),
|
|
281
|
+
tipAngle: TIP_ANGLE[edge],
|
|
282
|
+
})
|
|
283
|
+
}
|
|
284
|
+
return placements
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export type OutlinePoint = { position: [number, number, number]; normal: [number, number] }
|
|
288
|
+
|
|
289
|
+
/** Closed outline of a region on its face (counter-clockwise seen from outside), lifted off it. */
|
|
290
|
+
export function regionOutline(
|
|
291
|
+
surface: WallFaceSurface,
|
|
292
|
+
region: WallFaceRegion,
|
|
293
|
+
faceHeight: number,
|
|
294
|
+
baseAt: (u: number) => number,
|
|
295
|
+
lift = 0.004,
|
|
296
|
+
): OutlinePoint[] {
|
|
297
|
+
const bounds = resolveRegionBounds(region, surface.length, faceHeight)
|
|
298
|
+
const width = bounds.u1 - bounds.u0
|
|
299
|
+
const segments = surface.kind === 'arc' ? Math.min(48, Math.max(2, Math.ceil(width / 0.2))) : 1
|
|
300
|
+
const at = (u: number, v: number): OutlinePoint => {
|
|
301
|
+
const point = faceSurfacePoint(surface, u)
|
|
302
|
+
return {
|
|
303
|
+
position: [point.x + point.nx * lift, baseAt(u) + v, point.z + point.nz * lift],
|
|
304
|
+
normal: [point.nx, point.nz],
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
const stations = Array.from(
|
|
308
|
+
{ length: segments + 1 },
|
|
309
|
+
(_, index) => bounds.u0 + (width * index) / segments,
|
|
310
|
+
)
|
|
311
|
+
return [
|
|
312
|
+
...stations.map((u) => at(u, bounds.v0)),
|
|
313
|
+
...stations.reverse().map((u) => at(u, bounds.v1)),
|
|
314
|
+
]
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Triangles for a ribbon of half-width `halfWidth` running along a closed
|
|
319
|
+
* outline, lying in the face. Each segment is extended by the half-width so
|
|
320
|
+
* corners close.
|
|
321
|
+
*/
|
|
322
|
+
export function buildOutlineRibbon(
|
|
323
|
+
points: readonly OutlinePoint[],
|
|
324
|
+
halfWidth: number,
|
|
325
|
+
): Float32Array {
|
|
326
|
+
const out: number[] = []
|
|
327
|
+
const count = points.length
|
|
328
|
+
for (let index = 0; index < count; index += 1) {
|
|
329
|
+
const a = points[index]!
|
|
330
|
+
const b = points[(index + 1) % count]!
|
|
331
|
+
const dx = b.position[0] - a.position[0]
|
|
332
|
+
const dy = b.position[1] - a.position[1]
|
|
333
|
+
const dz = b.position[2] - a.position[2]
|
|
334
|
+
const length = Math.hypot(dx, dy, dz)
|
|
335
|
+
if (length < 1e-9) continue
|
|
336
|
+
const ux = dx / length
|
|
337
|
+
const uy = dy / length
|
|
338
|
+
const uz = dz / length
|
|
339
|
+
const nx = (a.normal[0] + b.normal[0]) / 2
|
|
340
|
+
const nz = (a.normal[1] + b.normal[1]) / 2
|
|
341
|
+
// In-face perpendicular: normal × direction.
|
|
342
|
+
let wx = -nz * uy
|
|
343
|
+
let wy = nz * ux - nx * uz
|
|
344
|
+
let wz = nx * uy
|
|
345
|
+
const wLength = Math.hypot(wx, wy, wz) || 1
|
|
346
|
+
wx = (wx / wLength) * halfWidth
|
|
347
|
+
wy = (wy / wLength) * halfWidth
|
|
348
|
+
wz = (wz / wLength) * halfWidth
|
|
349
|
+
const p0 = [
|
|
350
|
+
a.position[0] - ux * halfWidth,
|
|
351
|
+
a.position[1] - uy * halfWidth,
|
|
352
|
+
a.position[2] - uz * halfWidth,
|
|
353
|
+
]
|
|
354
|
+
const p1 = [
|
|
355
|
+
b.position[0] + ux * halfWidth,
|
|
356
|
+
b.position[1] + uy * halfWidth,
|
|
357
|
+
b.position[2] + uz * halfWidth,
|
|
358
|
+
]
|
|
359
|
+
const q = [
|
|
360
|
+
[p0[0]! - wx, p0[1]! - wy, p0[2]! - wz],
|
|
361
|
+
[p0[0]! + wx, p0[1]! + wy, p0[2]! + wz],
|
|
362
|
+
[p1[0]! + wx, p1[1]! + wy, p1[2]! + wz],
|
|
363
|
+
[p1[0]! - wx, p1[1]! - wy, p1[2]! - wz],
|
|
364
|
+
]
|
|
365
|
+
for (const corner of [0, 1, 2, 0, 2, 3]) out.push(...q[corner]!)
|
|
366
|
+
}
|
|
367
|
+
return new Float32Array(out)
|
|
368
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
type AnyNode,
|
|
4
|
+
type SceneMaterial,
|
|
5
|
+
type SceneMaterialId,
|
|
6
|
+
WALL_FACE_REGION_LIMIT,
|
|
7
|
+
type WallFaceRegion,
|
|
8
|
+
WallNode,
|
|
9
|
+
} from '@pascal-app/core'
|
|
10
|
+
import {
|
|
11
|
+
describeRegionFinish,
|
|
12
|
+
formatRegionBounds,
|
|
13
|
+
wallRegionCapNotice,
|
|
14
|
+
wallRegionFaceLabels,
|
|
15
|
+
wallRegionRows,
|
|
16
|
+
} from './wall-region-list'
|
|
17
|
+
|
|
18
|
+
const LEVEL = 'level_region-list'
|
|
19
|
+
const SCENE_ID = 'scene-material_region-list' as SceneMaterialId
|
|
20
|
+
const materials = {
|
|
21
|
+
[SCENE_ID]: {
|
|
22
|
+
id: SCENE_ID,
|
|
23
|
+
name: 'Sage green',
|
|
24
|
+
material: { properties: { color: '#8fa98a' } },
|
|
25
|
+
} as SceneMaterial,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function wall(id: string, start: [number, number], end: [number, number]) {
|
|
29
|
+
return WallNode.parse({ id, parentId: LEVEL, start, end })
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function nodesOf(walls: WallNode[]) {
|
|
33
|
+
return Object.fromEntries(walls.map((node) => [node.id, node])) as Record<string, AnyNode>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('bounds text', () => {
|
|
37
|
+
test('reads each axis in the viewer unit; absent bounds run to the edge', () => {
|
|
38
|
+
expect(formatRegionBounds({ v1: 0.9 }, 'metric')).toBe('0–0.90 m high')
|
|
39
|
+
expect(formatRegionBounds({ u0: 1.2, u1: 2.4 }, 'metric')).toBe('1.20–2.40 m along')
|
|
40
|
+
expect(formatRegionBounds({ u0: 1.2, u1: 2.4, v0: 0.9, v1: 2.1 }, 'metric')).toBe(
|
|
41
|
+
'1.20–2.40 m along, 0.90–2.10 m high',
|
|
42
|
+
)
|
|
43
|
+
expect(formatRegionBounds({ v0: 0.9 }, 'metric')).toBe('Above 0.90 m')
|
|
44
|
+
expect(formatRegionBounds({ u0: 1.5 }, 'metric')).toBe('From 1.50 m along')
|
|
45
|
+
expect(formatRegionBounds({}, 'metric')).toBe('Whole face')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('imperial converts to feet', () => {
|
|
49
|
+
expect(formatRegionBounds({ v1: 0.9144 }, 'imperial')).toBe('0–3.00 ft high')
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe('face labels', () => {
|
|
54
|
+
test('a lone wall reads Face A / Face B', () => {
|
|
55
|
+
const lone = wall('wall_lone', [0, 0], [4, 0])
|
|
56
|
+
expect(wallRegionFaceLabels(nodesOf([lone]), lone.id)).toEqual({ a: 'Face A', b: 'Face B' })
|
|
57
|
+
expect(wallRegionFaceLabels({}, 'wall_missing')).toEqual({ a: 'Face A', b: 'Face B' })
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('a room wall names its inside and outside faces', () => {
|
|
61
|
+
const walls = [
|
|
62
|
+
wall('wall_s', [0, 0], [4, 0]),
|
|
63
|
+
wall('wall_e', [4, 0], [4, 3]),
|
|
64
|
+
wall('wall_n', [4, 3], [0, 3]),
|
|
65
|
+
wall('wall_w', [0, 3], [0, 0]),
|
|
66
|
+
]
|
|
67
|
+
const labels = wallRegionFaceLabels(nodesOf(walls), 'wall_s')
|
|
68
|
+
expect(new Set(Object.values(labels))).toEqual(new Set(['Inside', 'Outside']))
|
|
69
|
+
// Drawn counter-clockwise, the room sits on the left: face a.
|
|
70
|
+
expect(labels.a).toBe('Inside')
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
describe('finish', () => {
|
|
75
|
+
test('a scene material shows its name and colour', () => {
|
|
76
|
+
expect(describeRegionFinish(`scene:${SCENE_ID}`, materials)).toEqual({
|
|
77
|
+
name: 'Sage green',
|
|
78
|
+
color: '#8fa98a',
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('unknown refs fall back to a neutral swatch', () => {
|
|
83
|
+
expect(describeRegionFinish('scene:missing', {}).name).toBe('Custom finish')
|
|
84
|
+
expect(describeRegionFinish('not-a-ref', {}).name).toBe('Custom finish')
|
|
85
|
+
expect(describeRegionFinish('library:no-such-material', {}).name).toBe('Library finish')
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
describe('rows', () => {
|
|
90
|
+
test('one row per region, face a first, with label, finish and bounds', () => {
|
|
91
|
+
const faceRegions: WallFaceRegion[] = [
|
|
92
|
+
{ id: 'r1', face: 'b', v1: 0.9, finish: `scene:${SCENE_ID}` },
|
|
93
|
+
{ id: 'r2', face: 'a', u0: 1.2, u1: 2.4, finish: `scene:${SCENE_ID}` },
|
|
94
|
+
]
|
|
95
|
+
const rows = wallRegionRows({ faceRegions }, { a: 'Inside', b: 'Outside' }, materials, 'metric')
|
|
96
|
+
expect(rows.map(({ id, faceLabel, boundsText }) => ({ id, faceLabel, boundsText }))).toEqual([
|
|
97
|
+
{ id: 'r2', faceLabel: 'Inside', boundsText: '1.20–2.40 m along' },
|
|
98
|
+
{ id: 'r1', faceLabel: 'Outside', boundsText: '0–0.90 m high' },
|
|
99
|
+
])
|
|
100
|
+
expect(rows[0]!.finish.name).toBe('Sage green')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('the cap notice appears only when a face is full', () => {
|
|
104
|
+
const full = (face: 'a' | 'b') =>
|
|
105
|
+
Array.from({ length: WALL_FACE_REGION_LIMIT }, (_, index) => ({
|
|
106
|
+
id: `${face}${index}`,
|
|
107
|
+
face,
|
|
108
|
+
finish: 'x',
|
|
109
|
+
}))
|
|
110
|
+
const labels = { a: 'Face A', b: 'Face B' }
|
|
111
|
+
expect(wallRegionCapNotice({ faceRegions: full('a').slice(1) }, labels)).toBeNull()
|
|
112
|
+
expect(wallRegionCapNotice({ faceRegions: full('b') }, labels)).toBe(
|
|
113
|
+
`Face B is full: up to ${WALL_FACE_REGION_LIMIT} regions per face`,
|
|
114
|
+
)
|
|
115
|
+
expect(wallRegionCapNotice({ faceRegions: [...full('a'), ...full('b')] }, labels)).toBe(
|
|
116
|
+
`Up to ${WALL_FACE_REGION_LIMIT} regions per face`,
|
|
117
|
+
)
|
|
118
|
+
})
|
|
119
|
+
})
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AnyNode,
|
|
3
|
+
getCatalogMaterialById,
|
|
4
|
+
parseMaterialRef,
|
|
5
|
+
roomSideFaces,
|
|
6
|
+
type SceneMaterial,
|
|
7
|
+
type SceneMaterialId,
|
|
8
|
+
WALL_FACE_REGION_LIMIT,
|
|
9
|
+
type WallFace,
|
|
10
|
+
type WallFaceRegion,
|
|
11
|
+
type WallNode,
|
|
12
|
+
} from '@pascal-app/core'
|
|
13
|
+
import { getLinearUnitLabel, type LinearUnit, metersToLinearUnit } from './measurements'
|
|
14
|
+
|
|
15
|
+
// Rows for the wall panel's "Paint regions" list: finish swatch + name, the
|
|
16
|
+
// face in room terms when the wall has a room on exactly one side, and the
|
|
17
|
+
// bounds in the viewer unit.
|
|
18
|
+
|
|
19
|
+
const FALLBACK_SWATCH_COLOR = '#d4d4d8'
|
|
20
|
+
|
|
21
|
+
export type WallRegionFinish = { name: string; color: string; imageUrl?: string }
|
|
22
|
+
|
|
23
|
+
export type WallRegionRow = {
|
|
24
|
+
id: string
|
|
25
|
+
face: WallFace
|
|
26
|
+
faceLabel: string
|
|
27
|
+
finish: WallRegionFinish
|
|
28
|
+
boundsText: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** "Inside" / "Outside" when a room sits on exactly one side of the wall, else "Face A" / "Face B". */
|
|
32
|
+
export function wallRegionFaceLabels(
|
|
33
|
+
nodes: Readonly<Record<string, AnyNode>>,
|
|
34
|
+
wallId: string,
|
|
35
|
+
): Record<WallFace, string> {
|
|
36
|
+
try {
|
|
37
|
+
const sides = roomSideFaces(nodes as Parameters<typeof roomSideFaces>[0], wallId)
|
|
38
|
+
if (sides.inside && sides.outside) {
|
|
39
|
+
return {
|
|
40
|
+
[sides.inside]: 'Inside',
|
|
41
|
+
[sides.outside]: 'Outside',
|
|
42
|
+
} as Record<WallFace, string>
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
// Not a wall (any more): fall through to the neutral labels.
|
|
46
|
+
}
|
|
47
|
+
return { a: 'Face A', b: 'Face B' }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Name and swatch for a region's finish ref (library or scene material). */
|
|
51
|
+
export function describeRegionFinish(
|
|
52
|
+
ref: string,
|
|
53
|
+
materials: Readonly<Record<SceneMaterialId, SceneMaterial>>,
|
|
54
|
+
): WallRegionFinish {
|
|
55
|
+
const parsed = parseMaterialRef(ref)
|
|
56
|
+
if (parsed?.kind === 'scene') {
|
|
57
|
+
const scene = materials[parsed.id as SceneMaterialId]
|
|
58
|
+
return {
|
|
59
|
+
name: scene?.name ?? 'Custom finish',
|
|
60
|
+
color: scene?.material.properties?.color ?? FALLBACK_SWATCH_COLOR,
|
|
61
|
+
...(scene?.material.texture?.url ? { imageUrl: scene.material.texture.url } : {}),
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (parsed?.kind === 'library') {
|
|
65
|
+
const item = getCatalogMaterialById(parsed.id)
|
|
66
|
+
const imageUrl = item?.previewThumbnailUrl ?? item?.preset.maps.albedoMap
|
|
67
|
+
return {
|
|
68
|
+
name: item?.label ?? 'Library finish',
|
|
69
|
+
color: item?.previewColor ?? item?.preset.mapProperties.color ?? FALLBACK_SWATCH_COLOR,
|
|
70
|
+
...(imageUrl ? { imageUrl } : {}),
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return { name: 'Custom finish', color: FALLBACK_SWATCH_COLOR }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function range(low: number | undefined, high: number | undefined, unit: LinearUnit, axis: string) {
|
|
77
|
+
const value = (meters: number) => metersToLinearUnit(meters, unit).toFixed(2)
|
|
78
|
+
const label = getLinearUnitLabel(unit)
|
|
79
|
+
if (low === undefined && high === undefined) return null
|
|
80
|
+
if (low === undefined) return `0–${value(high!)} ${label} ${axis}`
|
|
81
|
+
if (high === undefined)
|
|
82
|
+
return axis === 'high' ? `Above ${value(low)} ${label}` : `From ${value(low)} ${label} along`
|
|
83
|
+
return `${value(low)}–${value(high)} ${label} ${axis}`
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** "1.20–2.40 m along, 0–0.90 m high"; absent bounds run to the face edge. */
|
|
87
|
+
export function formatRegionBounds(
|
|
88
|
+
region: Pick<WallFaceRegion, 'u0' | 'u1' | 'v0' | 'v1'>,
|
|
89
|
+
unit: LinearUnit,
|
|
90
|
+
): string {
|
|
91
|
+
const parts = [
|
|
92
|
+
range(region.u0, region.u1, unit, 'along'),
|
|
93
|
+
range(region.v0, region.v1, unit, 'high'),
|
|
94
|
+
].filter((part): part is string => part !== null)
|
|
95
|
+
return parts.length > 0 ? parts.join(', ') : 'Whole face'
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function wallRegionRows(
|
|
99
|
+
wall: Pick<WallNode, 'faceRegions'>,
|
|
100
|
+
faceLabels: Record<WallFace, string>,
|
|
101
|
+
materials: Readonly<Record<SceneMaterialId, SceneMaterial>>,
|
|
102
|
+
unit: LinearUnit,
|
|
103
|
+
): WallRegionRow[] {
|
|
104
|
+
const regions = wall.faceRegions ?? []
|
|
105
|
+
return (['a', 'b'] as const).flatMap((face) =>
|
|
106
|
+
regions
|
|
107
|
+
.filter((region) => region.face === face)
|
|
108
|
+
.map((region) => ({
|
|
109
|
+
id: region.id,
|
|
110
|
+
face,
|
|
111
|
+
faceLabel: faceLabels[face],
|
|
112
|
+
finish: describeRegionFinish(region.finish, materials),
|
|
113
|
+
boundsText: formatRegionBounds(region, unit),
|
|
114
|
+
})),
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Faces that already hold the maximum number of regions. */
|
|
119
|
+
export function fullWallFaces(wall: Pick<WallNode, 'faceRegions'>): WallFace[] {
|
|
120
|
+
const regions = wall.faceRegions ?? []
|
|
121
|
+
return (['a', 'b'] as const).filter(
|
|
122
|
+
(face) => regions.filter((region) => region.face === face).length >= WALL_FACE_REGION_LIMIT,
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The cap notice under the list, naming the full face when only one is. */
|
|
127
|
+
export function wallRegionCapNotice(
|
|
128
|
+
wall: Pick<WallNode, 'faceRegions'>,
|
|
129
|
+
faceLabels: Record<WallFace, string>,
|
|
130
|
+
): string | null {
|
|
131
|
+
const full = fullWallFaces(wall)
|
|
132
|
+
const cap = `up to ${WALL_FACE_REGION_LIMIT} regions per face`
|
|
133
|
+
if (full.length === 0) return null
|
|
134
|
+
if (full.length === 2) return `Up to ${WALL_FACE_REGION_LIMIT} regions per face`
|
|
135
|
+
return `${faceLabels[full[0]!]} is full: ${cap}`
|
|
136
|
+
}
|