@pascal-app/editor 1.0.1 → 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 +16 -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 +221 -128
- 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 +37 -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 +536 -487
- package/src/components/editor/footprint-height-handle.tsx +204 -0
- package/src/components/editor/grid.tsx +31 -7
- package/src/components/editor/group-actions.ts +59 -11
- package/src/components/editor/group-floating-action-menu.tsx +19 -9
- package/src/components/editor/group-move-3d.ts +4 -6
- package/src/components/editor/group-rotate-handle.tsx +81 -35
- package/src/components/editor/group-selection-box-3d.tsx +25 -17
- package/src/components/editor/group-transform-shared.test.ts +126 -1
- package/src/components/editor/group-transform-shared.ts +79 -73
- package/src/components/editor/handles/handle-arrow.tsx +72 -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 +272 -131
- package/src/components/editor/index.tsx +316 -176
- package/src/components/editor/join-walls-pill.tsx +44 -0
- package/src/components/editor/node-action-menu.tsx +70 -70
- package/src/components/editor/node-arrow-handles.tsx +167 -14
- 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 +132 -0
- 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 +114 -163
- package/src/components/editor/wall-opening-highlights.tsx +2 -3
- 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-group-move.tsx +17 -24
- 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-registered-tool-layer.tsx +33 -12
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +11 -1
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +242 -126
- 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-measurement.tsx +166 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +4 -1
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +87 -8
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +355 -245
- 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 +31 -66
- package/src/components/tools/__fixtures__/hosting.ts +10 -0
- package/src/components/tools/item/item-surface-placement.test.ts +3 -9
- package/src/components/tools/item/item-surface-strategy-resolver.test.ts +3 -9
- package/src/components/tools/item/placement-history.test.tsx +474 -0
- package/src/components/tools/item/placement-strategies.ts +63 -47
- package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +3 -3
- package/src/components/tools/item/use-draft-node.test.tsx +432 -1
- package/src/components/tools/item/use-draft-node.ts +207 -97
- package/src/components/tools/item/use-placement-coordinator.tsx +251 -111
- 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/item-surface-move.test.ts +10 -2
- package/src/components/tools/registry/item-surface-move.ts +46 -24
- package/src/components/tools/registry/item-surface-resolver.test.ts +2 -2
- package/src/components/tools/registry/move-registry-node-tool.tsx +26 -10
- 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/affordance-dispatch.test.ts +24 -0
- package/src/components/tools/shared/affordance-dispatch.ts +13 -0
- package/src/components/tools/shared/cursor-sphere.tsx +36 -12
- package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
- package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
- package/src/components/tools/shared/pointer-support-cap.ts +17 -5
- package/src/components/tools/shared/surface-rejection.test.tsx +5 -4
- package/src/components/tools/shared/surface-rejection.tsx +14 -0
- 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 +82 -23
- 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 +101 -91
- package/src/components/ui/controls/material-picker.tsx +8 -67
- 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/controls/slider-control.tsx +76 -31
- 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 +348 -19
- package/src/components/ui/helpers/item-helper.tsx +8 -0
- package/src/components/ui/helpers/registered-tool-helper.tsx +10 -33
- package/src/components/ui/icon-ref.tsx +45 -1
- package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
- 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-height-mode.tsx +27 -21
- 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 +7 -19
- 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/shortcut-token.tsx +4 -0
- 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/audio-settings-dialog.tsx +2 -2
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +615 -372
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +7 -7
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +1 -1
- 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-visibility.test.ts +35 -0
- package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
- package/src/components/viewer/floorplan-preview.tsx +6 -18
- 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 +9 -9
- package/src/hooks/use-auto-save.integration.test.tsx +311 -0
- package/src/hooks/use-auto-save.ts +55 -14
- package/src/hooks/use-ceiling-events.test.ts +1 -0
- package/src/hooks/use-ceiling-events.ts +2 -0
- package/src/hooks/use-grid-events.test.ts +2 -0
- package/src/hooks/use-grid-events.ts +16 -11
- package/src/hooks/use-keyboard.test.ts +182 -0
- package/src/hooks/use-keyboard.ts +122 -91
- 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 +306 -39
- package/src/lib/catalog-panel-model.ts +39 -0
- package/src/lib/catalog-selection.test.ts +27 -0
- package/src/lib/catalog-selection.ts +8 -0
- 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/contextual-help.test.ts +27 -1
- package/src/lib/contextual-help.ts +16 -9
- 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 +15 -9
- package/src/lib/floorplan/floorplan-export.tsx +32 -8
- package/src/lib/floorplan/floorplan-extension.ts +48 -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 +191 -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 +544 -16
- package/src/lib/glb-export.ts +256 -125
- package/src/lib/grid-frame.test.ts +19 -0
- package/src/lib/grid-frame.ts +18 -0
- 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 +27 -1
- package/src/lib/keyboard-pan.test.ts +184 -0
- package/src/lib/keyboard-pan.ts +238 -0
- 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 +74 -0
- package/src/lib/material-paint-panel-model.ts +80 -0
- 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/overlay-transform.test.ts +28 -0
- package/src/lib/overlay-transform.ts +12 -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 +96 -0
- package/src/lib/panel-tool-options.test.ts +34 -0
- package/src/lib/panel-tool-options.ts +46 -0
- package/src/lib/parametric-node-update.ts +37 -0
- 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 +87 -0
- package/src/lib/scene.ts +39 -45
- 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 +17 -2
- package/src/lib/snapping-mode.ts +19 -7
- package/src/lib/spatial-drag-plane.test.ts +53 -0
- package/src/lib/spatial-drag-plane.ts +19 -0
- package/src/lib/spatial-pointer-input.test.ts +72 -0
- package/src/lib/spatial-pointer-input.ts +62 -0
- package/src/lib/spatial-snap.test.ts +47 -0
- package/src/lib/terrace-draft.test.ts +145 -0
- package/src/lib/terrace-draft.ts +213 -0
- package/src/lib/terrain-panel-model.ts +117 -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/world-grid-snap.test.ts +48 -0
- package/src/lib/world-grid-snap.ts +9 -5
- 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/rotation-snapping.test.ts +53 -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 +442 -323
- package/src/store/use-floorplan-draft-preview.ts +23 -1
- package/src/store/use-floorplan-marquee.ts +12 -2
- package/src/store/use-interaction-scope.ts +155 -6
- 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,847 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type AnyNode,
|
|
5
|
+
type AnyNodeId,
|
|
6
|
+
DEFAULT_LEVEL_HEIGHT,
|
|
7
|
+
deleteZone,
|
|
8
|
+
emitter,
|
|
9
|
+
type Point,
|
|
10
|
+
type StructureNodes,
|
|
11
|
+
sceneRegistry,
|
|
12
|
+
useScene,
|
|
13
|
+
} from '@pascal-app/core'
|
|
14
|
+
import { getSceneTheme, setSurfaceRaycastLayers, useViewer } from '@pascal-app/viewer'
|
|
15
|
+
import { Html } from '@react-three/drei'
|
|
16
|
+
import { useFrame, useThree } from '@react-three/fiber'
|
|
17
|
+
import { SquareSplitHorizontal } from 'lucide-react'
|
|
18
|
+
import Image from 'next/image'
|
|
19
|
+
import { type MouseEvent, type RefObject, useEffect, useMemo, useRef, useState } from 'react'
|
|
20
|
+
import { createPortal } from 'react-dom'
|
|
21
|
+
import {
|
|
22
|
+
type Camera,
|
|
23
|
+
DoubleSide,
|
|
24
|
+
type Group,
|
|
25
|
+
Matrix4,
|
|
26
|
+
type Mesh,
|
|
27
|
+
type Object3D,
|
|
28
|
+
type OrthographicCamera,
|
|
29
|
+
type PerspectiveCamera,
|
|
30
|
+
Raycaster,
|
|
31
|
+
Vector2,
|
|
32
|
+
Vector3,
|
|
33
|
+
} from 'three'
|
|
34
|
+
import { markToolCancelConsumed } from '../../hooks/use-keyboard'
|
|
35
|
+
import { getRoomSelectionIndex } from '../../hooks/use-selected-room'
|
|
36
|
+
import { EDITOR_LAYER } from '../../lib/constants'
|
|
37
|
+
import { getFloatingMenuScale } from '../../lib/floating-menu-scale'
|
|
38
|
+
import { clientToPlan } from '../../lib/floorplan/plan-coords'
|
|
39
|
+
import { formatLinearMeasurement } from '../../lib/measurements'
|
|
40
|
+
import { addMezzanineStairs } from '../../lib/mezzanine-stairs'
|
|
41
|
+
import {
|
|
42
|
+
cancelRoomDivide,
|
|
43
|
+
clickRoomDivide,
|
|
44
|
+
finishRoomDivide,
|
|
45
|
+
previewRoomDivide,
|
|
46
|
+
removeLastRoomDividePoint,
|
|
47
|
+
roomDivideContains,
|
|
48
|
+
startRoomDivide,
|
|
49
|
+
} from '../../lib/room-divide-session'
|
|
50
|
+
import { roomFloorElevation, useRoomHandleDrag } from '../../lib/room-handle-drag'
|
|
51
|
+
import type { RoomSelectionRecord } from '../../lib/room-selection'
|
|
52
|
+
import { requestRoomDeletion } from '../../lib/room-structure-commands'
|
|
53
|
+
import {
|
|
54
|
+
type RoomTransformKind,
|
|
55
|
+
rotateRoom,
|
|
56
|
+
showRoomNotice,
|
|
57
|
+
startRoomTransform,
|
|
58
|
+
useRoomTransform,
|
|
59
|
+
} from '../../lib/room-transform-session'
|
|
60
|
+
import { isTypingTarget } from '../../lib/typing-target'
|
|
61
|
+
import useEditor from '../../store/use-editor'
|
|
62
|
+
import useInteractionScope from '../../store/use-interaction-scope'
|
|
63
|
+
import { isViewVisible, VIEW_2D } from '../../store/view-layout'
|
|
64
|
+
import { useFloorplanRender } from '../editor-2d/floorplan-render-context'
|
|
65
|
+
import { CursorSphere } from '../tools/shared/cursor-sphere'
|
|
66
|
+
import {
|
|
67
|
+
DRAFT_LABEL_Y_OFFSET,
|
|
68
|
+
DraftMeasurementLabel,
|
|
69
|
+
} from '../tools/shared/draft-measurement-label'
|
|
70
|
+
import { ActionMenuButton } from './action-menu-button'
|
|
71
|
+
import { getMenuYOffset } from './floating-action-menu'
|
|
72
|
+
import { NodeActionMenu } from './node-action-menu'
|
|
73
|
+
import { RoomFloorHighlight3D } from './room-floor-highlight'
|
|
74
|
+
import { RoomHandleDragPreview3D, RoomHandles3D } from './room-handles'
|
|
75
|
+
import { RoomFloorHeightStepper, useRoomControls } from './room-handles-2d'
|
|
76
|
+
import { RoomTransformGhost2D, RoomTransformGhost3D } from './room-transform-ghost'
|
|
77
|
+
|
|
78
|
+
// The wall draft's colours: indigo while the draft is buildable, red when not.
|
|
79
|
+
const DRAFT_COLOR = '#818cf8'
|
|
80
|
+
const DRAFT_COLOR_LIGHT_PLAN = '#6366f1'
|
|
81
|
+
const INVALID_COLOR = '#ef4444'
|
|
82
|
+
|
|
83
|
+
type RoomDivideScope = Extract<
|
|
84
|
+
ReturnType<typeof useInteractionScope.getState>['scope'],
|
|
85
|
+
{ kind: 'room-divide' }
|
|
86
|
+
>
|
|
87
|
+
|
|
88
|
+
function useSceneIsDark() {
|
|
89
|
+
return useViewer((s) => getSceneTheme(s.sceneTheme).appearance === 'dark')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const stopPointer = (event: { stopPropagation: () => void }) => event.stopPropagation()
|
|
93
|
+
|
|
94
|
+
/** What a room pick-up carries: its outline and wall footprints, and its floor height. */
|
|
95
|
+
export function roomTransformSource(room: RoomSelectionRecord) {
|
|
96
|
+
const walls = room.boundaryWallIds.flatMap((id) => {
|
|
97
|
+
const ring = room.context.wallFootprints.get(id)
|
|
98
|
+
return ring ? [ring.map(([x, z]) => [x, z] as Point)] : []
|
|
99
|
+
})
|
|
100
|
+
return {
|
|
101
|
+
zoneId: room.zoneId,
|
|
102
|
+
levelId: room.key.levelId,
|
|
103
|
+
outline: [room.polygon, ...room.holes].map((ring) => ring.map(([x, z]) => [x, z] as Point)),
|
|
104
|
+
walls,
|
|
105
|
+
floorY: roomFloorElevation(useScene.getState().nodes, room.zoneId),
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A room's zone picked on its own (its label, the zones layer) moves and
|
|
111
|
+
* duplicates as the room: the room pick-up with its real outline and walls,
|
|
112
|
+
* never a zone-only drag whose preview is the zone's bounding box. False when
|
|
113
|
+
* the zone bounds no room (a free-drawn zone keeps its own move).
|
|
114
|
+
*/
|
|
115
|
+
export function startZoneRoomTransform(node: AnyNode, kind: RoomTransformKind): boolean {
|
|
116
|
+
if (node.type !== 'zone' || !node.parentId) return false
|
|
117
|
+
const record = getRoomSelectionIndex(node.parentId)
|
|
118
|
+
.update(useScene.getState().nodes)
|
|
119
|
+
.find((room) => room.zoneId === node.id)
|
|
120
|
+
if (!record) return false
|
|
121
|
+
useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
|
|
122
|
+
useEditor.getState().selectRoom(record.key)
|
|
123
|
+
return startRoomTransform(kind, roomTransformSource(record))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Why the room's trash is off, or undefined when it can be deleted: a room
|
|
128
|
+
* inside walls other rooms share has nothing of its own to remove.
|
|
129
|
+
*/
|
|
130
|
+
export function roomDeleteBlockedReason(nodes: StructureNodes, zoneId: string) {
|
|
131
|
+
if (nodes[zoneId]?.type !== 'zone') return undefined
|
|
132
|
+
const plan = deleteZone(nodes, { zoneId, contents: 'keep' })
|
|
133
|
+
return plan.payload.mode === 'blocked' ? plan.conflicts?.[0]?.message : undefined
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The selected room's action pill — the node action menu every selection
|
|
138
|
+
* gets, in a wall pill's order: Move, Rotate left / right (the Curve slot), the room's own
|
|
139
|
+
* contribution where a wall shows Split (Divide), Duplicate,
|
|
140
|
+
* Delete.
|
|
141
|
+
*/
|
|
142
|
+
export function RoomActionMenu({ room }: { room: RoomSelectionRecord }) {
|
|
143
|
+
const zoneId = room.zoneId
|
|
144
|
+
const levelId = room.key.levelId
|
|
145
|
+
const nodes = useScene((s) => s.nodes)
|
|
146
|
+
const deleteBlocked = useMemo(() => roomDeleteBlockedReason(nodes, zoneId), [nodes, zoneId])
|
|
147
|
+
const pickUp = (kind: 'move' | 'duplicate') => (event: MouseEvent) => {
|
|
148
|
+
event.stopPropagation()
|
|
149
|
+
startRoomTransform(kind, roomTransformSource(room))
|
|
150
|
+
}
|
|
151
|
+
// Alt-click slides a door or window the turn would cut through.
|
|
152
|
+
const turn = (direction: 'left' | 'right') => (event: MouseEvent) => {
|
|
153
|
+
event.stopPropagation()
|
|
154
|
+
rotateRoom(room.key, direction, { force: event.altKey })
|
|
155
|
+
}
|
|
156
|
+
const notice = useRoomTransform((s) => (s.notice?.zoneId === zoneId ? s.notice.message : null))
|
|
157
|
+
// A mezzanine moves, turns and copies inside its host (core refuses what
|
|
158
|
+
// leaves it); it has no walls to divide, and it gets its own stairs.
|
|
159
|
+
const mezzanine = !!room.mezzanine
|
|
160
|
+
const addStairs = (event: MouseEvent) => {
|
|
161
|
+
event.stopPropagation()
|
|
162
|
+
const result = addMezzanineStairs(zoneId)
|
|
163
|
+
if (!result.ok) showRoomNotice({ zoneId, message: result.message })
|
|
164
|
+
}
|
|
165
|
+
return (
|
|
166
|
+
<div className="flex flex-col items-center gap-1">
|
|
167
|
+
<NodeActionMenu
|
|
168
|
+
deleteDisabledReason={deleteBlocked}
|
|
169
|
+
deleteLabel={mezzanine ? 'Delete mezzanine' : 'Delete room'}
|
|
170
|
+
onDelete={(event) => {
|
|
171
|
+
event.stopPropagation()
|
|
172
|
+
requestRoomDeletion(zoneId)
|
|
173
|
+
}}
|
|
174
|
+
onDuplicate={pickUp('duplicate')}
|
|
175
|
+
onMove={pickUp('move')}
|
|
176
|
+
onPointerDown={stopPointer}
|
|
177
|
+
onPointerUp={stopPointer}
|
|
178
|
+
onRotateLeft={turn('left')}
|
|
179
|
+
onRotateRight={turn('right')}
|
|
180
|
+
>
|
|
181
|
+
{mezzanine && (
|
|
182
|
+
<ActionMenuButton label="Add stairs" onClick={addStairs}>
|
|
183
|
+
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
|
|
184
|
+
<Image
|
|
185
|
+
alt=""
|
|
186
|
+
className="h-4 w-4 object-contain"
|
|
187
|
+
height={16}
|
|
188
|
+
src="/icons/stairs.webp"
|
|
189
|
+
width={16}
|
|
190
|
+
/>
|
|
191
|
+
</span>
|
|
192
|
+
</ActionMenuButton>
|
|
193
|
+
)}
|
|
194
|
+
{!mezzanine && (
|
|
195
|
+
<ActionMenuButton
|
|
196
|
+
label="Divide room"
|
|
197
|
+
onClick={(event: MouseEvent<HTMLButtonElement>) => {
|
|
198
|
+
event.stopPropagation()
|
|
199
|
+
startRoomDivide(zoneId, levelId)
|
|
200
|
+
}}
|
|
201
|
+
>
|
|
202
|
+
<SquareSplitHorizontal className="h-4 w-4" />
|
|
203
|
+
</ActionMenuButton>
|
|
204
|
+
)}
|
|
205
|
+
</NodeActionMenu>
|
|
206
|
+
{notice && (
|
|
207
|
+
<div
|
|
208
|
+
className="pointer-events-none whitespace-nowrap rounded-full border border-destructive/40 bg-background/95 px-3 py-1 text-destructive text-xs shadow-md backdrop-blur-md"
|
|
209
|
+
role="status"
|
|
210
|
+
>
|
|
211
|
+
{notice}
|
|
212
|
+
</div>
|
|
213
|
+
)}
|
|
214
|
+
</div>
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Floats the pill above the room like `FloatingActionMenu` floats a node's. */
|
|
219
|
+
function RoomFloatingMenu({
|
|
220
|
+
room,
|
|
221
|
+
zone,
|
|
222
|
+
height,
|
|
223
|
+
}: {
|
|
224
|
+
room: RoomSelectionRecord
|
|
225
|
+
zone: AnyNode
|
|
226
|
+
height: number
|
|
227
|
+
}) {
|
|
228
|
+
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
|
|
229
|
+
const anchor = useRef<Group>(null)
|
|
230
|
+
const scale = useRef<HTMLDivElement>(null)
|
|
231
|
+
const world = useMemo(() => new Vector3(), [])
|
|
232
|
+
useFrame(({ camera }) => {
|
|
233
|
+
if (!(anchor.current && scale.current)) return
|
|
234
|
+
anchor.current.getWorldPosition(world)
|
|
235
|
+
scale.current.style.transform = `scale(${getFloatingMenuScale(camera, world)})`
|
|
236
|
+
})
|
|
237
|
+
if (zone.type !== 'zone' || !zone.seed || isFloorplanHovered) return null
|
|
238
|
+
// A mezzanine's pill floats over its host's walls, not a storey above its own floor.
|
|
239
|
+
const elevation = zone.floor?.support === 'open' ? 0.05 : (zone.floor?.elevation ?? 0.05)
|
|
240
|
+
return (
|
|
241
|
+
<group
|
|
242
|
+
// Above the room's walls, as a wall's pill floats above the wall — clear
|
|
243
|
+
// of the floor a second click drills into.
|
|
244
|
+
position={[zone.seed[0], elevation + height + getMenuYOffset(zone), zone.seed[1]]}
|
|
245
|
+
ref={anchor}
|
|
246
|
+
>
|
|
247
|
+
<Html center style={{ pointerEvents: 'auto', touchAction: 'none' }} zIndexRange={[25, 0]}>
|
|
248
|
+
<div ref={scale} style={{ transformOrigin: 'center center' }}>
|
|
249
|
+
<RoomActionMenu room={room} />
|
|
250
|
+
</div>
|
|
251
|
+
</Html>
|
|
252
|
+
</group>
|
|
253
|
+
)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* The 2D pill: above the room's footprint, like a slab's on the plan, with the
|
|
258
|
+
* floor height stepper under it. Shown while the room is selected and the plan
|
|
259
|
+
* is on screen — not only while it is hovered, so split view does not flicker.
|
|
260
|
+
*/
|
|
261
|
+
function RoomFloorplanMenu({
|
|
262
|
+
room,
|
|
263
|
+
plan,
|
|
264
|
+
}: {
|
|
265
|
+
room: RoomSelectionRecord
|
|
266
|
+
plan: RefObject<SVGGElement | null>
|
|
267
|
+
}) {
|
|
268
|
+
const planShown = useEditor((s) => isViewVisible(s, VIEW_2D))
|
|
269
|
+
const [position, setPosition] = useState<{ left: number; top: number } | null>(null)
|
|
270
|
+
const rings = room.geometry.clearPolygon
|
|
271
|
+
useEffect(() => {
|
|
272
|
+
if (!planShown) {
|
|
273
|
+
setPosition(null)
|
|
274
|
+
return
|
|
275
|
+
}
|
|
276
|
+
let raf = 0
|
|
277
|
+
const tick = () => {
|
|
278
|
+
raf = requestAnimationFrame(tick)
|
|
279
|
+
const group = plan.current
|
|
280
|
+
const svg = group?.ownerSVGElement
|
|
281
|
+
const ctm = group?.getScreenCTM()
|
|
282
|
+
if (!(svg && ctm)) return setPosition(null)
|
|
283
|
+
let minX = Number.POSITIVE_INFINITY
|
|
284
|
+
let maxX = Number.NEGATIVE_INFINITY
|
|
285
|
+
let top = Number.POSITIVE_INFINITY
|
|
286
|
+
const point = svg.createSVGPoint()
|
|
287
|
+
for (const { outer } of rings)
|
|
288
|
+
for (const [x, y] of outer) {
|
|
289
|
+
point.x = x
|
|
290
|
+
point.y = y
|
|
291
|
+
const screen = point.matrixTransform(ctm)
|
|
292
|
+
minX = Math.min(minX, screen.x)
|
|
293
|
+
maxX = Math.max(maxX, screen.x)
|
|
294
|
+
top = Math.min(top, screen.y)
|
|
295
|
+
}
|
|
296
|
+
const next = Number.isFinite(top) ? { left: (minX + maxX) / 2, top } : null
|
|
297
|
+
setPosition((prev) =>
|
|
298
|
+
prev && next && Math.abs(prev.left - next.left) < 0.5 && Math.abs(prev.top - next.top) < 0.5
|
|
299
|
+
? prev
|
|
300
|
+
: next,
|
|
301
|
+
)
|
|
302
|
+
}
|
|
303
|
+
raf = requestAnimationFrame(tick)
|
|
304
|
+
return () => cancelAnimationFrame(raf)
|
|
305
|
+
}, [planShown, plan, rings])
|
|
306
|
+
if (!(planShown && position) || typeof document === 'undefined') return null
|
|
307
|
+
return createPortal(
|
|
308
|
+
<div
|
|
309
|
+
className="pointer-events-none fixed z-30 flex w-max flex-col items-center"
|
|
310
|
+
style={{
|
|
311
|
+
left: position.left,
|
|
312
|
+
top: position.top,
|
|
313
|
+
transform: 'translate(-50%, calc(-100% - 32px))',
|
|
314
|
+
}}
|
|
315
|
+
>
|
|
316
|
+
<div className="flex flex-col items-center gap-1">
|
|
317
|
+
<RoomActionMenu room={room} />
|
|
318
|
+
<RoomFloorHeightStepper zoneId={room.zoneId} />
|
|
319
|
+
</div>
|
|
320
|
+
</div>,
|
|
321
|
+
document.body,
|
|
322
|
+
)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Where a divide pointer landed. `boundaryId` is set when the pointer resolved
|
|
327
|
+
* against a boundary's own surface rather than a free point on the floor.
|
|
328
|
+
*/
|
|
329
|
+
export type DividePointerTarget = { point: Point; boundaryId?: string }
|
|
330
|
+
|
|
331
|
+
/** Keys typed into a field never reach the Divide draft. */
|
|
332
|
+
export const isDivideTypingTarget = isTypingTarget
|
|
333
|
+
|
|
334
|
+
function divideKey(event: KeyboardEvent) {
|
|
335
|
+
if (useInteractionScope.getState().scope.kind !== 'room-divide') return
|
|
336
|
+
if (event.defaultPrevented || isDivideTypingTarget(event.target)) return
|
|
337
|
+
if (event.key === 'Escape') cancelRoomDivide()
|
|
338
|
+
else if (event.key === 'Backspace') removeLastRoomDividePoint()
|
|
339
|
+
else if (event.key === 'Enter') finishRoomDivide()
|
|
340
|
+
else return
|
|
341
|
+
event.preventDefault()
|
|
342
|
+
event.stopPropagation()
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// One keyboard listener per target however many surfaces bind to it: split
|
|
346
|
+
// view mounts the 2D and 3D controls together, and each key must act once.
|
|
347
|
+
const keyboardOwners = new WeakMap<EventTarget, number>()
|
|
348
|
+
function acquireDivideKeyboard(target: EventTarget) {
|
|
349
|
+
const owners = keyboardOwners.get(target) ?? 0
|
|
350
|
+
if (owners === 0) target.addEventListener('keydown', divideKey as EventListener, true)
|
|
351
|
+
keyboardOwners.set(target, owners + 1)
|
|
352
|
+
let released = false
|
|
353
|
+
return () => {
|
|
354
|
+
if (released) return
|
|
355
|
+
released = true
|
|
356
|
+
const left = (keyboardOwners.get(target) ?? 1) - 1
|
|
357
|
+
keyboardOwners.set(target, left)
|
|
358
|
+
if (left === 0) target.removeEventListener('keydown', divideKey as EventListener, true)
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export function bindDividePointer(
|
|
363
|
+
surface: EventTarget,
|
|
364
|
+
resolve: (event: PointerEvent) => DividePointerTarget | null,
|
|
365
|
+
keyboard: EventTarget = window,
|
|
366
|
+
) {
|
|
367
|
+
let pressed: number | null = null
|
|
368
|
+
// Pointer moves are coalesced to one preview per animation frame; a press
|
|
369
|
+
// drops the pending move and previews its own position at once.
|
|
370
|
+
let frame = 0
|
|
371
|
+
let pending: PointerEvent | null = null
|
|
372
|
+
const flush = () => {
|
|
373
|
+
frame = 0
|
|
374
|
+
const event = pending
|
|
375
|
+
pending = null
|
|
376
|
+
if (!event || useInteractionScope.getState().scope.kind !== 'room-divide') return
|
|
377
|
+
const target = resolve(event)
|
|
378
|
+
if (target) previewRoomDivide(target.point, target.boundaryId)
|
|
379
|
+
}
|
|
380
|
+
const dropPending = () => {
|
|
381
|
+
if (frame) cancelAnimationFrame(frame)
|
|
382
|
+
frame = 0
|
|
383
|
+
pending = null
|
|
384
|
+
}
|
|
385
|
+
const move = (event: PointerEvent) => {
|
|
386
|
+
if (useInteractionScope.getState().scope.kind !== 'room-divide') return
|
|
387
|
+
pending = event
|
|
388
|
+
if (!frame) frame = requestAnimationFrame(flush) || 0
|
|
389
|
+
}
|
|
390
|
+
const down = (event: PointerEvent) => {
|
|
391
|
+
if (event.button !== 0 || useInteractionScope.getState().scope.kind !== 'room-divide') return
|
|
392
|
+
const target = resolve(event)
|
|
393
|
+
if (!target) return
|
|
394
|
+
dropPending()
|
|
395
|
+
event.preventDefault()
|
|
396
|
+
event.stopPropagation()
|
|
397
|
+
previewRoomDivide(target.point, target.boundaryId)
|
|
398
|
+
pressed = event.pointerId
|
|
399
|
+
}
|
|
400
|
+
const up = (event: PointerEvent) => {
|
|
401
|
+
if (pressed !== event.pointerId) return
|
|
402
|
+
pressed = null
|
|
403
|
+
dropPending()
|
|
404
|
+
event.preventDefault()
|
|
405
|
+
event.stopPropagation()
|
|
406
|
+
useViewer.getState().setInputDragging(true)
|
|
407
|
+
clickRoomDivide()
|
|
408
|
+
setTimeout(() => useViewer.getState().setInputDragging(false), 0)
|
|
409
|
+
}
|
|
410
|
+
const onMove = (event: Event) => move(event as PointerEvent)
|
|
411
|
+
const onDown = (event: Event) => down(event as PointerEvent)
|
|
412
|
+
surface.addEventListener('pointermove', onMove, true)
|
|
413
|
+
surface.addEventListener('pointerdown', onDown, true)
|
|
414
|
+
keyboard.addEventListener('pointerup', up as EventListener, true)
|
|
415
|
+
const releaseKeyboard = acquireDivideKeyboard(keyboard)
|
|
416
|
+
return () => {
|
|
417
|
+
dropPending()
|
|
418
|
+
surface.removeEventListener('pointermove', onMove, true)
|
|
419
|
+
surface.removeEventListener('pointerdown', onDown, true)
|
|
420
|
+
keyboard.removeEventListener('pointerup', up as EventListener, true)
|
|
421
|
+
releaseKeyboard()
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Escape (`tool:cancel`) ends the draft and is consumed, so the tool stays;
|
|
427
|
+
* the surface unmounting ends it too. Everything else — leaving the structure
|
|
428
|
+
* select tool or the room, another level, a selection change, a boundary
|
|
429
|
+
* change under it, any history command — is the gesture lifecycle owner's
|
|
430
|
+
* (`startRoomDivide`).
|
|
431
|
+
*/
|
|
432
|
+
export function useDivideLifetime() {
|
|
433
|
+
useEffect(() => {
|
|
434
|
+
const onToolCancel = () => {
|
|
435
|
+
if (useInteractionScope.getState().scope.kind !== 'room-divide') return
|
|
436
|
+
cancelRoomDivide()
|
|
437
|
+
markToolCancelConsumed()
|
|
438
|
+
}
|
|
439
|
+
emitter.on('tool:cancel', onToolCancel)
|
|
440
|
+
return () => {
|
|
441
|
+
emitter.off('tool:cancel', onToolCancel)
|
|
442
|
+
cancelRoomDivide()
|
|
443
|
+
}
|
|
444
|
+
}, [])
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Where a Divide pointer lands in 3D: the ray (level space) meets the room's
|
|
449
|
+
* floor plane. Walls never answer — the room's floor is drawn on top of them
|
|
450
|
+
* for the session, so a point behind a near wall is placed where the user
|
|
451
|
+
* sees it; the session snaps onto the room's edges within its capture.
|
|
452
|
+
*/
|
|
453
|
+
export function divideFloorPoint(
|
|
454
|
+
origin: readonly [number, number, number],
|
|
455
|
+
direction: readonly [number, number, number],
|
|
456
|
+
elevation: number,
|
|
457
|
+
): Point | null {
|
|
458
|
+
if (Math.abs(direction[1]) < 1e-9) return null
|
|
459
|
+
const t = (elevation - origin[1]) / direction[1]
|
|
460
|
+
if (!(t > 0) || !Number.isFinite(t)) return null
|
|
461
|
+
return [origin[0] + direction[0] * t, origin[2] + direction[2] * t]
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** The live segment: from the last placed point to the pointer's point. */
|
|
465
|
+
function liveSegment(scope: RoomDivideScope): [Point, Point] | null {
|
|
466
|
+
const from = scope.points.at(-1)
|
|
467
|
+
return from && scope.end ? [from, scope.end] : null
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** What the draft label reads: the live segment's length, or why it cannot be built. */
|
|
471
|
+
function useDivideLabel(scope: RoomDivideScope) {
|
|
472
|
+
const unit = useViewer((s) => s.unit)
|
|
473
|
+
const metricNotation = useViewer((s) => s.metricNotation)
|
|
474
|
+
if (!scope.valid) return scope.message ?? null
|
|
475
|
+
const live = liveSegment(scope)
|
|
476
|
+
if (!live) return null
|
|
477
|
+
const length = Math.hypot(live[1][0] - live[0][0], live[1][1] - live[0][1])
|
|
478
|
+
return length > 0.01 ? formatLinearMeasurement(length, unit, metricNotation) : null
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function labelPoint(scope: RoomDivideScope): Point | null {
|
|
482
|
+
const live = liveSegment(scope)
|
|
483
|
+
if (!scope.valid) return scope.end
|
|
484
|
+
return live ? [(live[0][0] + live[1][0]) / 2, (live[0][1] + live[1][1]) / 2] : null
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// The draft draws over the room's floor highlight (998 / 999), which draws
|
|
488
|
+
// over every wall, so the line and its points stay readable wherever they are.
|
|
489
|
+
const DRAFT_RENDER_ORDER = 1000
|
|
490
|
+
const noRaycast = () => {}
|
|
491
|
+
/** The line's and a point's size on screen, however far the camera is. */
|
|
492
|
+
const STRIP_WIDTH_PX = 3
|
|
493
|
+
const VERTEX_RADIUS_PX = 7
|
|
494
|
+
const STRIP_MIN_WIDTH = 0.06
|
|
495
|
+
const VERTEX_MIN_RADIUS = 0.16
|
|
496
|
+
|
|
497
|
+
const scratch = new Vector3()
|
|
498
|
+
const cameraScratch = new Vector3()
|
|
499
|
+
|
|
500
|
+
/** World metres one screen pixel spans at `object`'s origin. */
|
|
501
|
+
function metresPerPixel(object: Object3D, camera: Camera, viewportHeight: number) {
|
|
502
|
+
if (viewportHeight <= 0) return 0
|
|
503
|
+
if ((camera as OrthographicCamera).isOrthographicCamera) {
|
|
504
|
+
const ortho = camera as OrthographicCamera
|
|
505
|
+
return (ortho.top - ortho.bottom) / ortho.zoom / viewportHeight
|
|
506
|
+
}
|
|
507
|
+
const perspective = camera as PerspectiveCamera
|
|
508
|
+
const distance = perspective
|
|
509
|
+
.getWorldPosition(cameraScratch)
|
|
510
|
+
.distanceTo(object.getWorldPosition(scratch))
|
|
511
|
+
return (2 * distance * Math.tan((perspective.fov * Math.PI) / 360)) / viewportHeight
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function DraftStrip({
|
|
515
|
+
from,
|
|
516
|
+
to,
|
|
517
|
+
color,
|
|
518
|
+
elevation,
|
|
519
|
+
}: {
|
|
520
|
+
from: Point
|
|
521
|
+
to: Point
|
|
522
|
+
color: string
|
|
523
|
+
elevation: number
|
|
524
|
+
}) {
|
|
525
|
+
const ref = useRef<Mesh>(null)
|
|
526
|
+
useFrame(({ camera, size }) => {
|
|
527
|
+
const mesh = ref.current
|
|
528
|
+
if (!mesh) return
|
|
529
|
+
mesh.scale.z = Math.max(
|
|
530
|
+
STRIP_MIN_WIDTH,
|
|
531
|
+
STRIP_WIDTH_PX * metresPerPixel(mesh, camera, size.height),
|
|
532
|
+
)
|
|
533
|
+
})
|
|
534
|
+
const length = Math.hypot(to[0] - from[0], to[1] - from[1])
|
|
535
|
+
if (length < 0.01) return null
|
|
536
|
+
return (
|
|
537
|
+
<mesh
|
|
538
|
+
layers={EDITOR_LAYER}
|
|
539
|
+
position={[(from[0] + to[0]) / 2, elevation + 0.01, (from[1] + to[1]) / 2]}
|
|
540
|
+
raycast={noRaycast}
|
|
541
|
+
ref={ref}
|
|
542
|
+
renderOrder={DRAFT_RENDER_ORDER}
|
|
543
|
+
rotation={[0, -Math.atan2(to[1] - from[1], to[0] - from[0]), 0]}
|
|
544
|
+
scale-z={STRIP_MIN_WIDTH}
|
|
545
|
+
>
|
|
546
|
+
<boxGeometry args={[length, 0.02, 1]} />
|
|
547
|
+
<meshBasicMaterial
|
|
548
|
+
color={color}
|
|
549
|
+
depthTest={false}
|
|
550
|
+
depthWrite={false}
|
|
551
|
+
opacity={0.95}
|
|
552
|
+
side={DoubleSide}
|
|
553
|
+
transparent
|
|
554
|
+
/>
|
|
555
|
+
</mesh>
|
|
556
|
+
)
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** A placed point: a solid dot in a soft ring, flat on the floor, over the highlight. */
|
|
560
|
+
function DraftVertex({ at, color, elevation }: { at: Point; color: string; elevation: number }) {
|
|
561
|
+
const ref = useRef<Group>(null)
|
|
562
|
+
useFrame(({ camera, size }) => {
|
|
563
|
+
const group = ref.current
|
|
564
|
+
if (!group) return
|
|
565
|
+
const radius = Math.max(
|
|
566
|
+
VERTEX_MIN_RADIUS,
|
|
567
|
+
VERTEX_RADIUS_PX * metresPerPixel(group, camera, size.height),
|
|
568
|
+
)
|
|
569
|
+
group.scale.setScalar(radius)
|
|
570
|
+
})
|
|
571
|
+
return (
|
|
572
|
+
<group
|
|
573
|
+
position={[at[0], elevation + 0.012, at[1]]}
|
|
574
|
+
ref={ref}
|
|
575
|
+
rotation={[-Math.PI / 2, 0, 0]}
|
|
576
|
+
scale={VERTEX_MIN_RADIUS}
|
|
577
|
+
>
|
|
578
|
+
<mesh layers={EDITOR_LAYER} raycast={noRaycast} renderOrder={DRAFT_RENDER_ORDER}>
|
|
579
|
+
<circleGeometry args={[1, 32]} />
|
|
580
|
+
<meshBasicMaterial
|
|
581
|
+
color={color}
|
|
582
|
+
depthTest={false}
|
|
583
|
+
depthWrite={false}
|
|
584
|
+
opacity={0.3}
|
|
585
|
+
transparent
|
|
586
|
+
/>
|
|
587
|
+
</mesh>
|
|
588
|
+
<mesh layers={EDITOR_LAYER} raycast={noRaycast} renderOrder={DRAFT_RENDER_ORDER + 1}>
|
|
589
|
+
<circleGeometry args={[0.45, 32]} />
|
|
590
|
+
<meshBasicMaterial
|
|
591
|
+
color={color}
|
|
592
|
+
depthTest={false}
|
|
593
|
+
depthWrite={false}
|
|
594
|
+
opacity={0.95}
|
|
595
|
+
transparent
|
|
596
|
+
/>
|
|
597
|
+
</mesh>
|
|
598
|
+
</group>
|
|
599
|
+
)
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/** A pointer off the room reads grey (not the red of a refused cut). */
|
|
603
|
+
const OUTSIDE_COLOR = '#94a3b8'
|
|
604
|
+
|
|
605
|
+
/** Whether the live point sits off the room: not on its edge, not inside it. */
|
|
606
|
+
export function divideEndOutside(scope: RoomDivideScope): boolean {
|
|
607
|
+
if (!scope.end || scope.endKind === 'edge' || scope.endKind === 'close') return false
|
|
608
|
+
if (scope.endKind === 'start' && scope.endBoundaryId) return false
|
|
609
|
+
return !roomDivideContains(scope.end)
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** The divide draft in 3D, drawn like a wall draft: cursor beam, strips, length. */
|
|
613
|
+
function RoomDividePreview3D({
|
|
614
|
+
scope,
|
|
615
|
+
elevation,
|
|
616
|
+
height,
|
|
617
|
+
}: {
|
|
618
|
+
scope: RoomDivideScope
|
|
619
|
+
elevation: number
|
|
620
|
+
height: number
|
|
621
|
+
}) {
|
|
622
|
+
const isDark = useSceneIsDark()
|
|
623
|
+
const label = useDivideLabel(scope)
|
|
624
|
+
const liveColor = scope.valid ? DRAFT_COLOR : INVALID_COLOR
|
|
625
|
+
const outside = divideEndOutside(scope)
|
|
626
|
+
const live = liveSegment(scope)
|
|
627
|
+
const at = labelPoint(scope)
|
|
628
|
+
const { points, end } = scope
|
|
629
|
+
return (
|
|
630
|
+
<group>
|
|
631
|
+
{points.map((point, index) => (
|
|
632
|
+
<DraftVertex at={point} color={DRAFT_COLOR} elevation={elevation} key={index} />
|
|
633
|
+
))}
|
|
634
|
+
{points.slice(1).map((point, index) => (
|
|
635
|
+
<DraftStrip
|
|
636
|
+
color={DRAFT_COLOR}
|
|
637
|
+
elevation={elevation}
|
|
638
|
+
from={points[index]!}
|
|
639
|
+
key={index}
|
|
640
|
+
to={point}
|
|
641
|
+
/>
|
|
642
|
+
))}
|
|
643
|
+
{live && <DraftStrip color={liveColor} elevation={elevation} from={live[0]} to={live[1]} />}
|
|
644
|
+
{end && (
|
|
645
|
+
<CursorSphere
|
|
646
|
+
color={outside ? OUTSIDE_COLOR : liveColor}
|
|
647
|
+
height={height}
|
|
648
|
+
position={[end[0], elevation, end[1]]}
|
|
649
|
+
tooltipContent={<SquareSplitHorizontal className="h-5 w-5 text-white" />}
|
|
650
|
+
/>
|
|
651
|
+
)}
|
|
652
|
+
{label && at && (
|
|
653
|
+
<DraftMeasurementLabel
|
|
654
|
+
color={scope.valid ? (isDark ? '#ffffff' : '#111111') : INVALID_COLOR}
|
|
655
|
+
label={label}
|
|
656
|
+
position={[at[0], elevation + DRAFT_LABEL_Y_OFFSET + (scope.valid ? 0 : 0.2), at[1]]}
|
|
657
|
+
shadowColor={isDark ? '#111111' : '#ffffff'}
|
|
658
|
+
/>
|
|
659
|
+
)}
|
|
660
|
+
</group>
|
|
661
|
+
)
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
export function RoomControls3D() {
|
|
665
|
+
const room = useRoomControls()
|
|
666
|
+
const scope = useInteractionScope((s) => s.scope)
|
|
667
|
+
const active = scope.kind === 'room-divide' ? scope : null
|
|
668
|
+
const handleDrag = useRoomHandleDrag((s) => s.drag)
|
|
669
|
+
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
|
|
670
|
+
const levelId = active?.levelId ?? room?.key.levelId ?? handleDrag?.levelId
|
|
671
|
+
const zoneId = active?.nodeId ?? room?.zoneId
|
|
672
|
+
const zone = useScene((s) => (zoneId ? s.nodes[zoneId as AnyNodeId] : undefined))
|
|
673
|
+
const levelHeight = useScene((s) => {
|
|
674
|
+
const level = levelId ? s.nodes[levelId as AnyNodeId] : undefined
|
|
675
|
+
return level?.type === 'level' ? (level.height ?? DEFAULT_LEVEL_HEIGHT) : DEFAULT_LEVEL_HEIGHT
|
|
676
|
+
})
|
|
677
|
+
const elevation = zone?.type === 'zone' ? (zone.floor?.elevation ?? 0.05) : 0.05
|
|
678
|
+
const root = useRef<Group>(null)
|
|
679
|
+
const { gl, camera, invalidate } = useThree()
|
|
680
|
+
useDivideLifetime()
|
|
681
|
+
useEffect(
|
|
682
|
+
() =>
|
|
683
|
+
bindDividePointer(gl.domElement, (event) => {
|
|
684
|
+
const current = useInteractionScope.getState().scope
|
|
685
|
+
if (current.kind !== 'room-divide' || event.target !== gl.domElement) return null
|
|
686
|
+
const level = sceneRegistry.nodes.get(current.levelId)
|
|
687
|
+
if (!level) return null
|
|
688
|
+
level.updateWorldMatrix(true, false)
|
|
689
|
+
const inverse = new Matrix4().copy(level.matrixWorld).invert()
|
|
690
|
+
const bounds = gl.domElement.getBoundingClientRect()
|
|
691
|
+
const raycaster = new Raycaster()
|
|
692
|
+
raycaster.setFromCamera(
|
|
693
|
+
new Vector2(
|
|
694
|
+
((event.clientX - bounds.left) / bounds.width) * 2 - 1,
|
|
695
|
+
1 - ((event.clientY - bounds.top) / bounds.height) * 2,
|
|
696
|
+
),
|
|
697
|
+
camera,
|
|
698
|
+
)
|
|
699
|
+
setSurfaceRaycastLayers(raycaster.layers)
|
|
700
|
+
const ray = raycaster.ray.clone().applyMatrix4(inverse)
|
|
701
|
+
const point = divideFloorPoint(
|
|
702
|
+
[ray.origin.x, ray.origin.y, ray.origin.z],
|
|
703
|
+
[ray.direction.x, ray.direction.y, ray.direction.z],
|
|
704
|
+
elevation,
|
|
705
|
+
)
|
|
706
|
+
return point ? { point } : null
|
|
707
|
+
}),
|
|
708
|
+
[gl, camera, elevation],
|
|
709
|
+
)
|
|
710
|
+
useFrame(() => {
|
|
711
|
+
const level = levelId ? sceneRegistry.nodes.get(levelId) : null
|
|
712
|
+
if (!root.current) return
|
|
713
|
+
root.current.visible = !!level
|
|
714
|
+
if (level) {
|
|
715
|
+
level.updateWorldMatrix(true, false)
|
|
716
|
+
root.current.matrix.copy(level.matrixWorld)
|
|
717
|
+
}
|
|
718
|
+
})
|
|
719
|
+
return (
|
|
720
|
+
<>
|
|
721
|
+
<group matrixAutoUpdate={false} ref={root}>
|
|
722
|
+
{typeof document !== 'undefined' && room && zone && (
|
|
723
|
+
<RoomFloatingMenu height={levelHeight} room={room} zone={zone} />
|
|
724
|
+
)}
|
|
725
|
+
{room && !isFloorplanHovered && <RoomHandles3D room={room} />}
|
|
726
|
+
<RoomHandleDragPreview3D />
|
|
727
|
+
{active && (
|
|
728
|
+
<RoomFloorHighlight3D
|
|
729
|
+
elevation={elevation}
|
|
730
|
+
levelId={active.levelId}
|
|
731
|
+
zoneId={active.nodeId}
|
|
732
|
+
/>
|
|
733
|
+
)}
|
|
734
|
+
{active && (
|
|
735
|
+
<RoomDividePreview3D elevation={elevation} height={levelHeight} scope={active} />
|
|
736
|
+
)}
|
|
737
|
+
</group>
|
|
738
|
+
<RoomTransformGhost3D />
|
|
739
|
+
</>
|
|
740
|
+
)
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
export function resolveRoomDividePlanPoint(
|
|
744
|
+
clientX: number,
|
|
745
|
+
clientY: number,
|
|
746
|
+
): DividePointerTarget | null {
|
|
747
|
+
const point = clientToPlan(clientX, clientY)
|
|
748
|
+
return point ? { point: [point[0], point[1]] } : null
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// The plan's draft cursor marker: a soft glow under a solid core, in pixels.
|
|
752
|
+
const MARKER_GLOW_PX = 10
|
|
753
|
+
const MARKER_CORE_PX = 3
|
|
754
|
+
|
|
755
|
+
function PlanMarker({ at, color, upp }: { at: Point; color: string; upp: number }) {
|
|
756
|
+
return (
|
|
757
|
+
<>
|
|
758
|
+
<circle cx={at[0]} cy={at[1]} fill={color} fillOpacity={0.25} r={MARKER_GLOW_PX * upp} />
|
|
759
|
+
<circle cx={at[0]} cy={at[1]} fill={color} fillOpacity={0.9} r={MARKER_CORE_PX * upp} />
|
|
760
|
+
</>
|
|
761
|
+
)
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/** The divide draft on the plan, drawn like the plan's wall draft. */
|
|
765
|
+
function RoomDividePreview2D({ scope }: { scope: RoomDivideScope }) {
|
|
766
|
+
const context = useFloorplanRender()
|
|
767
|
+
const isDark = useSceneIsDark()
|
|
768
|
+
const label = useDivideLabel(scope)
|
|
769
|
+
const upp = context?.unitsPerPixel ?? 0.01
|
|
770
|
+
const draft = isDark ? DRAFT_COLOR : DRAFT_COLOR_LIGHT_PLAN
|
|
771
|
+
const liveColor = scope.valid ? draft : INVALID_COLOR
|
|
772
|
+
const live = liveSegment(scope)
|
|
773
|
+
const at = labelPoint(scope)
|
|
774
|
+
const { points, end } = scope
|
|
775
|
+
return (
|
|
776
|
+
<g data-room-divide-preview pointerEvents="none">
|
|
777
|
+
{points.length > 1 && (
|
|
778
|
+
<polyline
|
|
779
|
+
fill="none"
|
|
780
|
+
points={points.map((point) => point.join(',')).join(' ')}
|
|
781
|
+
stroke={draft}
|
|
782
|
+
strokeLinecap="round"
|
|
783
|
+
strokeLinejoin="round"
|
|
784
|
+
strokeWidth={2 * upp}
|
|
785
|
+
/>
|
|
786
|
+
)}
|
|
787
|
+
{live && (
|
|
788
|
+
<line
|
|
789
|
+
stroke={liveColor}
|
|
790
|
+
strokeLinecap="round"
|
|
791
|
+
strokeWidth={2 * upp}
|
|
792
|
+
x1={live[0][0]}
|
|
793
|
+
x2={live[1][0]}
|
|
794
|
+
y1={live[0][1]}
|
|
795
|
+
y2={live[1][1]}
|
|
796
|
+
/>
|
|
797
|
+
)}
|
|
798
|
+
{points.map((point, index) => (
|
|
799
|
+
<PlanMarker at={point} color={draft} key={index} upp={upp} />
|
|
800
|
+
))}
|
|
801
|
+
{end && <PlanMarker at={end} color={liveColor} upp={upp} />}
|
|
802
|
+
{label && at && (
|
|
803
|
+
<g
|
|
804
|
+
transform={`translate(${at[0]} ${at[1]}) rotate(${-(context?.sceneRotationDeg ?? 0)}) scale(${upp})`}
|
|
805
|
+
>
|
|
806
|
+
<text
|
|
807
|
+
dominantBaseline="middle"
|
|
808
|
+
fill={scope.valid ? (context?.palette.measurementStroke ?? draft) : INVALID_COLOR}
|
|
809
|
+
fontSize={12}
|
|
810
|
+
fontWeight={600}
|
|
811
|
+
paintOrder="stroke"
|
|
812
|
+
stroke={isDark ? '#0f172a' : '#ffffff'}
|
|
813
|
+
strokeLinejoin="round"
|
|
814
|
+
strokeWidth={3}
|
|
815
|
+
textAnchor="middle"
|
|
816
|
+
y={-16}
|
|
817
|
+
>
|
|
818
|
+
{label}
|
|
819
|
+
</text>
|
|
820
|
+
</g>
|
|
821
|
+
)}
|
|
822
|
+
</g>
|
|
823
|
+
)
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
export function RoomControls2D({ levelId }: { levelId: string | null }) {
|
|
827
|
+
const room = useRoomControls()
|
|
828
|
+
const scope = useInteractionScope((s) => s.scope)
|
|
829
|
+
const active = scope.kind === 'room-divide' && scope.levelId === levelId ? scope : null
|
|
830
|
+
const ref = useRef<SVGGElement>(null)
|
|
831
|
+
useDivideLifetime()
|
|
832
|
+
useEffect(() => {
|
|
833
|
+
const svg = ref.current?.ownerSVGElement
|
|
834
|
+
if (!svg) return
|
|
835
|
+
return bindDividePointer(svg, (event) => {
|
|
836
|
+
if (!(event.target instanceof Element) || event.target.closest('foreignObject')) return null
|
|
837
|
+
return resolveRoomDividePlanPoint(event.clientX, event.clientY)
|
|
838
|
+
})
|
|
839
|
+
}, [])
|
|
840
|
+
return (
|
|
841
|
+
<g ref={ref}>
|
|
842
|
+
{room && room.key.levelId === levelId && <RoomFloorplanMenu plan={ref} room={room} />}
|
|
843
|
+
{active && <RoomDividePreview2D scope={active} />}
|
|
844
|
+
<RoomTransformGhost2D levelId={levelId} />
|
|
845
|
+
</g>
|
|
846
|
+
)
|
|
847
|
+
}
|