@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,725 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type AnyNode,
|
|
5
|
+
type AnyNodeId,
|
|
6
|
+
area,
|
|
7
|
+
getLevelDisplayName,
|
|
8
|
+
useScene,
|
|
9
|
+
type ZoneNode,
|
|
10
|
+
} from '@pascal-app/core'
|
|
11
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
12
|
+
import { Check, ChevronDown, Plus } from 'lucide-react'
|
|
13
|
+
import { type KeyboardEvent, useEffect, useId, useMemo, useRef, useState } from 'react'
|
|
14
|
+
import { startCeilingEdit } from '../../../lib/ceiling-edit-session'
|
|
15
|
+
import { resolveOverlayPolicy } from '../../../lib/interaction/overlay-policy'
|
|
16
|
+
import { formatAreaLabel, formatLinearMeasurement } from '../../../lib/measurements'
|
|
17
|
+
import { addMezzanineStairs } from '../../../lib/mezzanine-stairs'
|
|
18
|
+
import { roomFloorBase, roomRelativeFloorHeight } from '../../../lib/room-construction-commands'
|
|
19
|
+
import { mezzanineElevationBounds } from '../../../lib/room-handle-drag'
|
|
20
|
+
import type { RoomSelectionRecord } from '../../../lib/room-selection'
|
|
21
|
+
import {
|
|
22
|
+
baseRoomName,
|
|
23
|
+
numberedRoomName,
|
|
24
|
+
type RoomNameSection,
|
|
25
|
+
roomNameSections,
|
|
26
|
+
roomNameUseCount,
|
|
27
|
+
} from '../../../lib/room-name-catalog'
|
|
28
|
+
import { renameRoom, requestRoomDeletion } from '../../../lib/room-structure-commands'
|
|
29
|
+
import { addZoneToNewUnit, assignZoneToUnit, zoneUnits } from '../../../lib/units'
|
|
30
|
+
import { cn } from '../../../lib/utils'
|
|
31
|
+
import useEditor from '../../../store/use-editor'
|
|
32
|
+
import useInteractionScope from '../../../store/use-interaction-scope'
|
|
33
|
+
import {
|
|
34
|
+
DropdownMenu,
|
|
35
|
+
DropdownMenuContent,
|
|
36
|
+
DropdownMenuItem,
|
|
37
|
+
DropdownMenuSeparator,
|
|
38
|
+
DropdownMenuTrigger,
|
|
39
|
+
} from '../primitives/dropdown-menu'
|
|
40
|
+
import { ColorDot } from '../primitives/color-dot'
|
|
41
|
+
import { Popover, PopoverAnchor, PopoverContent } from '../primitives/popover'
|
|
42
|
+
import { ShortcutToken } from '../primitives/shortcut-token'
|
|
43
|
+
import { PanelWrapper } from './panel-wrapper'
|
|
44
|
+
import { RoomOpeningsField } from './room-openings-field'
|
|
45
|
+
import {
|
|
46
|
+
ConstructionPart,
|
|
47
|
+
FloorHeightField,
|
|
48
|
+
PartField,
|
|
49
|
+
pill,
|
|
50
|
+
RoomConstructionRows,
|
|
51
|
+
roomInsideHeight,
|
|
52
|
+
} from './room-construction-rows'
|
|
53
|
+
import { type RoomNameOption, roomNameOptions } from './room-name-options'
|
|
54
|
+
|
|
55
|
+
/** "Room · Ground floor", "Mezzanine · in Kitchen", "Room · not enclosed". */
|
|
56
|
+
export function roomKicker(
|
|
57
|
+
nodes: Record<string, AnyNode>,
|
|
58
|
+
zoneId: string,
|
|
59
|
+
options: { mezzanine?: boolean; open?: boolean } = {},
|
|
60
|
+
): string {
|
|
61
|
+
const zone = nodes[zoneId]
|
|
62
|
+
if (options.mezzanine && zone?.type === 'zone') {
|
|
63
|
+
const host = zone.hostZoneId ? nodes[zone.hostZoneId] : undefined
|
|
64
|
+
const hostName = host?.type === 'zone' ? host.name?.trim() : ''
|
|
65
|
+
return hostName ? `Mezzanine · in ${hostName}` : 'Mezzanine'
|
|
66
|
+
}
|
|
67
|
+
if (options.open) return 'Room · not enclosed'
|
|
68
|
+
const level = zone?.parentId ? nodes[zone.parentId] : undefined
|
|
69
|
+
return level?.type === 'level' ? `Room · ${getLevelDisplayName(level)}` : 'Room'
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function RoomPanel({
|
|
73
|
+
room,
|
|
74
|
+
onClose = () => useEditor.getState().clearRoom(),
|
|
75
|
+
}: {
|
|
76
|
+
room: RoomSelectionRecord
|
|
77
|
+
onClose?: () => void
|
|
78
|
+
}) {
|
|
79
|
+
const nodes = useScene((state) => state.nodes)
|
|
80
|
+
const sections = useMemo(() => roomNameSections(nodes, room.zoneId), [nodes, room.zoneId])
|
|
81
|
+
const name = useScene((state) => state.nodes[room.zoneId as AnyNodeId]?.name ?? '')
|
|
82
|
+
const visible = useInteractionScope(
|
|
83
|
+
(state) => resolveOverlayPolicy(state.scope).conflictingControls === 'shown',
|
|
84
|
+
)
|
|
85
|
+
if (!visible) return null
|
|
86
|
+
const mezzanine = !!room.mezzanine
|
|
87
|
+
return (
|
|
88
|
+
<PanelWrapper
|
|
89
|
+
kicker={roomKicker(nodes, room.zoneId, { mezzanine })}
|
|
90
|
+
onClose={onClose}
|
|
91
|
+
title={name.trim() || (mezzanine ? 'Mezzanine' : 'Room')}
|
|
92
|
+
titleContent={
|
|
93
|
+
<div className="flex min-w-0 flex-1 items-center gap-2">
|
|
94
|
+
<RoomColorDot zoneId={room.zoneId} />
|
|
95
|
+
<RoomName
|
|
96
|
+
key={room.zoneId}
|
|
97
|
+
mezzanine={mezzanine}
|
|
98
|
+
name={name}
|
|
99
|
+
sections={sections}
|
|
100
|
+
zoneId={room.zoneId}
|
|
101
|
+
/>
|
|
102
|
+
</div>
|
|
103
|
+
}
|
|
104
|
+
>
|
|
105
|
+
<div className="flex flex-col px-4 pt-3 pb-4 text-sm" data-room-panel={room.key.zoneId}>
|
|
106
|
+
{!mezzanine && <RoomSubline room={room} />}
|
|
107
|
+
<RoomStats room={room} />
|
|
108
|
+
<RoomUnitRow zoneId={room.zoneId} />
|
|
109
|
+
{mezzanine ? (
|
|
110
|
+
<MezzanineRows key={room.zoneId} room={room} />
|
|
111
|
+
) : (
|
|
112
|
+
<RoomConstructionRows key={room.zoneId} room={room} />
|
|
113
|
+
)}
|
|
114
|
+
</div>
|
|
115
|
+
</PanelWrapper>
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* A room the walls do not close (yet): the same title and kicker, and what
|
|
121
|
+
* needs no closed outline — its name, its unit, deleting it.
|
|
122
|
+
*/
|
|
123
|
+
export function OpenRoomPanel({ zoneId, onClose }: { zoneId: string; onClose: () => void }) {
|
|
124
|
+
const nodes = useScene((state) => state.nodes)
|
|
125
|
+
const sections = useMemo(() => roomNameSections(nodes, zoneId), [nodes, zoneId])
|
|
126
|
+
const name = useScene((state) => state.nodes[zoneId as AnyNodeId]?.name ?? '')
|
|
127
|
+
const unit = useViewer((s) => s.unit)
|
|
128
|
+
const zone = nodes[zoneId as AnyNodeId]
|
|
129
|
+
if (zone?.type !== 'zone') return null
|
|
130
|
+
return (
|
|
131
|
+
<PanelWrapper
|
|
132
|
+
kicker={roomKicker(nodes, zoneId, { open: true })}
|
|
133
|
+
onClose={onClose}
|
|
134
|
+
title={name.trim() || 'Room'}
|
|
135
|
+
titleContent={
|
|
136
|
+
<div className="flex min-w-0 flex-1 items-center gap-2">
|
|
137
|
+
<RoomColorDot zoneId={zoneId} />
|
|
138
|
+
<RoomName
|
|
139
|
+
key={zoneId}
|
|
140
|
+
mezzanine={false}
|
|
141
|
+
name={name}
|
|
142
|
+
sections={sections}
|
|
143
|
+
zoneId={zoneId}
|
|
144
|
+
/>
|
|
145
|
+
</div>
|
|
146
|
+
}
|
|
147
|
+
>
|
|
148
|
+
<div className="flex flex-col px-4 pt-3 pb-4 text-sm" data-open-room-panel={zoneId}>
|
|
149
|
+
<p className="text-muted-foreground text-xs">
|
|
150
|
+
{formatAreaLabel(area([{ outer: zone.polygon, holes: zone.holes }]), unit, 2)} · Close
|
|
151
|
+
its walls to build its floor and ceiling.
|
|
152
|
+
</p>
|
|
153
|
+
<RoomUnitRow zoneId={zoneId} />
|
|
154
|
+
<button
|
|
155
|
+
className="mt-3 w-full rounded-full border border-border/50 py-2 font-medium text-[13px] text-foreground transition-colors hover:bg-accent/50"
|
|
156
|
+
data-delete-open-room
|
|
157
|
+
onClick={() => requestRoomDeletion(zoneId)}
|
|
158
|
+
type="button"
|
|
159
|
+
>
|
|
160
|
+
Delete room
|
|
161
|
+
</button>
|
|
162
|
+
</div>
|
|
163
|
+
</PanelWrapper>
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** "Unit: Flat A ›" — which unit the room belongs to; change it, add it to one, or take it out. */
|
|
168
|
+
export function RoomUnitRow({ zoneId }: { zoneId: string }) {
|
|
169
|
+
const nodes = useScene((scene) => scene.nodes)
|
|
170
|
+
const { buildingId, units, current } = useMemo(() => zoneUnits(nodes, zoneId), [nodes, zoneId])
|
|
171
|
+
if (!buildingId) return null
|
|
172
|
+
return (
|
|
173
|
+
<div className="mt-3 flex min-h-8 items-center justify-between gap-3" data-room-unit={zoneId}>
|
|
174
|
+
<span className="text-muted-foreground text-xs">Unit</span>
|
|
175
|
+
<DropdownMenu>
|
|
176
|
+
<DropdownMenuTrigger asChild>
|
|
177
|
+
<button
|
|
178
|
+
aria-label="Unit"
|
|
179
|
+
className="flex min-w-0 items-center gap-1.5 rounded-full border border-border/50 px-2.5 py-0.5 text-xs transition-colors hover:bg-accent"
|
|
180
|
+
type="button"
|
|
181
|
+
>
|
|
182
|
+
{current && (
|
|
183
|
+
<span
|
|
184
|
+
aria-hidden
|
|
185
|
+
className="size-2 shrink-0 rounded-full"
|
|
186
|
+
style={{ backgroundColor: current.color }}
|
|
187
|
+
/>
|
|
188
|
+
)}
|
|
189
|
+
<span className="truncate">
|
|
190
|
+
{current ? current.name || 'Unit' : units.length ? 'None' : 'Add to a unit'}
|
|
191
|
+
</span>
|
|
192
|
+
<ChevronDown className="size-3 shrink-0 text-muted-foreground" />
|
|
193
|
+
</button>
|
|
194
|
+
</DropdownMenuTrigger>
|
|
195
|
+
<DropdownMenuContent align="end" className="min-w-40">
|
|
196
|
+
{units.map((unit) => (
|
|
197
|
+
<DropdownMenuItem
|
|
198
|
+
key={unit.id}
|
|
199
|
+
onSelect={() => assignZoneToUnit(zoneId as ZoneNode['id'], unit.id)}
|
|
200
|
+
>
|
|
201
|
+
<span
|
|
202
|
+
aria-hidden
|
|
203
|
+
className="size-2 shrink-0 rounded-full"
|
|
204
|
+
style={{ backgroundColor: unit.color }}
|
|
205
|
+
/>
|
|
206
|
+
<span className="flex-1 truncate">{unit.name || 'Unit'}</span>
|
|
207
|
+
{unit.id === current?.id && <Check className="size-3.5" />}
|
|
208
|
+
</DropdownMenuItem>
|
|
209
|
+
))}
|
|
210
|
+
{units.length > 0 && <DropdownMenuSeparator />}
|
|
211
|
+
<DropdownMenuItem onSelect={() => addZoneToNewUnit(zoneId as ZoneNode['id'])}>
|
|
212
|
+
New unit
|
|
213
|
+
</DropdownMenuItem>
|
|
214
|
+
{current && (
|
|
215
|
+
<DropdownMenuItem onSelect={() => assignZoneToUnit(zoneId as ZoneNode['id'], null)}>
|
|
216
|
+
Remove from {current.name || 'unit'}
|
|
217
|
+
</DropdownMenuItem>
|
|
218
|
+
)}
|
|
219
|
+
</DropdownMenuContent>
|
|
220
|
+
</DropdownMenu>
|
|
221
|
+
</div>
|
|
222
|
+
)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const count = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`
|
|
226
|
+
|
|
227
|
+
/** "2 doors · 3 windows" (the level is in the kicker). */
|
|
228
|
+
function RoomSubline({ room }: { room: RoomSelectionRecord }) {
|
|
229
|
+
const text = useScene((scene) => {
|
|
230
|
+
const walls = new Set(room.boundaryWallIds)
|
|
231
|
+
let doors = 0
|
|
232
|
+
let windows = 0
|
|
233
|
+
for (const node of Object.values(scene.nodes)) {
|
|
234
|
+
if (node.type !== 'door' && node.type !== 'window') continue
|
|
235
|
+
const wallId = node.parentId ?? ('wallId' in node ? (node.wallId as string) : '')
|
|
236
|
+
if (!walls.has(wallId)) continue
|
|
237
|
+
if (node.type === 'door') doors++
|
|
238
|
+
else windows++
|
|
239
|
+
}
|
|
240
|
+
return `${count(doors, 'door')} · ${count(windows, 'window')}`
|
|
241
|
+
})
|
|
242
|
+
return (
|
|
243
|
+
<p className="text-muted-foreground text-xs" data-room-subline>
|
|
244
|
+
{text}
|
|
245
|
+
</p>
|
|
246
|
+
)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function ringPerimeter(ring: readonly (readonly [number, number])[]) {
|
|
250
|
+
let total = 0
|
|
251
|
+
for (let i = 0; i < ring.length; i++) {
|
|
252
|
+
const a = ring[i]!
|
|
253
|
+
const b = ring[(i + 1) % ring.length]!
|
|
254
|
+
total += Math.hypot(b[0] - a[0], b[1] - a[1])
|
|
255
|
+
}
|
|
256
|
+
return total
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Area, perimeter and inside height, in the user's units. */
|
|
260
|
+
function RoomStats({ room }: { room: RoomSelectionRecord }) {
|
|
261
|
+
const unit = useViewer((s) => s.unit)
|
|
262
|
+
const metricNotation = useViewer((s) => s.metricNotation)
|
|
263
|
+
const nodes = useScene((scene) => scene.nodes)
|
|
264
|
+
const zone = nodes[room.zoneId as AnyNodeId]
|
|
265
|
+
const perimeter =
|
|
266
|
+
zone?.type === 'zone'
|
|
267
|
+
? [zone.polygon, ...(zone.holes ?? [])].reduce((sum, ring) => sum + ringPerimeter(ring), 0)
|
|
268
|
+
: 0
|
|
269
|
+
const { height } = roomInsideHeight(room, nodes)
|
|
270
|
+
const stats = [
|
|
271
|
+
{ label: 'Area', value: formatAreaLabel(room.area, unit, 2) },
|
|
272
|
+
{ label: 'Perimeter', value: formatLinearMeasurement(perimeter, unit, metricNotation) },
|
|
273
|
+
{
|
|
274
|
+
label: 'Height',
|
|
275
|
+
value: height === null ? '—' : formatLinearMeasurement(height, unit, metricNotation),
|
|
276
|
+
},
|
|
277
|
+
]
|
|
278
|
+
return (
|
|
279
|
+
<div
|
|
280
|
+
className="mt-3 grid grid-cols-3 divide-x divide-border/50 rounded-lg border border-border/50"
|
|
281
|
+
data-room-stats
|
|
282
|
+
>
|
|
283
|
+
{stats.map((stat) => (
|
|
284
|
+
<div className="flex min-w-0 flex-col gap-0.5 px-2.5 py-2" key={stat.label}>
|
|
285
|
+
<span className="text-[11px] text-muted-foreground">{stat.label}</span>
|
|
286
|
+
<span className="truncate font-medium font-mono text-[13px] text-foreground tabular-nums">
|
|
287
|
+
{stat.value}
|
|
288
|
+
</span>
|
|
289
|
+
</div>
|
|
290
|
+
))}
|
|
291
|
+
</div>
|
|
292
|
+
)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* A mezzanine is built the same way, adapted: its own floor at its height
|
|
297
|
+
* (editable within what core accepts), stairs up to it, and the ceiling over
|
|
298
|
+
* it when there is headroom. No walls of its own, so no walls row.
|
|
299
|
+
*/
|
|
300
|
+
function MezzanineRows({ room }: { room: RoomSelectionRecord }) {
|
|
301
|
+
const zoneId = room.zoneId
|
|
302
|
+
const nodes = useScene((scene) => scene.nodes)
|
|
303
|
+
const unit = useViewer((s) => s.unit)
|
|
304
|
+
const metricNotation = useViewer((s) => s.metricNotation)
|
|
305
|
+
const [expanded, setExpanded] = useState<'floor' | 'ceiling' | null>(null)
|
|
306
|
+
const [stairMessage, setStairMessage] = useState<string | null>(null)
|
|
307
|
+
const length = (meters: number) => formatLinearMeasurement(meters, unit, metricNotation)
|
|
308
|
+
const elevation = roomRelativeFloorHeight(nodes, zoneId)
|
|
309
|
+
const base = roomFloorBase(nodes, zoneId)
|
|
310
|
+
const absoluteBounds = mezzanineElevationBounds(nodes, zoneId)
|
|
311
|
+
const bounds = absoluteBounds && {
|
|
312
|
+
min: absoluteBounds.min - base,
|
|
313
|
+
max: absoluteBounds.max - base,
|
|
314
|
+
}
|
|
315
|
+
const hasCeiling = Object.values(nodes).some(
|
|
316
|
+
(node) => node.type === 'ceiling' && node.zoneId === zoneId,
|
|
317
|
+
)
|
|
318
|
+
const { ceiling } = roomInsideHeight(room, nodes)
|
|
319
|
+
const toggle = (part: 'floor' | 'ceiling') => () =>
|
|
320
|
+
setExpanded((current) => (current === part ? null : part))
|
|
321
|
+
return (
|
|
322
|
+
<div className="flex flex-col" data-mezzanine-rows={zoneId}>
|
|
323
|
+
<h3 className="mt-4 mb-1.5 font-medium text-muted-foreground text-xs">Built with</h3>
|
|
324
|
+
<div className="flex flex-col gap-1">
|
|
325
|
+
<ConstructionPart
|
|
326
|
+
expanded={expanded === 'floor'}
|
|
327
|
+
icon="/icons/floor.webp"
|
|
328
|
+
label="Floor"
|
|
329
|
+
meta={`${length(elevation)} up`}
|
|
330
|
+
on
|
|
331
|
+
onToggleExpanded={toggle('floor')}
|
|
332
|
+
part="floor"
|
|
333
|
+
>
|
|
334
|
+
<FloorHeightField bounds={bounds} zoneId={zoneId} />
|
|
335
|
+
<RoomOpeningsField surface="floor" zoneId={zoneId} />
|
|
336
|
+
</ConstructionPart>
|
|
337
|
+
<ConstructionPart
|
|
338
|
+
control={
|
|
339
|
+
<button
|
|
340
|
+
className={pill}
|
|
341
|
+
data-add-mezzanine-stairs
|
|
342
|
+
onClick={() => {
|
|
343
|
+
const result = addMezzanineStairs(zoneId)
|
|
344
|
+
setStairMessage(result.ok ? null : result.message)
|
|
345
|
+
}}
|
|
346
|
+
type="button"
|
|
347
|
+
>
|
|
348
|
+
Add stairs
|
|
349
|
+
</button>
|
|
350
|
+
}
|
|
351
|
+
expanded={false}
|
|
352
|
+
icon="/icons/stairs.webp"
|
|
353
|
+
label="Stairs"
|
|
354
|
+
meta="Up from the floor below"
|
|
355
|
+
on
|
|
356
|
+
onToggleExpanded={() => {}}
|
|
357
|
+
part="stairs"
|
|
358
|
+
/>
|
|
359
|
+
<ConstructionPart
|
|
360
|
+
expanded={expanded === 'ceiling'}
|
|
361
|
+
icon="/icons/ceiling.webp"
|
|
362
|
+
label="Ceiling"
|
|
363
|
+
meta={
|
|
364
|
+
hasCeiling
|
|
365
|
+
? ceiling === null
|
|
366
|
+
? 'Over the mezzanine'
|
|
367
|
+
: `${length(ceiling)} headroom`
|
|
368
|
+
: 'Not enough headroom'
|
|
369
|
+
}
|
|
370
|
+
on
|
|
371
|
+
onToggleExpanded={toggle('ceiling')}
|
|
372
|
+
part="ceiling"
|
|
373
|
+
>
|
|
374
|
+
{hasCeiling ? (
|
|
375
|
+
<PartField label="Shape">
|
|
376
|
+
<button className={pill} onClick={() => startCeilingEdit(zoneId)} type="button">
|
|
377
|
+
Edit shape
|
|
378
|
+
</button>
|
|
379
|
+
</PartField>
|
|
380
|
+
) : undefined}
|
|
381
|
+
</ConstructionPart>
|
|
382
|
+
</div>
|
|
383
|
+
{stairMessage && (
|
|
384
|
+
<span className="mt-2 text-destructive text-xs" role="status">
|
|
385
|
+
{stairMessage}
|
|
386
|
+
</span>
|
|
387
|
+
)}
|
|
388
|
+
</div>
|
|
389
|
+
)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* The room's colour, as the scene graph shows and edits it: the plan fill and
|
|
394
|
+
* the label tint, never a finish (finishes are painted).
|
|
395
|
+
*/
|
|
396
|
+
function RoomColorDot({ zoneId }: { zoneId: string }) {
|
|
397
|
+
const color = useScene((state) => {
|
|
398
|
+
const zone = state.nodes[zoneId as AnyNodeId]
|
|
399
|
+
return zone?.type === 'zone' ? zone.color : undefined
|
|
400
|
+
})
|
|
401
|
+
return (
|
|
402
|
+
<ColorDot
|
|
403
|
+
color={color ?? '#3b82f6'}
|
|
404
|
+
label="Room colour — used in the plan and labels"
|
|
405
|
+
onChange={(next) => useScene.getState().updateNode(zoneId as AnyNodeId, { color: next })}
|
|
406
|
+
side="bottom"
|
|
407
|
+
/>
|
|
408
|
+
)
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* The name, edited in place: it reads as the panel's heading until focused.
|
|
413
|
+
* Focusing opens every suggestion — the current name is not a filter until
|
|
414
|
+
* the user types — with the caret at the end so the name can be fine-tuned.
|
|
415
|
+
* Picking a name (click or Enter) writes it as one rename — numbered when
|
|
416
|
+
* another room on the level has it ("Bedroom 2") — and lets go of the field;
|
|
417
|
+
* Escape reverts.
|
|
418
|
+
*/
|
|
419
|
+
function RoomName({
|
|
420
|
+
zoneId,
|
|
421
|
+
name,
|
|
422
|
+
sections,
|
|
423
|
+
mezzanine,
|
|
424
|
+
}: {
|
|
425
|
+
zoneId: string
|
|
426
|
+
name: string
|
|
427
|
+
sections: readonly RoomNameSection[]
|
|
428
|
+
mezzanine: boolean
|
|
429
|
+
}) {
|
|
430
|
+
const nodes = useScene((state) => state.nodes)
|
|
431
|
+
const [value, setValue] = useState(name)
|
|
432
|
+
const [open, setOpen] = useState(false)
|
|
433
|
+
const [typing, setTyping] = useState(false)
|
|
434
|
+
const [active, setActive] = useState(-1)
|
|
435
|
+
const valueRef = useRef(name)
|
|
436
|
+
// Enter/click commit and then blur, and blur commits too; this keeps it one rename.
|
|
437
|
+
const committedRef = useRef(name)
|
|
438
|
+
const originalRef = useRef(name)
|
|
439
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
440
|
+
const listId = useId()
|
|
441
|
+
const { typed: typedRow, groups, options } = roomNameOptions(value, sections, typing)
|
|
442
|
+
const expanded = open && options.length > 0
|
|
443
|
+
const activeId = expanded && options[active] ? `${listId}-${active}` : undefined
|
|
444
|
+
const current = baseRoomName(value).toLowerCase()
|
|
445
|
+
|
|
446
|
+
// A rename from elsewhere (undo, a collaborator, the plan) lands in the field
|
|
447
|
+
// unless the user is editing it (the field stays mounted across renames).
|
|
448
|
+
useEffect(() => {
|
|
449
|
+
if (document.activeElement === inputRef.current) return
|
|
450
|
+
valueRef.current = name
|
|
451
|
+
committedRef.current = name
|
|
452
|
+
setValue(name)
|
|
453
|
+
}, [name])
|
|
454
|
+
|
|
455
|
+
// Opening and the arrow keys bring the active row into view; the pointer
|
|
456
|
+
// only highlights (scrolling under it would move the row being clicked).
|
|
457
|
+
const scrollToActive = useRef(false)
|
|
458
|
+
useEffect(() => {
|
|
459
|
+
if (!(activeId && scrollToActive.current)) return
|
|
460
|
+
scrollToActive.current = false
|
|
461
|
+
// The list mounts in the same commit that opens it: wait a frame so the
|
|
462
|
+
// current name, lower in the list, is scrolled to on open.
|
|
463
|
+
const frame = requestAnimationFrame(() =>
|
|
464
|
+
document.getElementById(activeId)?.scrollIntoView({ block: 'nearest' }),
|
|
465
|
+
)
|
|
466
|
+
return () => cancelAnimationFrame(frame)
|
|
467
|
+
}, [activeId])
|
|
468
|
+
|
|
469
|
+
const caretToEnd = () => {
|
|
470
|
+
const input = inputRef.current
|
|
471
|
+
if (!input) return
|
|
472
|
+
const end = input.value.length
|
|
473
|
+
input.setSelectionRange(end, end)
|
|
474
|
+
}
|
|
475
|
+
const showAll = () => {
|
|
476
|
+
setTyping(false)
|
|
477
|
+
setOpen(true)
|
|
478
|
+
const all = roomNameOptions(valueRef.current, sections, false).options
|
|
479
|
+
const here = baseRoomName(valueRef.current).toLowerCase()
|
|
480
|
+
scrollToActive.current = true
|
|
481
|
+
setActive(Math.max(0, all.findIndex((option) => option.value.toLowerCase() === here)))
|
|
482
|
+
}
|
|
483
|
+
const type = (next: string) => {
|
|
484
|
+
valueRef.current = next
|
|
485
|
+
setValue(next)
|
|
486
|
+
}
|
|
487
|
+
const commit = () => {
|
|
488
|
+
const next = valueRef.current
|
|
489
|
+
if (next === committedRef.current) return
|
|
490
|
+
committedRef.current = next
|
|
491
|
+
renameRoom(zoneId, next)
|
|
492
|
+
}
|
|
493
|
+
const pick = (option: RoomNameOption) => {
|
|
494
|
+
const next =
|
|
495
|
+
option.kind === 'typed' ? option.value : numberedRoomName(nodes, zoneId, option.value)
|
|
496
|
+
type(next)
|
|
497
|
+
commit()
|
|
498
|
+
setOpen(false)
|
|
499
|
+
setTyping(false)
|
|
500
|
+
// A pick is the answer: the field lets go. To fine-tune the name, click it
|
|
501
|
+
// again — the caret lands at the end.
|
|
502
|
+
inputRef.current?.blur()
|
|
503
|
+
}
|
|
504
|
+
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
|
505
|
+
const count = options.length
|
|
506
|
+
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
507
|
+
event.preventDefault()
|
|
508
|
+
event.stopPropagation()
|
|
509
|
+
if (!expanded) {
|
|
510
|
+
showAll()
|
|
511
|
+
return
|
|
512
|
+
}
|
|
513
|
+
if (!count) return
|
|
514
|
+
const down = event.key === 'ArrowDown'
|
|
515
|
+
scrollToActive.current = true
|
|
516
|
+
setActive((index) => (down ? (index + 1) % count : index <= 0 ? count - 1 : index - 1))
|
|
517
|
+
} else if (event.key === 'Enter') {
|
|
518
|
+
event.preventDefault()
|
|
519
|
+
event.stopPropagation()
|
|
520
|
+
const chosen = expanded ? options[active] : undefined
|
|
521
|
+
if (chosen) pick(chosen)
|
|
522
|
+
else inputRef.current?.blur()
|
|
523
|
+
} else if (event.key === 'Escape') {
|
|
524
|
+
event.preventDefault()
|
|
525
|
+
event.stopPropagation()
|
|
526
|
+
if (expanded) {
|
|
527
|
+
type(originalRef.current)
|
|
528
|
+
setOpen(false)
|
|
529
|
+
setTyping(false)
|
|
530
|
+
requestAnimationFrame(() => inputRef.current?.select())
|
|
531
|
+
} else inputRef.current?.blur()
|
|
532
|
+
} else if (event.key === 'Tab') setOpen(false)
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const renderOption = (option: RoomNameOption, index: number) => {
|
|
536
|
+
const isCurrent = option.kind === 'suggestion' && option.value.toLowerCase() === current
|
|
537
|
+
const used = option.kind === 'suggestion' ? roomNameUseCount(nodes, zoneId, option.value) : 0
|
|
538
|
+
return (
|
|
539
|
+
<div
|
|
540
|
+
aria-selected={index === active}
|
|
541
|
+
className={cn(
|
|
542
|
+
'flex cursor-default select-none items-center gap-2 rounded-md px-2.5 py-1.5 text-[13px] text-foreground',
|
|
543
|
+
index === active && 'bg-accent',
|
|
544
|
+
option.kind === 'typed' && 'mb-0.5 rounded-b-none border-border border-b',
|
|
545
|
+
)}
|
|
546
|
+
data-room-name-option={option.value}
|
|
547
|
+
id={`${listId}-${index}`}
|
|
548
|
+
key={`${option.kind}:${index}:${option.value}`}
|
|
549
|
+
role="option"
|
|
550
|
+
tabIndex={-1}
|
|
551
|
+
onClick={() => pick(option)}
|
|
552
|
+
onMouseDown={(event) => event.preventDefault()}
|
|
553
|
+
onMouseMove={() => setActive(index)}
|
|
554
|
+
>
|
|
555
|
+
{option.kind === 'typed' && <Plus aria-hidden className="size-3.5 shrink-0" />}
|
|
556
|
+
<RoomNameOptionLabel option={option} />
|
|
557
|
+
{isCurrent ? (
|
|
558
|
+
<Check aria-hidden className="size-3.5 shrink-0" />
|
|
559
|
+
) : used > 0 ? (
|
|
560
|
+
<span
|
|
561
|
+
className="shrink-0 whitespace-nowrap font-mono text-[11px] text-muted-foreground"
|
|
562
|
+
title="Picking it adds a number"
|
|
563
|
+
>
|
|
564
|
+
{used} on this level
|
|
565
|
+
</span>
|
|
566
|
+
) : null}
|
|
567
|
+
</div>
|
|
568
|
+
)
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
return (
|
|
572
|
+
<Popover open={expanded} onOpenChange={setOpen}>
|
|
573
|
+
<PopoverAnchor asChild>
|
|
574
|
+
{/* A stable hook for host-app onboarding to point at; nothing here reads it. */}
|
|
575
|
+
<div
|
|
576
|
+
className={cn(
|
|
577
|
+
'-mx-1.5 flex min-w-0 flex-1 cursor-text items-center gap-1 rounded-md border border-transparent py-0.5 pr-0.5 pl-1.5 transition-colors hover:bg-accent/50',
|
|
578
|
+
(expanded || open) && 'border-ring/60 bg-accent/40',
|
|
579
|
+
'focus-within:border-ring/60 focus-within:bg-accent/40',
|
|
580
|
+
)}
|
|
581
|
+
data-guide-target="room-name"
|
|
582
|
+
data-room-name-combobox
|
|
583
|
+
title={name.trim() ? `${name.trim()} · Rename` : 'Rename'}
|
|
584
|
+
>
|
|
585
|
+
<input
|
|
586
|
+
aria-activedescendant={activeId}
|
|
587
|
+
aria-autocomplete="list"
|
|
588
|
+
aria-controls={expanded ? listId : undefined}
|
|
589
|
+
aria-expanded={expanded}
|
|
590
|
+
aria-label={mezzanine ? 'Mezzanine name' : 'Room name'}
|
|
591
|
+
autoComplete="off"
|
|
592
|
+
className="min-w-0 flex-1 text-ellipsis bg-transparent p-0 font-semibold text-foreground text-sm leading-snug tracking-tight outline-none placeholder:text-foreground"
|
|
593
|
+
data-room-name
|
|
594
|
+
maxLength={40}
|
|
595
|
+
placeholder={mezzanine ? 'Mezzanine' : 'Room'}
|
|
596
|
+
ref={inputRef}
|
|
597
|
+
role="combobox"
|
|
598
|
+
spellCheck={false}
|
|
599
|
+
type="text"
|
|
600
|
+
value={value}
|
|
601
|
+
onBlur={() => {
|
|
602
|
+
setOpen(false)
|
|
603
|
+
setTyping(false)
|
|
604
|
+
commit()
|
|
605
|
+
}}
|
|
606
|
+
onChange={(event) => {
|
|
607
|
+
type(event.target.value)
|
|
608
|
+
setTyping(true)
|
|
609
|
+
setOpen(true)
|
|
610
|
+
const next = roomNameOptions(event.target.value, sections, true)
|
|
611
|
+
scrollToActive.current = true
|
|
612
|
+
setActive(next.typed && next.options.length > 1 ? 1 : 0)
|
|
613
|
+
}}
|
|
614
|
+
onFocus={() => {
|
|
615
|
+
originalRef.current = valueRef.current
|
|
616
|
+
showAll()
|
|
617
|
+
}}
|
|
618
|
+
onKeyDown={onKeyDown}
|
|
619
|
+
onMouseUp={(event) => {
|
|
620
|
+
// The click that focused the field lands the caret at the end;
|
|
621
|
+
// a click in an already-focused field reopens the whole list.
|
|
622
|
+
if (event.detail === 1 && !open) showAll()
|
|
623
|
+
if (!typing) caretToEnd()
|
|
624
|
+
}}
|
|
625
|
+
/>
|
|
626
|
+
<button
|
|
627
|
+
aria-label={mezzanine ? 'Show mezzanine names' : 'Show room names'}
|
|
628
|
+
className="grid size-6 shrink-0 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
|
629
|
+
data-room-name-open
|
|
630
|
+
tabIndex={-1}
|
|
631
|
+
type="button"
|
|
632
|
+
onMouseDown={(event) => {
|
|
633
|
+
event.preventDefault()
|
|
634
|
+
const input = inputRef.current
|
|
635
|
+
if (!input) return
|
|
636
|
+
if (expanded) setOpen(false)
|
|
637
|
+
else if (document.activeElement === input) showAll()
|
|
638
|
+
else input.focus()
|
|
639
|
+
}}
|
|
640
|
+
>
|
|
641
|
+
<ChevronDown
|
|
642
|
+
aria-hidden
|
|
643
|
+
className={cn('size-3 transition-transform', expanded && 'rotate-180')}
|
|
644
|
+
/>
|
|
645
|
+
</button>
|
|
646
|
+
</div>
|
|
647
|
+
</PopoverAnchor>
|
|
648
|
+
<PopoverContent
|
|
649
|
+
align="start"
|
|
650
|
+
aria-label={mezzanine ? 'Mezzanine names' : 'Room names'}
|
|
651
|
+
className="flex max-h-85 w-(--radix-popover-trigger-width) min-w-60 flex-col overflow-hidden p-0"
|
|
652
|
+
id={listId}
|
|
653
|
+
role="listbox"
|
|
654
|
+
onCloseAutoFocus={(event) => event.preventDefault()}
|
|
655
|
+
// Escape belongs to the field: it reverts the name (the popover's own
|
|
656
|
+
// dismiss would close the list first and turn it into a commit).
|
|
657
|
+
onEscapeKeyDown={(event) => event.preventDefault()}
|
|
658
|
+
onInteractOutside={(event) => {
|
|
659
|
+
if (event.target instanceof Node && inputRef.current?.parentElement?.contains(event.target)) {
|
|
660
|
+
event.preventDefault()
|
|
661
|
+
}
|
|
662
|
+
}}
|
|
663
|
+
onOpenAutoFocus={(event) => event.preventDefault()}
|
|
664
|
+
// React events bubble out of the portal into the panel header, whose
|
|
665
|
+
// pointerdown starts a panel drag and captures the pointer — the
|
|
666
|
+
// release would then land on the header instead of the picked row.
|
|
667
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
668
|
+
>
|
|
669
|
+
<div className="no-scrollbar min-h-0 flex-1 overflow-y-auto px-1.5 pt-1.5 pb-1">
|
|
670
|
+
{typedRow && renderOption(typedRow, 0)}
|
|
671
|
+
{groups.map((group) => {
|
|
672
|
+
const first = options.indexOf(group.options[0]!)
|
|
673
|
+
return (
|
|
674
|
+
<div aria-labelledby={`${listId}-${group.id}`} key={group.id} role="group">
|
|
675
|
+
<div
|
|
676
|
+
className="px-2.5 pt-2.5 pb-1 text-[11px] text-muted-foreground"
|
|
677
|
+
id={`${listId}-${group.id}`}
|
|
678
|
+
role="presentation"
|
|
679
|
+
>
|
|
680
|
+
{group.title}
|
|
681
|
+
</div>
|
|
682
|
+
{group.options.map((option, offset) => renderOption(option, first + offset))}
|
|
683
|
+
</div>
|
|
684
|
+
)
|
|
685
|
+
})}
|
|
686
|
+
</div>
|
|
687
|
+
<div className="flex shrink-0 flex-wrap gap-x-3 gap-y-1 border-border border-t px-3 py-2 text-[11px] text-muted-foreground">
|
|
688
|
+
{(
|
|
689
|
+
[
|
|
690
|
+
[['↑', '↓'], 'Browse'],
|
|
691
|
+
[['Enter'], 'Pick'],
|
|
692
|
+
[['Esc'], 'Revert'],
|
|
693
|
+
] as const
|
|
694
|
+
).map(([keys, action]) => (
|
|
695
|
+
<span className="flex items-center gap-1" key={action}>
|
|
696
|
+
{keys.map((key) => (
|
|
697
|
+
<ShortcutToken className="h-4 min-w-4 justify-center px-1 text-[9.5px]" key={key} value={key} />
|
|
698
|
+
))}
|
|
699
|
+
{action}
|
|
700
|
+
</span>
|
|
701
|
+
))}
|
|
702
|
+
</div>
|
|
703
|
+
</PopoverContent>
|
|
704
|
+
</Popover>
|
|
705
|
+
)
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function RoomNameOptionLabel({ option }: { option: RoomNameOption }) {
|
|
709
|
+
if (option.kind === 'typed') {
|
|
710
|
+
return (
|
|
711
|
+
<span className="min-w-0 flex-1 truncate">
|
|
712
|
+
<span className="text-muted-foreground">Use </span>“{option.value}”
|
|
713
|
+
</span>
|
|
714
|
+
)
|
|
715
|
+
}
|
|
716
|
+
const { value, match } = option
|
|
717
|
+
if (!match) return <span className="min-w-0 flex-1 truncate">{value}</span>
|
|
718
|
+
return (
|
|
719
|
+
<span className="min-w-0 flex-1 truncate">
|
|
720
|
+
{value.slice(0, match.start)}
|
|
721
|
+
<span className="font-semibold underline decoration-muted-foreground underline-offset-3">{value.slice(match.start, match.end)}</span>
|
|
722
|
+
{value.slice(match.end)}
|
|
723
|
+
</span>
|
|
724
|
+
)
|
|
725
|
+
}
|