@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,990 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type AnyNode,
|
|
5
|
+
type AnyNodeId,
|
|
6
|
+
area,
|
|
7
|
+
DEFAULT_LEVEL_HEIGHT,
|
|
8
|
+
emitter,
|
|
9
|
+
polygonInteriorPoint,
|
|
10
|
+
sceneRegistry,
|
|
11
|
+
useScene,
|
|
12
|
+
type ZoneNode,
|
|
13
|
+
} from '@pascal-app/core'
|
|
14
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
15
|
+
import { Html } from '@react-three/drei'
|
|
16
|
+
import { useFrame, useThree } from '@react-three/fiber'
|
|
17
|
+
import {
|
|
18
|
+
createContext,
|
|
19
|
+
Fragment,
|
|
20
|
+
type MouseEvent as ReactMouseEvent,
|
|
21
|
+
type ReactNode,
|
|
22
|
+
type PointerEvent as ReactPointerEvent,
|
|
23
|
+
useCallback,
|
|
24
|
+
useContext,
|
|
25
|
+
useEffect,
|
|
26
|
+
useLayoutEffect,
|
|
27
|
+
useMemo,
|
|
28
|
+
useRef,
|
|
29
|
+
useState,
|
|
30
|
+
} from 'react'
|
|
31
|
+
import { createPortal } from 'react-dom'
|
|
32
|
+
import { type Group, type Object3D, Vector3 } from 'three'
|
|
33
|
+
import { useShallow } from 'zustand/react/shallow'
|
|
34
|
+
import { useRoomRecords } from '../../hooks/use-selected-room'
|
|
35
|
+
import { getAreaUnitLabel, type LinearUnit, squareMetersToAreaUnit } from '../../lib/measurements'
|
|
36
|
+
import { roomFloorElevation } from '../../lib/room-handle-drag'
|
|
37
|
+
import type { RoomSelectionRecord } from '../../lib/room-selection'
|
|
38
|
+
import {
|
|
39
|
+
clickZoneArea,
|
|
40
|
+
hoverZoneArea,
|
|
41
|
+
leaveZoneArea,
|
|
42
|
+
zoneKindLabel,
|
|
43
|
+
} from '../../lib/room-zone-routing'
|
|
44
|
+
import { selectionModifiersFromEvent } from '../../lib/selection-routing'
|
|
45
|
+
import useEditor from '../../store/use-editor'
|
|
46
|
+
import useInteractionScope from '../../store/use-interaction-scope'
|
|
47
|
+
import { isViewVisible, VIEW_2D, VIEW_3D } from '../../store/view-layout'
|
|
48
|
+
|
|
49
|
+
const CONTEXT_TYPES = new Set(['site', 'building', 'level'])
|
|
50
|
+
|
|
51
|
+
export type RoomLabelVisibilityState = {
|
|
52
|
+
phase: string
|
|
53
|
+
mode: string
|
|
54
|
+
scopeIdle: boolean
|
|
55
|
+
room: unknown
|
|
56
|
+
selectedTypes: readonly (string | undefined)[]
|
|
57
|
+
zoneId: string | null
|
|
58
|
+
focusedUnitId: string | null
|
|
59
|
+
isCaptureMode: boolean
|
|
60
|
+
isThumbnailCapture: boolean
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Room pills are the resting view of a level: inside the building, select tool
|
|
65
|
+
* idle, and nothing picked but the site, building or level itself. Anything
|
|
66
|
+
* selected, a unit being arranged, or a gesture in flight takes them away.
|
|
67
|
+
*/
|
|
68
|
+
export function roomLabelsVisible(state: RoomLabelVisibilityState): boolean {
|
|
69
|
+
return (
|
|
70
|
+
state.phase === 'building' &&
|
|
71
|
+
state.mode === 'select' &&
|
|
72
|
+
state.scopeIdle &&
|
|
73
|
+
!state.room &&
|
|
74
|
+
!state.zoneId &&
|
|
75
|
+
!state.focusedUnitId &&
|
|
76
|
+
!state.isCaptureMode &&
|
|
77
|
+
!state.isThumbnailCapture &&
|
|
78
|
+
state.selectedTypes.every((type) => type !== undefined && CONTEXT_TYPES.has(type))
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function formatRoomArea(squareMeters: number, unit: LinearUnit): string {
|
|
83
|
+
return `${squareMetersToAreaUnit(squareMeters, unit).toFixed(1)} ${getAreaUnitLabel(unit)}`
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function useRoomLabelsVisible() {
|
|
87
|
+
const [isThumbnailCapture, setThumbnailCapture] = useState(false)
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
const hide = () => setThumbnailCapture(true)
|
|
90
|
+
const restore = () => setThumbnailCapture(false)
|
|
91
|
+
emitter.on('thumbnail:before-capture', hide)
|
|
92
|
+
emitter.on('thumbnail:after-capture', restore)
|
|
93
|
+
return () => {
|
|
94
|
+
emitter.off('thumbnail:before-capture', hide)
|
|
95
|
+
emitter.off('thumbnail:after-capture', restore)
|
|
96
|
+
}
|
|
97
|
+
}, [])
|
|
98
|
+
const editor = useEditor(
|
|
99
|
+
useShallow((s) => ({
|
|
100
|
+
phase: s.phase,
|
|
101
|
+
mode: s.mode,
|
|
102
|
+
room: s.room,
|
|
103
|
+
isCaptureMode: s.isCaptureMode,
|
|
104
|
+
})),
|
|
105
|
+
)
|
|
106
|
+
const scopeIdle = useInteractionScope((s) => s.scope.kind === 'idle')
|
|
107
|
+
const zoneId = useViewer((s) => s.selection.zoneId)
|
|
108
|
+
const focusedUnitId = useViewer((s) => s.focusedUnitId)
|
|
109
|
+
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
|
110
|
+
const selectedTypes = useScene(
|
|
111
|
+
useShallow((s) =>
|
|
112
|
+
selectedIds.map((id) => (s.nodes[id as AnyNodeId] as AnyNode | undefined)?.type),
|
|
113
|
+
),
|
|
114
|
+
)
|
|
115
|
+
return roomLabelsVisible({
|
|
116
|
+
...editor,
|
|
117
|
+
scopeIdle,
|
|
118
|
+
zoneId,
|
|
119
|
+
focusedUnitId,
|
|
120
|
+
selectedTypes,
|
|
121
|
+
isThumbnailCapture,
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
type LabelRect = { left: number; top: number; right: number; bottom: number }
|
|
126
|
+
|
|
127
|
+
/** A pill's screen rect: centred on where its anchor lands, at its size. */
|
|
128
|
+
export function pillRect(x: number, y: number, [width, height]: readonly [number, number]) {
|
|
129
|
+
return { left: x - width / 2, top: y - height / 2, right: x + width / 2, bottom: y + height / 2 }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** A pill to place: where its room's anchor lands on screen, its full size and its room's area. */
|
|
133
|
+
export type PillCandidate = {
|
|
134
|
+
id: string
|
|
135
|
+
x: number
|
|
136
|
+
y: number
|
|
137
|
+
size: readonly [number, number]
|
|
138
|
+
area: number
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Where a pill goes: in full (moved `dy` px off its anchor), as its colour dot, or nowhere. */
|
|
142
|
+
export type PillPlacement = { mode: 'full' | 'dot' | 'hidden'; dy: number }
|
|
143
|
+
|
|
144
|
+
/** The collapsed pill: its colour dot in a small round box. */
|
|
145
|
+
export const PILL_DOT_SIZE = 16
|
|
146
|
+
const PILL_GAP = 2
|
|
147
|
+
const NUDGE_STEP = 4
|
|
148
|
+
|
|
149
|
+
function overlaps(a: LabelRect, b: LabelRect) {
|
|
150
|
+
return (
|
|
151
|
+
a.left < b.right + PILL_GAP &&
|
|
152
|
+
b.left < a.right + PILL_GAP &&
|
|
153
|
+
a.top < b.bottom + PILL_GAP &&
|
|
154
|
+
b.top < a.bottom + PILL_GAP
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** 0, then up and down by growing steps, up to `budget` px either way. */
|
|
159
|
+
function* nudges(budget: number) {
|
|
160
|
+
yield 0
|
|
161
|
+
for (let d = NUDGE_STEP; d <= budget; d += NUDGE_STEP) {
|
|
162
|
+
yield -d
|
|
163
|
+
yield d
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Where every pill goes so none overlap. Larger rooms are placed first (ties
|
|
169
|
+
* by id, so the order is stable while the camera moves). A pill that overlaps
|
|
170
|
+
* one already placed takes the nearest free slot above or below its room,
|
|
171
|
+
* within one pill height; failing that it collapses to its colour dot, which
|
|
172
|
+
* takes the nearest free slot the same way; only a dot with no free slot is
|
|
173
|
+
* hidden.
|
|
174
|
+
*/
|
|
175
|
+
export function placePills(candidates: readonly PillCandidate[]): Map<string, PillPlacement> {
|
|
176
|
+
const placed: LabelRect[] = []
|
|
177
|
+
const placements = new Map<string, PillPlacement>()
|
|
178
|
+
const order = [...candidates].sort(
|
|
179
|
+
(a, b) => b.area - a.area || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0),
|
|
180
|
+
)
|
|
181
|
+
for (const pill of order) {
|
|
182
|
+
let placement: PillPlacement = { mode: 'hidden', dy: 0 }
|
|
183
|
+
for (const dy of nudges(pill.size[1])) {
|
|
184
|
+
const rect = pillRect(pill.x, pill.y + dy, pill.size)
|
|
185
|
+
if (placed.some((other) => overlaps(rect, other))) continue
|
|
186
|
+
placed.push(rect)
|
|
187
|
+
placement = { mode: 'full', dy }
|
|
188
|
+
break
|
|
189
|
+
}
|
|
190
|
+
if (placement.mode === 'hidden') {
|
|
191
|
+
for (const dy of nudges(pill.size[1])) {
|
|
192
|
+
const dot = pillRect(pill.x, pill.y + dy, [PILL_DOT_SIZE, PILL_DOT_SIZE])
|
|
193
|
+
if (placed.some((other) => overlaps(dot, other))) continue
|
|
194
|
+
placed.push(dot)
|
|
195
|
+
placement = { mode: 'dot', dy }
|
|
196
|
+
break
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
placements.set(pill.id, placement)
|
|
200
|
+
}
|
|
201
|
+
return placements
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export type RoomLabelEntry = {
|
|
205
|
+
zoneId: string
|
|
206
|
+
polygon: [number, number][]
|
|
207
|
+
holes: [number, number][][]
|
|
208
|
+
area: number
|
|
209
|
+
}
|
|
210
|
+
type LabelEntry = RoomLabelEntry
|
|
211
|
+
|
|
212
|
+
// Mid-height of a default storey: above furniture, below the ceiling.
|
|
213
|
+
const LABEL_HEIGHT = DEFAULT_LEVEL_HEIGHT / 2
|
|
214
|
+
// The view has to hold still this long before pills are re-placed; while it
|
|
215
|
+
// moves they keep their places and ride their rooms, so nothing jitters.
|
|
216
|
+
const DECLUTTER_REST_SECONDS = 0.12
|
|
217
|
+
// Below this the view counts as still: well under a pixel on screen.
|
|
218
|
+
const VIEW_EPSILON = 1e-4
|
|
219
|
+
/** How long a pill takes to fade, move or collapse. */
|
|
220
|
+
export const PILL_FADE_MS = 180
|
|
221
|
+
|
|
222
|
+
const labelWorld = new Vector3()
|
|
223
|
+
|
|
224
|
+
function pillFadeMs(): number {
|
|
225
|
+
return typeof window !== 'undefined' &&
|
|
226
|
+
window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
|
|
227
|
+
? 0
|
|
228
|
+
: PILL_FADE_MS
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* When a layer re-places its pills: once the view has held still, and again
|
|
233
|
+
* whenever its pills change while it rests. Still means within an epsilon of
|
|
234
|
+
* the view last seen moving — camera damping keeps nudging the camera by
|
|
235
|
+
* fractions of a millimetre for a second or more after it visibly stops, and
|
|
236
|
+
* waiting for it to stop exactly left pills misplaced for that long.
|
|
237
|
+
*/
|
|
238
|
+
export class DeclutterClock {
|
|
239
|
+
private reference: number[] | null = null
|
|
240
|
+
private movedAt = Number.NEGATIVE_INFINITY
|
|
241
|
+
private settled = false
|
|
242
|
+
private dirty = true
|
|
243
|
+
|
|
244
|
+
/** The pills changed (mounted, unmounted, resized): place them again. */
|
|
245
|
+
invalidate() {
|
|
246
|
+
this.dirty = true
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Whether to place pills for this view (any flat list of numbers) at `now` seconds. */
|
|
250
|
+
shouldRead(view: ArrayLike<number>, now: number): boolean {
|
|
251
|
+
const reference = this.reference
|
|
252
|
+
let moved = !reference || reference.length !== view.length
|
|
253
|
+
for (let i = 0; !moved && i < view.length; i++) {
|
|
254
|
+
moved = Math.abs(view[i]! - reference![i]!) > VIEW_EPSILON
|
|
255
|
+
}
|
|
256
|
+
if (moved) {
|
|
257
|
+
this.reference = Array.from(view)
|
|
258
|
+
this.movedAt = now
|
|
259
|
+
this.settled = false
|
|
260
|
+
}
|
|
261
|
+
if (now - this.movedAt < DECLUTTER_REST_SECONDS || (this.settled && !this.dirty)) return false
|
|
262
|
+
this.settled = true
|
|
263
|
+
this.dirty = false
|
|
264
|
+
return true
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** The pills a layer has mounted, by zone id; the layer hears each one come and go. */
|
|
269
|
+
export class PillElements extends Map<string, HTMLElement> {
|
|
270
|
+
onMount: ((id: string, element: HTMLElement) => void) | null = null
|
|
271
|
+
onUnmount: ((element: HTMLElement) => void) | null = null
|
|
272
|
+
onMeasure: ((id: string, element: HTMLElement) => void) | null = null
|
|
273
|
+
|
|
274
|
+
measure(id: string) {
|
|
275
|
+
const element = this.get(id)
|
|
276
|
+
if (element) this.onMeasure?.(id, element)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
override set(id: string, element: HTMLElement) {
|
|
280
|
+
super.set(id, element)
|
|
281
|
+
this.onMount?.(id, element)
|
|
282
|
+
return this
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
override delete(id: string) {
|
|
286
|
+
const element = this.get(id)
|
|
287
|
+
const removed = super.delete(id)
|
|
288
|
+
if (element) this.onUnmount?.(element)
|
|
289
|
+
return removed
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Read the full border-box in CSS pixels, even when the dot is showing. */
|
|
294
|
+
export function measureFullPill(
|
|
295
|
+
element: HTMLElement,
|
|
296
|
+
read: (element: HTMLElement) => Pick<CSSStyleDeclaration, 'width' | 'height'> = getComputedStyle,
|
|
297
|
+
): readonly [number, number] {
|
|
298
|
+
const collapsed = element.dataset.roomLabelCollapsed
|
|
299
|
+
delete element.dataset.roomLabelCollapsed
|
|
300
|
+
try {
|
|
301
|
+
const style = read(element)
|
|
302
|
+
return [Number.parseFloat(style.width), Number.parseFloat(style.height)]
|
|
303
|
+
} finally {
|
|
304
|
+
if (collapsed !== undefined) element.dataset.roomLabelCollapsed = collapsed
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
type Timer = ReturnType<typeof setTimeout>
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Applies placements to the mounted pills as direct style writes: the nudge
|
|
312
|
+
* as a `translate`, the dot as `data-room-label-collapsed`, both eased by the
|
|
313
|
+
* pill's transition. A hidden pill fades out without taking the pointer, then
|
|
314
|
+
* leaves the DOM (`gone`); one given room again mounts and fades back in.
|
|
315
|
+
*/
|
|
316
|
+
export class PillPlacer {
|
|
317
|
+
gone: ReadonlySet<string> = new Set()
|
|
318
|
+
private placements = new Map<string, PillPlacement>()
|
|
319
|
+
private readonly fading = new Map<string, Timer>()
|
|
320
|
+
|
|
321
|
+
constructor(
|
|
322
|
+
private readonly elements: ReadonlyMap<string, HTMLElement>,
|
|
323
|
+
private readonly onGone: (gone: ReadonlySet<string>) => void,
|
|
324
|
+
private readonly schedule: (run: () => void, ms: number) => Timer = setTimeout,
|
|
325
|
+
private readonly cancel: (timer: Timer) => void = clearTimeout,
|
|
326
|
+
) {}
|
|
327
|
+
|
|
328
|
+
apply(placements: ReadonlyMap<string, PillPlacement>, fadeMs = pillFadeMs()) {
|
|
329
|
+
this.placements = new Map([...this.placements, ...placements])
|
|
330
|
+
for (const [id, element] of this.elements) {
|
|
331
|
+
const placement = placements.get(id)
|
|
332
|
+
if (!placement) continue
|
|
333
|
+
if (placement.mode !== 'hidden') {
|
|
334
|
+
const timer = this.fading.get(id)
|
|
335
|
+
if (timer !== undefined) this.cancel(timer)
|
|
336
|
+
this.fading.delete(id)
|
|
337
|
+
delete element.dataset.roomLabelHidden
|
|
338
|
+
placePill(element, placement)
|
|
339
|
+
continue
|
|
340
|
+
}
|
|
341
|
+
if (this.fading.has(id)) continue
|
|
342
|
+
element.dataset.roomLabelHidden = ''
|
|
343
|
+
this.fading.set(
|
|
344
|
+
id,
|
|
345
|
+
this.schedule(() => {
|
|
346
|
+
this.fading.delete(id)
|
|
347
|
+
this.setGone(new Set(this.gone).add(id))
|
|
348
|
+
}, fadeMs),
|
|
349
|
+
)
|
|
350
|
+
}
|
|
351
|
+
const kept = [...this.gone].filter((id) => {
|
|
352
|
+
const placement = placements.get(id)
|
|
353
|
+
return !placement || placement.mode === 'hidden'
|
|
354
|
+
})
|
|
355
|
+
if (kept.length !== this.gone.size) this.setGone(new Set(kept))
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** A pill mounting takes its last placement at once. */
|
|
359
|
+
mount(id: string, element: HTMLElement) {
|
|
360
|
+
const placement = this.placements.get(id)
|
|
361
|
+
if (placement?.mode === 'hidden') element.dataset.roomLabelHidden = ''
|
|
362
|
+
else if (placement) placePill(element, placement)
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Remount hidden pills invisibly so changed text or fonts can be measured. */
|
|
366
|
+
remeasure() {
|
|
367
|
+
if (this.gone.size) this.setGone(new Set())
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Fade every mounted pill out (the layer is leaving), or back in. */
|
|
371
|
+
showAll(shown: boolean) {
|
|
372
|
+
for (const [id, element] of this.elements) {
|
|
373
|
+
if (!shown) element.dataset.roomLabelHidden = ''
|
|
374
|
+
else if (!this.fading.has(id)) delete element.dataset.roomLabelHidden
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
dispose() {
|
|
379
|
+
for (const timer of this.fading.values()) this.cancel(timer)
|
|
380
|
+
this.fading.clear()
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
private setGone(gone: ReadonlySet<string>) {
|
|
384
|
+
this.gone = gone
|
|
385
|
+
this.onGone(gone)
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function placePill(element: HTMLElement, placement: PillPlacement) {
|
|
390
|
+
element.style.translate = placement.dy ? `0 ${placement.dy}px` : ''
|
|
391
|
+
if (placement.mode === 'dot') element.dataset.roomLabelCollapsed = ''
|
|
392
|
+
else delete element.dataset.roomLabelCollapsed
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** True while `shown`, and through the fade once it turns false; then false, so the pills unmount. */
|
|
396
|
+
export function useFadePresence(shown: boolean, onGone?: () => void): boolean {
|
|
397
|
+
const [present, setPresent] = useState(shown)
|
|
398
|
+
useEffect(() => {
|
|
399
|
+
if (shown) {
|
|
400
|
+
setPresent(true)
|
|
401
|
+
return
|
|
402
|
+
}
|
|
403
|
+
const timer = setTimeout(() => setPresent(false), pillFadeMs())
|
|
404
|
+
return () => clearTimeout(timer)
|
|
405
|
+
}, [shown])
|
|
406
|
+
const goneRef = useRef(onGone)
|
|
407
|
+
goneRef.current = onGone
|
|
408
|
+
const isPresent = shown || present
|
|
409
|
+
useEffect(() => {
|
|
410
|
+
if (!isPresent) goneRef.current?.()
|
|
411
|
+
}, [isPresent])
|
|
412
|
+
return isPresent
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* One pill layer per level: the current level's fades in, a level just left
|
|
417
|
+
* fades out over the next one, then unmounts.
|
|
418
|
+
*/
|
|
419
|
+
function useFadingLevels(levelId: string | null, visible: boolean) {
|
|
420
|
+
const [levels, setLevels] = useState<string[]>([])
|
|
421
|
+
useEffect(() => {
|
|
422
|
+
if (levelId && visible) {
|
|
423
|
+
setLevels((previous) => (previous.includes(levelId) ? previous : [...previous, levelId]))
|
|
424
|
+
}
|
|
425
|
+
}, [levelId, visible])
|
|
426
|
+
const drop = useCallback(
|
|
427
|
+
(id: string) => setLevels((previous) => previous.filter((level) => level !== id)),
|
|
428
|
+
[],
|
|
429
|
+
)
|
|
430
|
+
return { levels, drop }
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* The placement state of one layer: its mounted pills, the full size each
|
|
435
|
+
* last rendered at (observed, so a web font arriving, a rename or a unit
|
|
436
|
+
* change re-places them; kept for pills that have left the DOM), and the
|
|
437
|
+
* placer that moves, collapses and fades them.
|
|
438
|
+
*/
|
|
439
|
+
function usePillPlacement(shown: boolean, entries: readonly LabelEntry[]) {
|
|
440
|
+
const [elements] = useState(() => new PillElements())
|
|
441
|
+
const [clock] = useState(() => new DeclutterClock())
|
|
442
|
+
const [gone, setGone] = useState<ReadonlySet<string>>(() => new Set())
|
|
443
|
+
const [placer] = useState(() => new PillPlacer(elements, setGone))
|
|
444
|
+
const unit = useViewer((s) => s.unit)
|
|
445
|
+
const sizes = useRef(new Map<string, readonly [number, number]>())
|
|
446
|
+
const shownRef = useRef(shown)
|
|
447
|
+
shownRef.current = shown
|
|
448
|
+
useEffect(() => {
|
|
449
|
+
elements.onMeasure = (id, element) => {
|
|
450
|
+
const size = measureFullPill(element)
|
|
451
|
+
if (!(size[0] > 0 && size[1] > 0)) return
|
|
452
|
+
const previous = sizes.current.get(id)
|
|
453
|
+
if (
|
|
454
|
+
previous &&
|
|
455
|
+
Math.abs(previous[0] - size[0]) < 0.01 &&
|
|
456
|
+
Math.abs(previous[1] - size[1]) < 0.01
|
|
457
|
+
)
|
|
458
|
+
return
|
|
459
|
+
sizes.current.set(id, size)
|
|
460
|
+
clock.invalidate()
|
|
461
|
+
}
|
|
462
|
+
const observer =
|
|
463
|
+
typeof ResizeObserver === 'undefined'
|
|
464
|
+
? null
|
|
465
|
+
: new ResizeObserver((records) => {
|
|
466
|
+
for (const record of records) {
|
|
467
|
+
const element = record.target as HTMLElement
|
|
468
|
+
const id = element.dataset.roomLabel
|
|
469
|
+
const box = record.borderBoxSize?.[0]
|
|
470
|
+
if (!(id && box && box.inlineSize > 0)) continue
|
|
471
|
+
// A dot coming back on screen may have changed text while its
|
|
472
|
+
// wrapper was display:none, when its full width was unreadable.
|
|
473
|
+
if ('roomLabelCollapsed' in element.dataset) elements.measure(id)
|
|
474
|
+
else sizes.current.set(id, [box.inlineSize, box.blockSize])
|
|
475
|
+
}
|
|
476
|
+
clock.invalidate()
|
|
477
|
+
})
|
|
478
|
+
elements.onMount = (id, element) => {
|
|
479
|
+
if (!shownRef.current) element.dataset.roomLabelHidden = ''
|
|
480
|
+
placer.mount(id, element)
|
|
481
|
+
elements.measure(id)
|
|
482
|
+
observer?.observe(element)
|
|
483
|
+
clock.invalidate()
|
|
484
|
+
}
|
|
485
|
+
elements.onUnmount = (element) => {
|
|
486
|
+
observer?.unobserve(element)
|
|
487
|
+
clock.invalidate()
|
|
488
|
+
}
|
|
489
|
+
for (const [id, element] of elements) elements.onMount(id, element)
|
|
490
|
+
const fontsChanged = () => {
|
|
491
|
+
placer.remeasure()
|
|
492
|
+
for (const id of elements.keys()) elements.measure(id)
|
|
493
|
+
}
|
|
494
|
+
document.fonts?.addEventListener('loadingdone', fontsChanged)
|
|
495
|
+
return () => {
|
|
496
|
+
elements.onMount = null
|
|
497
|
+
elements.onUnmount = null
|
|
498
|
+
elements.onMeasure = null
|
|
499
|
+
document.fonts?.removeEventListener('loadingdone', fontsChanged)
|
|
500
|
+
observer?.disconnect()
|
|
501
|
+
placer.dispose()
|
|
502
|
+
}
|
|
503
|
+
}, [clock, elements, placer])
|
|
504
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: Hidden pills must remount to measure changed entries or units.
|
|
505
|
+
useEffect(() => {
|
|
506
|
+
placer.remeasure()
|
|
507
|
+
clock.invalidate()
|
|
508
|
+
}, [clock, entries, placer, unit])
|
|
509
|
+
useEffect(() => {
|
|
510
|
+
placer.showAll(shown)
|
|
511
|
+
if (shown) clock.invalidate()
|
|
512
|
+
}, [clock, placer, shown])
|
|
513
|
+
return { elements, clock, gone, placer, sizes: sizes.current }
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** The pills of one level: every detected room, and every drawn zone that bounds none. */
|
|
517
|
+
export function roomLabelEntries(
|
|
518
|
+
rooms: readonly RoomSelectionRecord[],
|
|
519
|
+
zones: readonly ZoneNode[],
|
|
520
|
+
): RoomLabelEntry[] {
|
|
521
|
+
const hiddenIds = new Set<string>(
|
|
522
|
+
zones.filter((zone) => zone.visible === false).map((zone) => zone.id),
|
|
523
|
+
)
|
|
524
|
+
const roomIds = new Set(rooms.map((room) => room.zoneId))
|
|
525
|
+
return [
|
|
526
|
+
...rooms
|
|
527
|
+
.filter((room) => !hiddenIds.has(room.zoneId))
|
|
528
|
+
.map((room) => ({
|
|
529
|
+
zoneId: room.zoneId,
|
|
530
|
+
polygon: room.polygon,
|
|
531
|
+
holes: room.holes,
|
|
532
|
+
area: room.area,
|
|
533
|
+
})),
|
|
534
|
+
...zones
|
|
535
|
+
.filter((zone) => zone.visible !== false && !roomIds.has(zone.id) && zone.polygon.length >= 3)
|
|
536
|
+
.map((zone) => ({
|
|
537
|
+
zoneId: zone.id,
|
|
538
|
+
polygon: zone.polygon,
|
|
539
|
+
holes: zone.holes ?? [],
|
|
540
|
+
area: area([{ outer: zone.polygon, holes: zone.holes ?? [] }]),
|
|
541
|
+
})),
|
|
542
|
+
]
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function useRoomLabelEntries(levelId: string | null): LabelEntry[] {
|
|
546
|
+
const rooms = useRoomRecords(levelId)
|
|
547
|
+
const zones = useScene(
|
|
548
|
+
useShallow((s) =>
|
|
549
|
+
levelId
|
|
550
|
+
? Object.values(s.nodes).filter(
|
|
551
|
+
(node): node is ZoneNode => node.type === 'zone' && node.parentId === levelId,
|
|
552
|
+
)
|
|
553
|
+
: [],
|
|
554
|
+
),
|
|
555
|
+
)
|
|
556
|
+
return useMemo(() => roomLabelEntries(rooms, zones), [rooms, zones])
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** The pill itself: a colour dot, the name, the area in the active unit. */
|
|
560
|
+
export function RoomPillChip({
|
|
561
|
+
zoneId,
|
|
562
|
+
view,
|
|
563
|
+
color,
|
|
564
|
+
name,
|
|
565
|
+
areaM2,
|
|
566
|
+
elements,
|
|
567
|
+
onClick,
|
|
568
|
+
onHover,
|
|
569
|
+
onLeave,
|
|
570
|
+
}: {
|
|
571
|
+
zoneId: string
|
|
572
|
+
view: '2d' | '3d'
|
|
573
|
+
color: string
|
|
574
|
+
name: string
|
|
575
|
+
areaM2: number
|
|
576
|
+
elements?: Map<string, HTMLElement>
|
|
577
|
+
onClick: (event: ReactMouseEvent) => void
|
|
578
|
+
onHover: (event: ReactPointerEvent) => void
|
|
579
|
+
onLeave: () => void
|
|
580
|
+
}) {
|
|
581
|
+
const unit = useViewer((s) => s.unit)
|
|
582
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: A dot's border box does not resize when its hidden text changes.
|
|
583
|
+
useLayoutEffect(() => {
|
|
584
|
+
if (elements instanceof PillElements) elements.measure(zoneId)
|
|
585
|
+
}, [areaM2, elements, name, unit, zoneId])
|
|
586
|
+
const register = useCallback(
|
|
587
|
+
(element: HTMLDivElement | null) => {
|
|
588
|
+
if (element) elements?.set(zoneId, element)
|
|
589
|
+
else elements?.delete(zoneId)
|
|
590
|
+
},
|
|
591
|
+
[elements, zoneId],
|
|
592
|
+
)
|
|
593
|
+
return (
|
|
594
|
+
<div
|
|
595
|
+
// Fades in as it mounts and out (taking no pointer) while the layer hides
|
|
596
|
+
// it; eases into its nudged place; collapsed, it is its dot until hovered.
|
|
597
|
+
className="pointer-events-auto flex cursor-pointer select-none items-center gap-1.5 whitespace-nowrap rounded-full border border-white/10 bg-neutral-950/75 px-2.5 py-1 text-white text-xs shadow-sm backdrop-blur-sm transition-[opacity,translate,background-color] duration-180 hover:bg-neutral-950/90 starting:opacity-0 data-room-label-hidden:pointer-events-none data-room-label-hidden:opacity-0 motion-reduce:transition-none [&[data-room-label-collapsed]:not(:hover)]:gap-0 [&[data-room-label-collapsed]:not(:hover)]:p-[3px]"
|
|
598
|
+
data-room-label={zoneId}
|
|
599
|
+
data-room-label-view={view}
|
|
600
|
+
onClick={(event) => {
|
|
601
|
+
event.stopPropagation()
|
|
602
|
+
onClick(event)
|
|
603
|
+
}}
|
|
604
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
605
|
+
// Move as well as enter: leaving the canvas for the pill ends the canvas
|
|
606
|
+
// hover after the pill's enter has run.
|
|
607
|
+
onPointerEnter={onHover}
|
|
608
|
+
onPointerLeave={onLeave}
|
|
609
|
+
onPointerMove={onHover}
|
|
610
|
+
ref={register}
|
|
611
|
+
>
|
|
612
|
+
<span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: color }} />
|
|
613
|
+
<span className="font-medium [[data-room-label-collapsed]:not(:hover)>&]:hidden">{name}</span>
|
|
614
|
+
<span className="font-mono text-white/60 tabular-nums [[data-room-label-collapsed]:not(:hover)>&]:hidden">
|
|
615
|
+
{formatRoomArea(areaM2, unit)}
|
|
616
|
+
</span>
|
|
617
|
+
</div>
|
|
618
|
+
)
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* One room's pill in the editor. Hover and click take the same rule as the
|
|
623
|
+
* zone's area in the plan (`hoverZoneArea`, `clickZoneArea`); only the pill
|
|
624
|
+
* itself takes the pointer.
|
|
625
|
+
*/
|
|
626
|
+
function RoomPill({
|
|
627
|
+
entry,
|
|
628
|
+
view,
|
|
629
|
+
elements,
|
|
630
|
+
}: {
|
|
631
|
+
entry: LabelEntry
|
|
632
|
+
view: '2d' | '3d'
|
|
633
|
+
elements?: Map<string, HTMLElement>
|
|
634
|
+
}) {
|
|
635
|
+
const zone = useScene((s) => s.nodes[entry.zoneId as AnyNodeId] as ZoneNode | undefined)
|
|
636
|
+
// A pill that disappears under the cursor (its click selected something)
|
|
637
|
+
// fires no leave: end the hover it set.
|
|
638
|
+
useEffect(() => () => leaveZoneArea(entry.zoneId), [entry.zoneId])
|
|
639
|
+
if (!zone || zone.visible === false) return null
|
|
640
|
+
return (
|
|
641
|
+
<RoomPillChip
|
|
642
|
+
areaM2={entry.area}
|
|
643
|
+
color={zone.color}
|
|
644
|
+
elements={elements}
|
|
645
|
+
name={zone.name?.trim() || zoneKindLabel(zone)}
|
|
646
|
+
onClick={(event) => clickZoneArea(entry.zoneId, selectionModifiersFromEvent(event))}
|
|
647
|
+
onHover={(event) => hoverZoneArea(entry.zoneId, selectionModifiersFromEvent(event))}
|
|
648
|
+
onLeave={() => leaveZoneArea(entry.zoneId)}
|
|
649
|
+
view={view}
|
|
650
|
+
zoneId={entry.zoneId}
|
|
651
|
+
/>
|
|
652
|
+
)
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/** Light name-and-area pills over each room and drawn zone of the active level. */
|
|
656
|
+
export function RoomLabels3D() {
|
|
657
|
+
const levelId = useViewer((s) => s.selection.levelId)
|
|
658
|
+
const sceneShown = useEditor((s) => isViewVisible(s, VIEW_3D))
|
|
659
|
+
const visible = useRoomLabelsVisible() && sceneShown
|
|
660
|
+
const { levels, drop } = useFadingLevels(levelId, visible)
|
|
661
|
+
return levels.map((id) => (
|
|
662
|
+
<LevelRoomLabels3D key={id} levelId={id} onGone={drop} shown={visible && id === levelId} />
|
|
663
|
+
))
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function LevelRoomLabels3D({
|
|
667
|
+
levelId,
|
|
668
|
+
shown,
|
|
669
|
+
onGone,
|
|
670
|
+
}: {
|
|
671
|
+
levelId: string
|
|
672
|
+
shown: boolean
|
|
673
|
+
onGone: (levelId: string) => void
|
|
674
|
+
}) {
|
|
675
|
+
const present = useFadePresence(shown, () => onGone(levelId))
|
|
676
|
+
const entries = useRoomLabelEntries(present ? levelId : null)
|
|
677
|
+
if (!present) return null
|
|
678
|
+
return (
|
|
679
|
+
<RoomPillsLayer3D
|
|
680
|
+
entries={entries}
|
|
681
|
+
levelObject={() => sceneRegistry.nodes.get(levelId)}
|
|
682
|
+
renderLabel={(entry, anchors, elements) => (
|
|
683
|
+
<EditorRoomLabel3D anchors={anchors} elements={elements} entry={entry} />
|
|
684
|
+
)}
|
|
685
|
+
shown={shown}
|
|
686
|
+
/>
|
|
687
|
+
)
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function EditorRoomLabel3D({
|
|
691
|
+
entry,
|
|
692
|
+
anchors,
|
|
693
|
+
elements,
|
|
694
|
+
}: {
|
|
695
|
+
entry: LabelEntry
|
|
696
|
+
anchors: Map<string, Vector3>
|
|
697
|
+
elements: Map<string, HTMLElement>
|
|
698
|
+
}) {
|
|
699
|
+
const floorY = useScene((s) => roomFloorElevation(s.nodes, entry.zoneId))
|
|
700
|
+
return (
|
|
701
|
+
<RoomLabelAnchor3D anchors={anchors} entry={entry} floorY={floorY}>
|
|
702
|
+
<RoomPill elements={elements} entry={entry} view="3d" />
|
|
703
|
+
</RoomLabelAnchor3D>
|
|
704
|
+
)
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* The pills of one level in 3D, riding the level's object (so they follow
|
|
709
|
+
* level stacking) and placed from where their anchors land on screen. They
|
|
710
|
+
* live in their own stacking layer clipped to the canvas, so every piece of
|
|
711
|
+
* UI over the canvas stays above them.
|
|
712
|
+
*/
|
|
713
|
+
export function RoomPillsLayer3D({
|
|
714
|
+
entries,
|
|
715
|
+
levelObject,
|
|
716
|
+
renderLabel,
|
|
717
|
+
shown = true,
|
|
718
|
+
}: {
|
|
719
|
+
entries: readonly LabelEntry[]
|
|
720
|
+
levelObject: () => Object3D | undefined
|
|
721
|
+
renderLabel: (
|
|
722
|
+
entry: LabelEntry,
|
|
723
|
+
anchors: Map<string, Vector3>,
|
|
724
|
+
elements: Map<string, HTMLElement>,
|
|
725
|
+
) => ReactNode
|
|
726
|
+
shown?: boolean
|
|
727
|
+
}) {
|
|
728
|
+
const ref = useRef<Group>(null)
|
|
729
|
+
const anchors = useRef(new Map<string, Vector3>())
|
|
730
|
+
const view = useRef(new Float64Array(50))
|
|
731
|
+
const { elements, clock, gone, placer, sizes } = usePillPlacement(shown, entries)
|
|
732
|
+
const host = useThree((s) => (s.events.connected ?? s.gl.domElement.parentNode) as HTMLElement)
|
|
733
|
+
const [container] = useState(() =>
|
|
734
|
+
typeof document === 'undefined' ? null : document.createElement('div'),
|
|
735
|
+
)
|
|
736
|
+
const portal = useMemo(() => (container ? { current: container } : null), [container])
|
|
737
|
+
useEffect(() => {
|
|
738
|
+
if (!(host && container)) return
|
|
739
|
+
container.className = 'pointer-events-none absolute inset-0 isolate z-0 overflow-hidden'
|
|
740
|
+
host.appendChild(container)
|
|
741
|
+
return () => container.remove()
|
|
742
|
+
}, [container, host])
|
|
743
|
+
|
|
744
|
+
useEffect(() => {
|
|
745
|
+
const ids = new Set(entries.map((entry) => entry.zoneId))
|
|
746
|
+
for (const id of anchors.current.keys()) if (!ids.has(id)) anchors.current.delete(id)
|
|
747
|
+
clock.invalidate()
|
|
748
|
+
}, [clock, entries])
|
|
749
|
+
|
|
750
|
+
useFrame(({ camera, clock: frameClock, size }) => {
|
|
751
|
+
if (!ref.current) return
|
|
752
|
+
const level = levelObject()
|
|
753
|
+
ref.current.visible = !!level
|
|
754
|
+
if (!level) return
|
|
755
|
+
level.updateWorldMatrix(true, false)
|
|
756
|
+
ref.current.matrix.copy(level.matrixWorld)
|
|
757
|
+
|
|
758
|
+
const flat = view.current
|
|
759
|
+
flat.set(camera.matrixWorld.elements, 0)
|
|
760
|
+
flat.set(camera.projectionMatrix.elements, 16)
|
|
761
|
+
flat.set(level.matrixWorld.elements, 32)
|
|
762
|
+
flat[48] = size.width
|
|
763
|
+
flat[49] = size.height
|
|
764
|
+
if (!(shown && clock.shouldRead(flat, frameClock.elapsedTime))) return
|
|
765
|
+
|
|
766
|
+
const candidates: PillCandidate[] = []
|
|
767
|
+
for (const entry of entries) {
|
|
768
|
+
const anchor = anchors.current.get(entry.zoneId)
|
|
769
|
+
const pill = sizes.get(entry.zoneId)
|
|
770
|
+
if (!(anchor && pill)) continue
|
|
771
|
+
labelWorld.copy(anchor).applyMatrix4(level.matrixWorld).project(camera)
|
|
772
|
+
// Behind the camera or past its far plane: not on screen to collide.
|
|
773
|
+
if (Math.abs(labelWorld.z) > 1) continue
|
|
774
|
+
candidates.push({
|
|
775
|
+
id: entry.zoneId,
|
|
776
|
+
x: ((labelWorld.x + 1) / 2) * size.width,
|
|
777
|
+
y: ((1 - labelWorld.y) / 2) * size.height,
|
|
778
|
+
size: pill,
|
|
779
|
+
area: entry.area,
|
|
780
|
+
})
|
|
781
|
+
}
|
|
782
|
+
placer.apply(placePills(candidates))
|
|
783
|
+
})
|
|
784
|
+
|
|
785
|
+
if (entries.length === 0 || !portal) return null
|
|
786
|
+
return (
|
|
787
|
+
<PillPortalContext.Provider value={portal}>
|
|
788
|
+
<HiddenPillsContext.Provider value={gone}>
|
|
789
|
+
<group matrixAutoUpdate={false} ref={ref}>
|
|
790
|
+
{entries.map((entry) => (
|
|
791
|
+
<Fragment key={entry.zoneId}>{renderLabel(entry, anchors.current, elements)}</Fragment>
|
|
792
|
+
))}
|
|
793
|
+
</group>
|
|
794
|
+
</HiddenPillsContext.Provider>
|
|
795
|
+
</PillPortalContext.Provider>
|
|
796
|
+
)
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
const PillPortalContext = createContext<{ current: HTMLElement } | null>(null)
|
|
800
|
+
const HiddenPillsContext = createContext<ReadonlySet<string>>(new Set())
|
|
801
|
+
|
|
802
|
+
/** Where a room's pill floats: over its interior, at mid-storey above its floor. */
|
|
803
|
+
export function RoomLabelAnchor3D({
|
|
804
|
+
entry,
|
|
805
|
+
floorY,
|
|
806
|
+
anchors,
|
|
807
|
+
children,
|
|
808
|
+
}: {
|
|
809
|
+
entry: LabelEntry
|
|
810
|
+
floorY: number
|
|
811
|
+
anchors: Map<string, Vector3>
|
|
812
|
+
children: ReactNode
|
|
813
|
+
}) {
|
|
814
|
+
const portal = useContext(PillPortalContext)
|
|
815
|
+
const gone = useContext(HiddenPillsContext)
|
|
816
|
+
const position = useMemo(() => {
|
|
817
|
+
const [x, z] = polygonInteriorPoint({ polygon: entry.polygon, holes: entry.holes })
|
|
818
|
+
return new Vector3(x, floorY + LABEL_HEIGHT, z)
|
|
819
|
+
}, [entry.polygon, entry.holes, floorY])
|
|
820
|
+
// Hidden HTML still needs a live anchor so moving a room can give its pill
|
|
821
|
+
// room again. The layer drops anchors of rooms that left.
|
|
822
|
+
useEffect(() => {
|
|
823
|
+
anchors.set(entry.zoneId, position)
|
|
824
|
+
}, [anchors, entry.zoneId, position])
|
|
825
|
+
if (gone.has(entry.zoneId)) return null
|
|
826
|
+
return (
|
|
827
|
+
<Html
|
|
828
|
+
center
|
|
829
|
+
portal={portal ?? undefined}
|
|
830
|
+
position={position}
|
|
831
|
+
// The wrapper lets the canvas keep everything but the pill itself; a
|
|
832
|
+
// hovered pill (a dot opening to its full pill) comes to the front.
|
|
833
|
+
style={{ pointerEvents: 'none' }}
|
|
834
|
+
wrapperClass="has-[[data-room-label]:hover]:z-30!"
|
|
835
|
+
zIndexRange={[20, 0]}
|
|
836
|
+
>
|
|
837
|
+
{children}
|
|
838
|
+
</Html>
|
|
839
|
+
)
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* The same pills over the 2D plan: screen-positioned from the plan's transform,
|
|
844
|
+
* re-placed as in 3D once the plan rests, and clipped to the plan's pane in the
|
|
845
|
+
* plan's own stacking layer, under the UI over it.
|
|
846
|
+
*/
|
|
847
|
+
export function RoomLabels2D({ levelId }: { levelId: string | null }) {
|
|
848
|
+
const planShown = useEditor((s) => isViewVisible(s, VIEW_2D))
|
|
849
|
+
const visible = useRoomLabelsVisible() && planShown
|
|
850
|
+
const { levels, drop } = useFadingLevels(levelId, visible)
|
|
851
|
+
return levels.map((id) => (
|
|
852
|
+
<LevelRoomLabels2D key={id} levelId={id} onGone={drop} shown={visible && id === levelId} />
|
|
853
|
+
))
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
function LevelRoomLabels2D({
|
|
857
|
+
levelId,
|
|
858
|
+
shown,
|
|
859
|
+
onGone,
|
|
860
|
+
}: {
|
|
861
|
+
levelId: string
|
|
862
|
+
shown: boolean
|
|
863
|
+
onGone: (levelId: string) => void
|
|
864
|
+
}) {
|
|
865
|
+
const present = useFadePresence(shown, () => onGone(levelId))
|
|
866
|
+
const entries = useRoomLabelEntries(present ? levelId : null)
|
|
867
|
+
const ref = useRef<SVGGElement>(null)
|
|
868
|
+
const paneRef = useRef<HTMLDivElement>(null)
|
|
869
|
+
const [host, setHost] = useState<HTMLElement | null>(null)
|
|
870
|
+
const [nodes] = useState(() => new PillElements())
|
|
871
|
+
const { elements, clock, gone, placer, sizes } = usePillPlacement(shown, entries)
|
|
872
|
+
const shownRef = useRef(shown)
|
|
873
|
+
shownRef.current = shown
|
|
874
|
+
const anchors = useMemo(
|
|
875
|
+
() =>
|
|
876
|
+
new Map(
|
|
877
|
+
entries.map((entry) => [
|
|
878
|
+
entry.zoneId,
|
|
879
|
+
polygonInteriorPoint({ polygon: entry.polygon, holes: entry.holes }),
|
|
880
|
+
]),
|
|
881
|
+
),
|
|
882
|
+
[entries],
|
|
883
|
+
)
|
|
884
|
+
|
|
885
|
+
useEffect(() => {
|
|
886
|
+
setHost(ref.current?.ownerSVGElement?.parentElement ?? null)
|
|
887
|
+
}, [])
|
|
888
|
+
|
|
889
|
+
useEffect(() => {
|
|
890
|
+
if (!present || entries.length === 0) return
|
|
891
|
+
let raf = 0
|
|
892
|
+
let lastKey = ''
|
|
893
|
+
const view = new Float64Array(10)
|
|
894
|
+
// A pill wrapper that mounts is placed on the next frame.
|
|
895
|
+
nodes.onMount = () => {
|
|
896
|
+
lastKey = ''
|
|
897
|
+
}
|
|
898
|
+
clock.invalidate()
|
|
899
|
+
const tick = (now: number) => {
|
|
900
|
+
raf = requestAnimationFrame(tick)
|
|
901
|
+
const group = ref.current
|
|
902
|
+
const svg = group?.ownerSVGElement
|
|
903
|
+
const ctm = group?.getScreenCTM()
|
|
904
|
+
const paneElement = paneRef.current
|
|
905
|
+
if (!(svg && ctm && paneElement)) return
|
|
906
|
+
const pane = svg.getBoundingClientRect()
|
|
907
|
+
const origin = paneElement.offsetParent?.getBoundingClientRect() ?? { left: 0, top: 0 }
|
|
908
|
+
const key = `${ctm.a},${ctm.b},${ctm.c},${ctm.d},${ctm.e},${ctm.f},${pane.left},${pane.top},${pane.width},${pane.height},${origin.left},${origin.top}`
|
|
909
|
+
const point = svg.createSVGPoint()
|
|
910
|
+
const place = (anchor: [number, number]) => {
|
|
911
|
+
point.x = anchor[0]
|
|
912
|
+
point.y = anchor[1]
|
|
913
|
+
const screen = point.matrixTransform(ctm)
|
|
914
|
+
return [screen.x - pane.left, screen.y - pane.top] as const
|
|
915
|
+
}
|
|
916
|
+
const inside = ([x, y]: readonly [number, number]) =>
|
|
917
|
+
x >= 0 && x <= pane.width && y >= 0 && y <= pane.height
|
|
918
|
+
if (key !== lastKey) {
|
|
919
|
+
lastKey = key
|
|
920
|
+
Object.assign(paneElement.style, {
|
|
921
|
+
left: `${pane.left - origin.left}px`,
|
|
922
|
+
top: `${pane.top - origin.top}px`,
|
|
923
|
+
width: `${pane.width}px`,
|
|
924
|
+
height: `${pane.height}px`,
|
|
925
|
+
})
|
|
926
|
+
for (const [id, element] of nodes) {
|
|
927
|
+
const anchor = anchors.get(id)
|
|
928
|
+
if (!anchor) continue
|
|
929
|
+
const at = place(anchor)
|
|
930
|
+
element.style.left = `${at[0]}px`
|
|
931
|
+
element.style.top = `${at[1]}px`
|
|
932
|
+
element.style.display = inside(at) ? '' : 'none'
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
view[0] = ctm.a
|
|
936
|
+
view[1] = ctm.b
|
|
937
|
+
view[2] = ctm.c
|
|
938
|
+
view[3] = ctm.d
|
|
939
|
+
view[4] = ctm.e
|
|
940
|
+
view[5] = ctm.f
|
|
941
|
+
view[6] = pane.left
|
|
942
|
+
view[7] = pane.top
|
|
943
|
+
view[8] = pane.width
|
|
944
|
+
view[9] = pane.height
|
|
945
|
+
if (!(shownRef.current && clock.shouldRead(view, now / 1000))) return
|
|
946
|
+
const candidates: PillCandidate[] = []
|
|
947
|
+
for (const entry of entries) {
|
|
948
|
+
const anchor = anchors.get(entry.zoneId)
|
|
949
|
+
const pill = sizes.get(entry.zoneId)
|
|
950
|
+
if (!(anchor && pill)) continue
|
|
951
|
+
const at = place(anchor)
|
|
952
|
+
if (!inside(at)) continue
|
|
953
|
+
candidates.push({ id: entry.zoneId, x: at[0], y: at[1], size: pill, area: entry.area })
|
|
954
|
+
}
|
|
955
|
+
placer.apply(placePills(candidates))
|
|
956
|
+
}
|
|
957
|
+
raf = requestAnimationFrame(tick)
|
|
958
|
+
return () => {
|
|
959
|
+
cancelAnimationFrame(raf)
|
|
960
|
+
nodes.onMount = null
|
|
961
|
+
}
|
|
962
|
+
}, [anchors, clock, entries, nodes, placer, present, sizes])
|
|
963
|
+
|
|
964
|
+
return (
|
|
965
|
+
<g ref={ref}>
|
|
966
|
+
{present &&
|
|
967
|
+
host &&
|
|
968
|
+
createPortal(
|
|
969
|
+
<div className="pointer-events-none absolute isolate z-0 overflow-hidden" ref={paneRef}>
|
|
970
|
+
{entries
|
|
971
|
+
.filter((entry) => !gone.has(entry.zoneId))
|
|
972
|
+
.map((entry) => (
|
|
973
|
+
<div
|
|
974
|
+
className="pointer-events-none absolute -translate-x-1/2 -translate-y-1/2 has-[[data-room-label]:hover]:z-10"
|
|
975
|
+
key={entry.zoneId}
|
|
976
|
+
ref={(element) => {
|
|
977
|
+
if (element) nodes.set(entry.zoneId, element)
|
|
978
|
+
else nodes.delete(entry.zoneId)
|
|
979
|
+
}}
|
|
980
|
+
style={{ display: 'none' }}
|
|
981
|
+
>
|
|
982
|
+
<RoomPill elements={elements} entry={entry} view="2d" />
|
|
983
|
+
</div>
|
|
984
|
+
))}
|
|
985
|
+
</div>,
|
|
986
|
+
host,
|
|
987
|
+
)}
|
|
988
|
+
</g>
|
|
989
|
+
)
|
|
990
|
+
}
|