@pascal-app/editor 0.8.0 → 0.9.2
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 +15 -13
- package/src/components/editor/alignment-3d-guide-layer.tsx +168 -0
- package/src/components/editor/bake-exporter.tsx +47 -0
- package/src/components/editor/custom-camera-controls.tsx +795 -28
- package/src/components/editor/editor-layout-mobile.tsx +8 -3
- package/src/components/editor/editor-layout-v2.tsx +76 -51
- package/src/components/editor/export-manager.tsx +69 -32
- package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
- package/src/components/editor/first-person/build-collider-world.test.ts +182 -0
- package/src/components/editor/first-person/build-collider-world.ts +228 -83
- package/src/components/editor/first-person-controls.tsx +943 -27
- package/src/components/editor/floating-action-menu.tsx +676 -208
- package/src/components/editor/floating-building-action-menu.tsx +2 -2
- package/src/components/editor/floorplan-background-selection.test.ts +58 -0
- package/src/components/editor/floorplan-background-selection.ts +3 -2
- package/src/components/editor/floorplan-panel.tsx +6916 -12137
- package/src/components/editor/grid.tsx +197 -36
- package/src/components/editor/group-actions.ts +457 -0
- package/src/components/editor/group-floating-action-menu.tsx +130 -0
- package/src/components/editor/group-move-3d.ts +397 -0
- package/src/components/editor/group-rotate-handle.tsx +420 -0
- package/src/components/editor/group-selection-box-3d.tsx +173 -0
- package/src/components/editor/group-transform-shared.test.ts +396 -0
- package/src/components/editor/group-transform-shared.ts +489 -0
- package/src/components/editor/handles/handle-arrow.tsx +581 -0
- package/src/components/editor/handles/use-handle-drag.ts +246 -0
- package/src/components/editor/index.tsx +412 -162
- package/src/components/editor/measurement-pill.tsx +115 -0
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/node-arrow-handles.tsx +1646 -0
- package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
- package/src/components/editor/quick-measurement-card.tsx +70 -0
- package/src/components/editor/quick-measurement-hud.tsx +28 -0
- package/src/components/editor/riser-diagram-panel.tsx +137 -0
- package/src/components/editor/selection-manager.tsx +1106 -483
- package/src/components/editor/site-edge-labels.tsx +45 -21
- package/src/components/editor/slab-hole-highlights.tsx +474 -0
- package/src/components/editor/snapshot-capture-overlay.tsx +369 -155
- package/src/components/editor/three-context-bridge.ts +22 -0
- package/src/components/editor/thumbnail-generator.tsx +140 -41
- package/src/components/editor/use-floorplan-background-placement.ts +190 -35
- package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
- package/src/components/editor/use-floorplan-scene-data.ts +19 -7
- package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
- package/src/components/editor/wall-measurement-label.tsx +14 -19
- package/src/components/editor/wall-move-side-handles.tsx +870 -0
- package/src/components/editor/wall-opening-highlights.tsx +181 -0
- package/src/components/editor/wall-snap-beacon-layer.tsx +304 -0
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +15 -6
- package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
- package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
- package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
- package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
- package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
- package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +392 -0
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +857 -0
- package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
- package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +77 -0
- package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
- package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
- package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -66
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +62 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +3325 -0
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +24 -9
- package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
- package/src/components/editor-2d/svg-paths.ts +10 -2
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +439 -57
- package/src/components/systems/ceiling/ceiling-system.tsx +109 -12
- package/src/components/systems/roof/roof-edit-system.tsx +1851 -22
- package/src/components/systems/selection-affordance-manager.tsx +38 -0
- package/src/components/systems/stair/stair-edit-system.tsx +1 -1
- package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
- package/src/components/systems/zone/zone-system.tsx +42 -13
- package/src/components/tools/elevator/elevator-defaults.ts +8 -0
- package/src/components/tools/elevator/elevator-tool.tsx +375 -0
- package/src/components/tools/elevator/move-elevator-tool.tsx +263 -0
- package/src/components/tools/fence/fence-drafting.ts +141 -27
- package/src/components/tools/item/move-tool.tsx +45 -105
- package/src/components/tools/item/placement-math.test.ts +22 -0
- package/src/components/tools/item/placement-math.ts +59 -7
- package/src/components/tools/item/placement-strategies.ts +432 -16
- package/src/components/tools/item/placement-types.ts +22 -1
- package/src/components/tools/item/use-draft-node.ts +51 -2
- package/src/components/tools/item/use-placement-coordinator.tsx +1399 -256
- package/src/components/tools/registry/move-registry-node-tool.tsx +1064 -0
- package/src/components/tools/roof/roof-tool.tsx +350 -28
- package/src/components/tools/select/box-select-state.ts +88 -0
- package/src/components/tools/select/box-select-tool.tsx +442 -520
- package/src/components/tools/select/marquee-geometry.test.ts +161 -0
- package/src/components/tools/select/marquee-geometry.ts +155 -0
- package/src/components/tools/select/plane-box-select-tool.tsx +570 -0
- package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
- package/src/components/tools/select/select-candidates.test.ts +109 -0
- package/src/components/tools/select/select-candidates.ts +85 -0
- package/src/components/tools/shared/affordance-dispatch.ts +30 -0
- package/src/components/tools/shared/cursor-sphere.tsx +62 -26
- package/src/components/tools/shared/drag-bounding-box.tsx +195 -0
- package/src/components/tools/shared/facing-indicator.tsx +87 -0
- package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
- package/src/components/tools/shared/floor-stack-preview.ts +25 -0
- package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
- package/src/components/tools/shared/placement-box-geometry.ts +123 -0
- package/src/components/tools/shared/placement-box.tsx +305 -0
- package/src/components/tools/shared/polygon-editor.tsx +866 -175
- package/src/components/tools/shared/segment-angle.ts +56 -0
- package/src/components/tools/site/site-boundary-editor.tsx +520 -16
- package/src/components/tools/site/site-flag-model.ts +18 -0
- package/src/components/tools/stair/stair-defaults.ts +2 -1
- package/src/components/tools/stair/stair-tool.tsx +342 -37
- package/src/components/tools/tool-manager.tsx +239 -62
- package/src/components/tools/wall/wall-drafting.test.ts +330 -0
- package/src/components/tools/wall/wall-drafting.ts +248 -171
- package/src/components/tools/wall/wall-snap-geometry.test.ts +123 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +331 -0
- package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
- package/src/components/tools/zone/zone-tool.tsx +86 -82
- package/src/components/ui/action-menu/action-button.tsx +21 -1
- package/src/components/ui/action-menu/camera-actions.tsx +24 -17
- package/src/components/ui/action-menu/control-modes.tsx +40 -170
- package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
- package/src/components/ui/action-menu/index.tsx +3 -117
- package/src/components/ui/action-menu/measurement-control.tsx +188 -0
- package/src/components/ui/action-menu/structure-tools.tsx +27 -93
- package/src/components/ui/action-menu/view-toggles.tsx +279 -90
- package/src/components/ui/command-palette/editor-commands.tsx +20 -15
- package/src/components/ui/command-palette/index.tsx +5 -3
- package/src/components/ui/controls/material-paint-panel.tsx +152 -0
- package/src/components/ui/controls/material-picker.tsx +87 -177
- package/src/components/ui/controls/material-properties-editor.tsx +108 -0
- package/src/components/ui/controls/metric-control.tsx +142 -46
- package/src/components/ui/controls/scene-material-list.tsx +247 -0
- package/src/components/ui/controls/slider-control.tsx +88 -38
- package/src/components/ui/floating-level-selector.tsx +47 -10
- package/src/components/ui/helpers/building-helper.tsx +10 -23
- package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
- package/src/components/ui/helpers/helper-manager.tsx +235 -23
- package/src/components/ui/helpers/item-helper.tsx +28 -32
- package/src/components/ui/helpers/registered-tool-helper.tsx +59 -0
- package/src/components/ui/helpers/roof-helper.tsx +10 -12
- package/src/components/ui/icon-ref.tsx +47 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +40 -1
- package/src/components/ui/item-catalog/item-catalog.tsx +16 -36
- package/src/components/ui/level-duplicate-dialog.tsx +2 -1
- package/src/components/ui/panels/node-display.ts +17 -16
- package/src/components/ui/panels/panel-manager.tsx +55 -58
- package/src/components/ui/panels/panel-wrapper.tsx +238 -12
- package/src/components/ui/panels/parametric-inspector.tsx +482 -0
- package/src/components/ui/panels/reference-panel.tsx +54 -19
- package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
- package/src/components/ui/primitives/shortcut-token.tsx +39 -3
- package/src/components/ui/primitives/sidebar.tsx +1 -0
- package/src/components/ui/scene-loader.tsx +1 -3
- package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
- package/src/components/ui/sidebar/icon-rail.tsx +50 -31
- package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
- package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
- package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +122 -41
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +66 -7
- package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +267 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +9 -5
- package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +86 -0
- package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +92 -0
- package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +3 -3
- package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +86 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +42 -21
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +140 -0
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +48 -8
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +132 -0
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +9 -5
- package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +86 -0
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +7 -1
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +112 -39
- package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +51 -2
- package/src/components/ui/sidebar/tab-bar.tsx +75 -1
- package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
- package/src/components/ui/snap-target-badge.test.tsx +40 -0
- package/src/components/ui/snap-target-badge.tsx +88 -0
- package/src/components/viewer-overlay.tsx +253 -61
- package/src/components/viewer-zone-system.tsx +7 -2
- package/src/hooks/use-auto-save.test.ts +14 -0
- package/src/hooks/use-auto-save.ts +64 -10
- package/src/hooks/use-ceiling-events.ts +176 -0
- package/src/hooks/use-drag-action.ts +127 -0
- package/src/hooks/use-keyboard.ts +493 -95
- package/src/hooks/use-selection.ts +64 -0
- package/src/index.tsx +451 -6
- package/src/lib/active-placement-surface.ts +35 -0
- package/src/lib/ceiling-plan-snap.ts +24 -0
- package/src/lib/constants.ts +6 -2
- package/src/lib/contextual-help.test.ts +112 -0
- package/src/lib/contextual-help.ts +144 -0
- package/src/lib/continuation.ts +64 -0
- package/src/lib/direct-manipulation.test.ts +192 -0
- package/src/lib/direct-manipulation.ts +109 -0
- package/src/lib/editor-api.ts +50 -0
- package/src/lib/elevator-support.ts +96 -0
- package/src/lib/floorplan/apply-alignment.test.ts +25 -0
- package/src/lib/floorplan/apply-alignment.ts +117 -0
- package/src/lib/floorplan/floorplan-export.tsx +364 -0
- package/src/lib/floorplan/geometry.ts +53 -0
- package/src/lib/floorplan/index.ts +10 -0
- package/src/lib/floorplan/items.ts +5 -2
- package/src/lib/floorplan/plan-coords.ts +21 -0
- package/src/lib/floorplan/selection-tool.ts +40 -0
- package/src/lib/floorplan/stairs.ts +12 -1
- package/src/lib/fresh-planar-placement.test.ts +339 -0
- package/src/lib/fresh-planar-placement.ts +130 -0
- package/src/lib/glb-export.test.ts +441 -0
- package/src/lib/glb-export.ts +874 -0
- package/src/lib/history.ts +11 -1
- package/src/lib/interaction/hot-set.test.ts +133 -0
- package/src/lib/interaction/hot-set.ts +67 -0
- package/src/lib/interaction/overlay-policy.test.ts +51 -0
- package/src/lib/interaction/overlay-policy.ts +59 -0
- package/src/lib/interaction/scope.ts +211 -0
- package/src/lib/level-duplication.ts +54 -42
- package/src/lib/level-selection.ts +2 -2
- package/src/lib/material-paint.ts +227 -48
- package/src/lib/measurement-kind.test.ts +30 -0
- package/src/lib/measurement-kind.ts +19 -0
- package/src/lib/measurement-label.test.ts +47 -0
- package/src/lib/measurement-label.ts +68 -0
- package/src/lib/measurement-parser.ts +116 -0
- package/src/lib/measurements.test.ts +183 -0
- package/src/lib/measurements.ts +199 -0
- package/src/lib/paint-scope.test.ts +454 -0
- package/src/lib/paint-scope.ts +461 -0
- package/src/lib/placement-drag-release.ts +23 -0
- package/src/lib/placement-metadata.ts +29 -0
- package/src/lib/planar-cursor-placement.test.ts +100 -0
- package/src/lib/planar-cursor-placement.ts +42 -0
- package/src/lib/plugin-panels.test.ts +19 -0
- package/src/lib/plugin-panels.ts +91 -0
- package/src/lib/quick-measurement.test.ts +77 -0
- package/src/lib/quick-measurement.ts +109 -0
- package/src/lib/roof-duplication.ts +7 -7
- package/src/lib/roof-hover-outline-proxy.ts +22 -0
- package/src/lib/roof-wall-hit.ts +164 -0
- package/src/lib/scene-clipboard.test.ts +196 -0
- package/src/lib/scene-clipboard.ts +332 -0
- package/src/lib/scene.ts +40 -8
- package/src/lib/selection-routing.test.ts +298 -0
- package/src/lib/selection-routing.ts +175 -0
- package/src/lib/sfx/index.ts +1 -0
- package/src/lib/sfx/movement-tick.test.ts +65 -0
- package/src/lib/sfx/movement-tick.ts +18 -0
- package/src/lib/sfx-bus.ts +69 -10
- package/src/lib/sfx-player.test.ts +124 -0
- package/src/lib/sfx-player.ts +148 -55
- package/src/lib/site-boundary.ts +1 -0
- package/src/lib/slab-plan-snap.test.ts +93 -0
- package/src/lib/slab-plan-snap.ts +108 -0
- package/src/lib/snapping-mode.test.ts +138 -0
- package/src/lib/snapping-mode.ts +179 -0
- package/src/lib/stair-duplication.ts +4 -4
- package/src/lib/stair-levels.test.ts +146 -0
- package/src/lib/stair-levels.ts +177 -0
- package/src/lib/surface-plan-snap.test.ts +71 -0
- package/src/lib/surface-plan-snap.ts +256 -0
- package/src/lib/use-linear-display.ts +40 -0
- package/src/lib/world-grid-snap.ts +231 -0
- package/src/lib/zone-content.ts +130 -0
- package/src/store/use-alignment-guides.ts +21 -0
- package/src/store/use-direct-manipulation-feedback.ts +20 -0
- package/src/store/use-editor.tsx +767 -114
- package/src/store/use-facing-pose.ts +44 -0
- package/src/store/use-fence-curve-draft.ts +21 -0
- package/src/store/use-floorplan-draft-preview.ts +101 -0
- package/src/store/use-floorplan-marquee.ts +50 -0
- package/src/store/use-interaction-scope.test.ts +186 -0
- package/src/store/use-interaction-scope.ts +132 -0
- package/src/store/use-measurement-draft.test.ts +530 -0
- package/src/store/use-measurement-draft.ts +543 -0
- package/src/store/use-opening-guides.ts +41 -0
- package/src/store/use-placement-preview.ts +39 -0
- package/src/store/use-quick-measurement-hud.test.ts +53 -0
- package/src/store/use-quick-measurement-hud.ts +77 -0
- package/src/store/use-segment-draft-chain.ts +28 -0
- package/src/store/use-stair-build-preview.ts +43 -0
- package/src/store/use-wall-move-ghosts.ts +36 -0
- package/src/store/use-wall-snap-indicator.ts +34 -0
- package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
- package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
- package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
- package/src/components/tools/building/move-building-tool.tsx +0 -157
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
- package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -264
- package/src/components/tools/column/column-tool.tsx +0 -97
- package/src/components/tools/column/move-column-tool.tsx +0 -105
- package/src/components/tools/door/door-math.ts +0 -110
- package/src/components/tools/door/door-tool.tsx +0 -324
- package/src/components/tools/door/move-door-tool.tsx +0 -412
- package/src/components/tools/fence/curve-fence-tool.tsx +0 -178
- package/src/components/tools/fence/fence-tool.tsx +0 -346
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
- package/src/components/tools/fence/move-fence-tool.tsx +0 -302
- package/src/components/tools/item/item-tool.tsx +0 -26
- package/src/components/tools/roof/move-roof-tool.tsx +0 -364
- package/src/components/tools/slab/move-slab-tool.tsx +0 -182
- package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
- package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
- package/src/components/tools/slab/slab-tool.tsx +0 -322
- package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
- package/src/components/tools/spawn/spawn-tool.tsx +0 -130
- package/src/components/tools/wall/curve-wall-tool.tsx +0 -178
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
- package/src/components/tools/wall/move-wall-tool.tsx +0 -358
- package/src/components/tools/wall/wall-tool.tsx +0 -332
- package/src/components/tools/window/move-window-tool.tsx +0 -447
- package/src/components/tools/window/window-math.ts +0 -117
- package/src/components/tools/window/window-tool.tsx +0 -332
- package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
- package/src/components/ui/helpers/slab-helper.tsx +0 -20
- package/src/components/ui/helpers/wall-helper.tsx +0 -20
- package/src/components/ui/panels/ceiling-panel.tsx +0 -249
- package/src/components/ui/panels/column-panel.tsx +0 -759
- package/src/components/ui/panels/door-panel.tsx +0 -1299
- package/src/components/ui/panels/fence-panel.tsx +0 -222
- package/src/components/ui/panels/item-panel.tsx +0 -306
- package/src/components/ui/panels/paint-panel.tsx +0 -163
- package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
- package/src/components/ui/panels/roof-panel.tsx +0 -280
- package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
- package/src/components/ui/panels/slab-panel.tsx +0 -250
- package/src/components/ui/panels/spawn-panel.tsx +0 -161
- package/src/components/ui/panels/stair-panel.tsx +0 -577
- package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
- package/src/components/ui/panels/wall-panel.tsx +0 -273
- package/src/components/ui/panels/window-panel.tsx +0 -968
- package/src/contexts/presets-context.tsx +0 -121
- package/src/hooks/use-contextual-tools.ts +0 -61
|
@@ -1,19 +1,32 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
|
+
type AnyNodeId,
|
|
4
5
|
type CameraControlEvent,
|
|
5
6
|
type CameraControlFitSceneEvent,
|
|
6
7
|
emitter,
|
|
7
8
|
sceneRegistry,
|
|
8
9
|
useScene,
|
|
9
10
|
} from '@pascal-app/core'
|
|
10
|
-
import {
|
|
11
|
+
import { GRID_LAYER, useViewer, ZONE_LAYER } from '@pascal-app/viewer'
|
|
11
12
|
import { CameraControls, CameraControlsImpl } from '@react-three/drei'
|
|
12
|
-
import { useThree } from '@react-three/fiber'
|
|
13
|
+
import { useFrame, useThree } from '@react-three/fiber'
|
|
13
14
|
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
|
14
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
Box3,
|
|
17
|
+
type Camera,
|
|
18
|
+
type OrthographicCamera,
|
|
19
|
+
type PerspectiveCamera,
|
|
20
|
+
Spherical,
|
|
21
|
+
Vector3,
|
|
22
|
+
} from 'three'
|
|
15
23
|
import { EDITOR_LAYER } from '../../lib/constants'
|
|
16
24
|
import useEditor from '../../store/use-editor'
|
|
25
|
+
import {
|
|
26
|
+
useActiveHandleDrag,
|
|
27
|
+
useEndpointReshape,
|
|
28
|
+
useMovingNode,
|
|
29
|
+
} from '../../store/use-interaction-scope'
|
|
17
30
|
|
|
18
31
|
const currentTarget = new Vector3()
|
|
19
32
|
const tempBox = new Box3()
|
|
@@ -22,30 +35,378 @@ const tempDelta = new Vector3()
|
|
|
22
35
|
const tempPosition = new Vector3()
|
|
23
36
|
const tempSize = new Vector3()
|
|
24
37
|
const tempTarget = new Vector3()
|
|
38
|
+
const syncTarget = new Vector3()
|
|
39
|
+
const syncSpherical = new Spherical()
|
|
40
|
+
const keyboardPanSpherical = new Spherical()
|
|
25
41
|
const DEFAULT_MAX_POLAR_ANGLE = Math.PI / 2 - 0.1
|
|
26
42
|
const DEBUG_MAX_POLAR_ANGLE = Math.PI - 0.05
|
|
43
|
+
const NAVIGATION_SYNC_POSITION_EPSILON = 0.001
|
|
44
|
+
const NAVIGATION_SYNC_AZIMUTH_EPSILON = 0.0005
|
|
45
|
+
const NAVIGATION_SYNC_VIEW_WIDTH_EPSILON = 0.001
|
|
46
|
+
const KEYBOARD_PAN_VIEW_WIDTH_PER_SECOND = 0.65
|
|
47
|
+
const KEYBOARD_PAN_MIN_SPEED = 2
|
|
48
|
+
const KEYBOARD_PAN_MAX_SPEED = 55
|
|
49
|
+
type CameraMode = ReturnType<typeof useViewer.getState>['cameraMode']
|
|
50
|
+
type CameraPoseSnapshot = {
|
|
51
|
+
mode: CameraMode
|
|
52
|
+
position: [number, number, number]
|
|
53
|
+
target: [number, number, number]
|
|
54
|
+
}
|
|
55
|
+
type NavigationCameraPoseSnapshot = {
|
|
56
|
+
target: [number, number, number]
|
|
57
|
+
azimuth: number
|
|
58
|
+
viewWidth: number
|
|
59
|
+
}
|
|
60
|
+
type PendingNavigationCameraPoseSnapshot = NavigationCameraPoseSnapshot & {
|
|
61
|
+
publishOnComplete: boolean
|
|
62
|
+
}
|
|
63
|
+
type CameraViewWidthUpdate =
|
|
64
|
+
| { type: 'distance'; distance: number; viewWidth: number }
|
|
65
|
+
| { type: 'zoom'; viewWidth: number; zoom: number }
|
|
66
|
+
| { type: 'none'; viewWidth: number }
|
|
67
|
+
|
|
68
|
+
function writeVectorTuple(tuple: [number, number, number], vector: Vector3) {
|
|
69
|
+
tuple[0] = vector.x
|
|
70
|
+
tuple[1] = vector.y
|
|
71
|
+
tuple[2] = vector.z
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function saveCameraPose(
|
|
75
|
+
control: CameraControlsImpl,
|
|
76
|
+
mode: CameraMode,
|
|
77
|
+
pose: CameraPoseSnapshot,
|
|
78
|
+
position: Vector3,
|
|
79
|
+
target: Vector3,
|
|
80
|
+
) {
|
|
81
|
+
control.getPosition(position)
|
|
82
|
+
control.getTarget(target)
|
|
83
|
+
pose.mode = mode
|
|
84
|
+
writeVectorTuple(pose.position, position)
|
|
85
|
+
writeVectorTuple(pose.target, target)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function restoreCameraPose(control: CameraControlsImpl, pose: CameraPoseSnapshot) {
|
|
89
|
+
control.setLookAt(
|
|
90
|
+
pose.position[0],
|
|
91
|
+
pose.position[1],
|
|
92
|
+
pose.position[2],
|
|
93
|
+
pose.target[0],
|
|
94
|
+
pose.target[1],
|
|
95
|
+
pose.target[2],
|
|
96
|
+
false,
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isEditableKeyboardTarget(target: EventTarget | null) {
|
|
101
|
+
return (
|
|
102
|
+
target instanceof HTMLInputElement ||
|
|
103
|
+
target instanceof HTMLTextAreaElement ||
|
|
104
|
+
target instanceof HTMLSelectElement ||
|
|
105
|
+
(target instanceof HTMLElement && target.isContentEditable)
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
type KeyboardPanState = {
|
|
110
|
+
forward: boolean
|
|
111
|
+
backward: boolean
|
|
112
|
+
left: boolean
|
|
113
|
+
right: boolean
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function setKeyboardPanKey(state: KeyboardPanState, code: string, pressed: boolean): boolean {
|
|
117
|
+
if (code === 'KeyW') {
|
|
118
|
+
const changed = state.forward !== pressed
|
|
119
|
+
state.forward = pressed
|
|
120
|
+
return changed
|
|
121
|
+
}
|
|
122
|
+
if (code === 'KeyS') {
|
|
123
|
+
const changed = state.backward !== pressed
|
|
124
|
+
state.backward = pressed
|
|
125
|
+
return changed
|
|
126
|
+
}
|
|
127
|
+
if (code === 'KeyA') {
|
|
128
|
+
const changed = state.left !== pressed
|
|
129
|
+
state.left = pressed
|
|
130
|
+
return changed
|
|
131
|
+
}
|
|
132
|
+
if (code === 'KeyD') {
|
|
133
|
+
const changed = state.right !== pressed
|
|
134
|
+
state.right = pressed
|
|
135
|
+
return changed
|
|
136
|
+
}
|
|
137
|
+
return false
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function isKeyboardPanKey(code: string): boolean {
|
|
141
|
+
return code === 'KeyW' || code === 'KeyA' || code === 'KeyS' || code === 'KeyD'
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
type CameraViewportSize = {
|
|
145
|
+
width: number
|
|
146
|
+
height: number
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function isPerspectiveCamera(camera: Camera): camera is PerspectiveCamera {
|
|
150
|
+
return (camera as PerspectiveCamera).isPerspectiveCamera === true
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function isOrthographicCamera(camera: Camera): camera is OrthographicCamera {
|
|
154
|
+
return (camera as OrthographicCamera).isOrthographicCamera === true
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function getCameraViewAspect(size: CameraViewportSize) {
|
|
158
|
+
return Math.max(size.width, 1) / Math.max(size.height, 1)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function getCameraViewWidth(camera: Camera, distance: number, size: CameraViewportSize) {
|
|
162
|
+
if (isPerspectiveCamera(camera)) {
|
|
163
|
+
const fovRadians = (camera.getEffectiveFOV() * Math.PI) / 180
|
|
164
|
+
return Math.max(0.001, 2 * distance * Math.tan(fovRadians / 2) * getCameraViewAspect(size))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (isOrthographicCamera(camera)) {
|
|
168
|
+
return Math.max(0.001, (camera.right - camera.left) / camera.zoom)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return Math.max(0.001, distance)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function getAngleDeltaRadians(a: number, b: number) {
|
|
175
|
+
return Math.atan2(Math.sin(a - b), Math.cos(a - b))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function nearestEquivalentRadians(angle: number, reference: number) {
|
|
179
|
+
return reference + getAngleDeltaRadians(angle, reference)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function clampFinite(value: number, min: number, max: number) {
|
|
183
|
+
const resolvedMin = Number.isFinite(min) ? min : Number.NEGATIVE_INFINITY
|
|
184
|
+
const resolvedMax = Number.isFinite(max) ? max : Number.POSITIVE_INFINITY
|
|
185
|
+
return Math.min(Math.max(value, resolvedMin), resolvedMax)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function clampCameraControlDistance(control: CameraControlsImpl, distance: number) {
|
|
189
|
+
const bounds = control as { minDistance?: number; maxDistance?: number }
|
|
190
|
+
return clampFinite(
|
|
191
|
+
distance,
|
|
192
|
+
bounds.minDistance ?? Number.NEGATIVE_INFINITY,
|
|
193
|
+
bounds.maxDistance ?? Number.POSITIVE_INFINITY,
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function clampCameraControlZoom(control: CameraControlsImpl, zoom: number) {
|
|
198
|
+
const bounds = control as { minZoom?: number; maxZoom?: number }
|
|
199
|
+
return clampFinite(
|
|
200
|
+
zoom,
|
|
201
|
+
bounds.minZoom ?? Number.NEGATIVE_INFINITY,
|
|
202
|
+
bounds.maxZoom ?? Number.POSITIVE_INFINITY,
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function isCameraAtNavigationPose(
|
|
207
|
+
pose: NavigationCameraPoseSnapshot,
|
|
208
|
+
target: Vector3,
|
|
209
|
+
azimuth: number,
|
|
210
|
+
viewWidth: number,
|
|
211
|
+
) {
|
|
212
|
+
return (
|
|
213
|
+
Math.abs(pose.target[0] - target.x) < NAVIGATION_SYNC_POSITION_EPSILON &&
|
|
214
|
+
Math.abs(pose.target[1] - target.y) < NAVIGATION_SYNC_POSITION_EPSILON &&
|
|
215
|
+
Math.abs(pose.target[2] - target.z) < NAVIGATION_SYNC_POSITION_EPSILON &&
|
|
216
|
+
Math.abs(getAngleDeltaRadians(pose.azimuth, azimuth)) < NAVIGATION_SYNC_AZIMUTH_EPSILON &&
|
|
217
|
+
Math.abs(pose.viewWidth - viewWidth) < NAVIGATION_SYNC_VIEW_WIDTH_EPSILON
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function getCameraDistanceForViewWidth(
|
|
222
|
+
camera: Camera,
|
|
223
|
+
viewWidth: number,
|
|
224
|
+
size: CameraViewportSize,
|
|
225
|
+
) {
|
|
226
|
+
if (!isPerspectiveCamera(camera)) {
|
|
227
|
+
return null
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const fovRadians = (camera.getEffectiveFOV() * Math.PI) / 180
|
|
231
|
+
const denominator = 2 * Math.tan(fovRadians / 2) * getCameraViewAspect(size)
|
|
232
|
+
|
|
233
|
+
return denominator > 0 ? Math.max(0.001, viewWidth / denominator) : null
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function getCameraZoomForViewWidth(camera: Camera, viewWidth: number) {
|
|
237
|
+
if (!isOrthographicCamera(camera)) {
|
|
238
|
+
return null
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return viewWidth > 0 ? Math.max(0.001, (camera.right - camera.left) / viewWidth) : null
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function resolveCameraViewWidthUpdate(
|
|
245
|
+
control: CameraControlsImpl,
|
|
246
|
+
camera: Camera,
|
|
247
|
+
viewWidth: number,
|
|
248
|
+
size: CameraViewportSize,
|
|
249
|
+
): CameraViewWidthUpdate {
|
|
250
|
+
const nextDistance = getCameraDistanceForViewWidth(camera, viewWidth, size)
|
|
251
|
+
if (nextDistance !== null) {
|
|
252
|
+
const appliedDistance = clampCameraControlDistance(control, nextDistance)
|
|
253
|
+
return {
|
|
254
|
+
type: 'distance',
|
|
255
|
+
distance: appliedDistance,
|
|
256
|
+
viewWidth: getCameraViewWidth(camera, appliedDistance, size),
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const nextZoom = getCameraZoomForViewWidth(camera, viewWidth)
|
|
261
|
+
if (nextZoom !== null) {
|
|
262
|
+
const appliedZoom = clampCameraControlZoom(control, nextZoom)
|
|
263
|
+
if (isOrthographicCamera(camera)) {
|
|
264
|
+
return {
|
|
265
|
+
type: 'zoom',
|
|
266
|
+
zoom: appliedZoom,
|
|
267
|
+
viewWidth: Math.max(0.001, (camera.right - camera.left) / Math.max(appliedZoom, 0.001)),
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return { type: 'none', viewWidth }
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function applyCameraViewWidth(control: CameraControlsImpl, update: CameraViewWidthUpdate) {
|
|
276
|
+
if (update.type === 'distance') {
|
|
277
|
+
control.dollyTo(update.distance, true)
|
|
278
|
+
return
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (update.type === 'zoom') {
|
|
282
|
+
control.zoomTo(update.zoom, true)
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function useFirstPersonCameraPoseRestore(
|
|
287
|
+
controls: { current: CameraControlsImpl | null },
|
|
288
|
+
isFirstPersonMode: boolean,
|
|
289
|
+
cameraMode: CameraMode,
|
|
290
|
+
) {
|
|
291
|
+
const restorePose = useRef<CameraPoseSnapshot>({
|
|
292
|
+
mode: cameraMode,
|
|
293
|
+
position: [0, 0, 0],
|
|
294
|
+
target: [0, 0, 0],
|
|
295
|
+
})
|
|
296
|
+
const hasRestorePose = useRef(false)
|
|
297
|
+
const isRestoring = useRef(false)
|
|
298
|
+
const wasFirstPersonMode = useRef(isFirstPersonMode)
|
|
299
|
+
const snapshotPosition = useRef(new Vector3())
|
|
300
|
+
const snapshotTarget = useRef(new Vector3())
|
|
301
|
+
|
|
302
|
+
useFrame(() => {
|
|
303
|
+
if (isFirstPersonMode || isRestoring.current) return
|
|
304
|
+
const control = controls.current
|
|
305
|
+
if (!control) return
|
|
306
|
+
|
|
307
|
+
saveCameraPose(
|
|
308
|
+
control,
|
|
309
|
+
cameraMode,
|
|
310
|
+
restorePose.current,
|
|
311
|
+
snapshotPosition.current,
|
|
312
|
+
snapshotTarget.current,
|
|
313
|
+
)
|
|
314
|
+
hasRestorePose.current = true
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
useEffect(() => {
|
|
318
|
+
const wasFirstPerson = wasFirstPersonMode.current
|
|
319
|
+
wasFirstPersonMode.current = isFirstPersonMode
|
|
320
|
+
|
|
321
|
+
if (isFirstPersonMode) {
|
|
322
|
+
return
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (!wasFirstPerson || !hasRestorePose.current) return
|
|
326
|
+
|
|
327
|
+
const pose = restorePose.current
|
|
328
|
+
isRestoring.current = true
|
|
329
|
+
useViewer.getState().setCameraMode(pose.mode)
|
|
330
|
+
|
|
331
|
+
const restoreFrame = requestAnimationFrame(() => {
|
|
332
|
+
const currentControls = controls.current
|
|
333
|
+
if (currentControls) {
|
|
334
|
+
restoreCameraPose(currentControls, pose)
|
|
335
|
+
}
|
|
336
|
+
isRestoring.current = false
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
return () => {
|
|
340
|
+
cancelAnimationFrame(restoreFrame)
|
|
341
|
+
isRestoring.current = false
|
|
342
|
+
}
|
|
343
|
+
}, [controls, isFirstPersonMode])
|
|
344
|
+
|
|
345
|
+
return useCallback(() => isRestoring.current, [])
|
|
346
|
+
}
|
|
27
347
|
|
|
28
348
|
export const CustomCameraControls = () => {
|
|
29
|
-
const controls = useRef<CameraControlsImpl>(null
|
|
349
|
+
const controls = useRef<CameraControlsImpl | null>(null)
|
|
350
|
+
const keyboardPanKeys = useRef<KeyboardPanState>({
|
|
351
|
+
forward: false,
|
|
352
|
+
backward: false,
|
|
353
|
+
left: false,
|
|
354
|
+
right: false,
|
|
355
|
+
})
|
|
30
356
|
const isPreviewMode = useEditor((s) => s.isPreviewMode)
|
|
31
357
|
const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
|
|
32
358
|
const allowUndergroundCamera = useEditor((s) => s.allowUndergroundCamera)
|
|
359
|
+
const isFloorplanOpen = useEditor((s) => s.isFloorplanOpen)
|
|
33
360
|
const selection = useViewer((s) => s.selection)
|
|
361
|
+
const cameraMode = useViewer((state) => state.cameraMode)
|
|
362
|
+
const isRestoringFirstPersonPose = useFirstPersonCameraPoseRestore(
|
|
363
|
+
controls,
|
|
364
|
+
isFirstPersonMode,
|
|
365
|
+
cameraMode,
|
|
366
|
+
)
|
|
34
367
|
const currentLevelId = selection.levelId
|
|
35
368
|
const firstLoad = useRef(true)
|
|
369
|
+
const lastPublishedNavigationSync = useRef<NavigationCameraPoseSnapshot | null>(null)
|
|
370
|
+
const pendingFloorplanNavigationPose = useRef<PendingNavigationCameraPoseSnapshot | null>(null)
|
|
371
|
+
const lastApplied2dNavigationRevision = useRef(0)
|
|
372
|
+
const savedSmoothTimeRef = useRef<number | null>(null)
|
|
36
373
|
const maxPolarAngle =
|
|
37
374
|
!isPreviewMode && allowUndergroundCamera ? DEBUG_MAX_POLAR_ANGLE : DEFAULT_MAX_POLAR_ANGLE
|
|
375
|
+
const clearPendingFloorplanNavigationPose = useCallback(() => {
|
|
376
|
+
pendingFloorplanNavigationPose.current = null
|
|
377
|
+
if (savedSmoothTimeRef.current !== null && controls.current) {
|
|
378
|
+
controls.current.smoothTime = savedSmoothTimeRef.current
|
|
379
|
+
savedSmoothTimeRef.current = null
|
|
380
|
+
}
|
|
381
|
+
}, [])
|
|
38
382
|
|
|
39
383
|
const camera = useThree((state) => state.camera)
|
|
384
|
+
const gl = useThree((state) => state.gl)
|
|
40
385
|
const raycaster = useThree((state) => state.raycaster)
|
|
386
|
+
const viewportSize = useThree((state) => state.size)
|
|
41
387
|
useEffect(() => {
|
|
42
388
|
camera.layers.enable(EDITOR_LAYER)
|
|
389
|
+
camera.layers.enable(GRID_LAYER)
|
|
43
390
|
raycaster.layers.enable(EDITOR_LAYER)
|
|
44
391
|
raycaster.layers.enable(ZONE_LAYER)
|
|
45
392
|
}, [camera, raycaster])
|
|
46
393
|
|
|
47
394
|
useEffect(() => {
|
|
48
|
-
|
|
395
|
+
// Dev-only: deterministic camera poses for screenshot/automation tooling.
|
|
396
|
+
// A getter, not a snapshot — drei recreates the impl when the default
|
|
397
|
+
// camera changes, so a captured instance goes stale.
|
|
398
|
+
if (process.env.NODE_ENV !== 'development') return
|
|
399
|
+
const w = window as typeof window & {
|
|
400
|
+
__pascalCameraControls?: (() => CameraControlsImpl | null) | null
|
|
401
|
+
}
|
|
402
|
+
w.__pascalCameraControls = () => controls.current
|
|
403
|
+
return () => {
|
|
404
|
+
w.__pascalCameraControls = null
|
|
405
|
+
}
|
|
406
|
+
}, [])
|
|
407
|
+
|
|
408
|
+
useEffect(() => {
|
|
409
|
+
if (isPreviewMode || isFirstPersonMode || isRestoringFirstPersonPose()) return
|
|
49
410
|
let targetY = 0
|
|
50
411
|
if (currentLevelId) {
|
|
51
412
|
const levelMesh = sceneRegistry.nodes.get(currentLevelId)
|
|
@@ -56,14 +417,22 @@ export const CustomCameraControls = () => {
|
|
|
56
417
|
if (!controls.current) return
|
|
57
418
|
if (firstLoad.current) {
|
|
58
419
|
firstLoad.current = false
|
|
420
|
+
clearPendingFloorplanNavigationPose()
|
|
59
421
|
controls.current.setLookAt(20, 20, 20, 0, 0, 0, true)
|
|
60
422
|
}
|
|
61
423
|
controls.current.getTarget(currentTarget)
|
|
424
|
+
clearPendingFloorplanNavigationPose()
|
|
62
425
|
controls.current.moveTo(currentTarget.x, targetY, currentTarget.z, true)
|
|
63
|
-
}, [
|
|
426
|
+
}, [
|
|
427
|
+
clearPendingFloorplanNavigationPose,
|
|
428
|
+
currentLevelId,
|
|
429
|
+
isPreviewMode,
|
|
430
|
+
isFirstPersonMode,
|
|
431
|
+
isRestoringFirstPersonPose,
|
|
432
|
+
])
|
|
64
433
|
|
|
65
434
|
useEffect(() => {
|
|
66
|
-
if (!controls.current) return
|
|
435
|
+
if (isFirstPersonMode || !controls.current) return
|
|
67
436
|
|
|
68
437
|
controls.current.maxPolarAngle = maxPolarAngle
|
|
69
438
|
controls.current.minPolarAngle = 0
|
|
@@ -71,11 +440,11 @@ export const CustomCameraControls = () => {
|
|
|
71
440
|
if (controls.current.polarAngle > maxPolarAngle) {
|
|
72
441
|
controls.current.rotateTo(controls.current.azimuthAngle, maxPolarAngle, true)
|
|
73
442
|
}
|
|
74
|
-
}, [maxPolarAngle])
|
|
443
|
+
}, [isFirstPersonMode, maxPolarAngle])
|
|
75
444
|
|
|
76
445
|
const focusNode = useCallback(
|
|
77
446
|
(nodeId: string) => {
|
|
78
|
-
if (isPreviewMode || !controls.current) return
|
|
447
|
+
if (isPreviewMode || isFirstPersonMode || !controls.current) return
|
|
79
448
|
|
|
80
449
|
const object3D = sceneRegistry.nodes.get(nodeId)
|
|
81
450
|
if (!object3D) return
|
|
@@ -88,6 +457,7 @@ export const CustomCameraControls = () => {
|
|
|
88
457
|
controls.current.getTarget(tempTarget)
|
|
89
458
|
tempDelta.copy(tempCenter).sub(tempTarget)
|
|
90
459
|
|
|
460
|
+
clearPendingFloorplanNavigationPose()
|
|
91
461
|
controls.current.setLookAt(
|
|
92
462
|
tempPosition.x + tempDelta.x,
|
|
93
463
|
tempPosition.y + tempDelta.y,
|
|
@@ -98,11 +468,143 @@ export const CustomCameraControls = () => {
|
|
|
98
468
|
true,
|
|
99
469
|
)
|
|
100
470
|
},
|
|
101
|
-
[isPreviewMode],
|
|
471
|
+
[clearPendingFloorplanNavigationPose, isPreviewMode, isFirstPersonMode],
|
|
102
472
|
)
|
|
103
473
|
|
|
474
|
+
useEffect(() => {
|
|
475
|
+
if (isFirstPersonMode) return
|
|
476
|
+
|
|
477
|
+
return useEditor.subscribe((state) => {
|
|
478
|
+
const pose = state.navigationSyncPose
|
|
479
|
+
if (pose?.source !== '2d' || pose.revision === lastApplied2dNavigationRevision.current) return
|
|
480
|
+
|
|
481
|
+
const control = controls.current
|
|
482
|
+
if (!control) return
|
|
483
|
+
|
|
484
|
+
lastApplied2dNavigationRevision.current = pose.revision
|
|
485
|
+
const targetAzimuth = nearestEquivalentRadians(pose.azimuth, control.azimuthAngle)
|
|
486
|
+
const viewWidthUpdate = resolveCameraViewWidthUpdate(
|
|
487
|
+
control,
|
|
488
|
+
camera,
|
|
489
|
+
pose.viewWidth,
|
|
490
|
+
viewportSize,
|
|
491
|
+
)
|
|
492
|
+
pendingFloorplanNavigationPose.current = {
|
|
493
|
+
target: [...pose.target],
|
|
494
|
+
azimuth: targetAzimuth,
|
|
495
|
+
viewWidth: viewWidthUpdate.viewWidth,
|
|
496
|
+
publishOnComplete:
|
|
497
|
+
Math.abs(viewWidthUpdate.viewWidth - pose.viewWidth) >=
|
|
498
|
+
NAVIGATION_SYNC_VIEW_WIDTH_EPSILON,
|
|
499
|
+
}
|
|
500
|
+
// Match 3D settle time to 2D exponential decay (τ=90ms). SmoothDamp's
|
|
501
|
+
// effective time constant is smoothTime/2, so smoothTime=0.18 gives
|
|
502
|
+
// τ≈90ms and visual convergence in ~350-400ms, matching the 2D panel.
|
|
503
|
+
if (savedSmoothTimeRef.current === null) {
|
|
504
|
+
savedSmoothTimeRef.current = control.smoothTime
|
|
505
|
+
}
|
|
506
|
+
control.smoothTime = 0.18
|
|
507
|
+
control.moveTo(pose.target[0], pose.target[1], pose.target[2], true)
|
|
508
|
+
control.rotateTo(targetAzimuth, control.polarAngle, true)
|
|
509
|
+
applyCameraViewWidth(control, viewWidthUpdate)
|
|
510
|
+
})
|
|
511
|
+
}, [camera, isFirstPersonMode, viewportSize])
|
|
512
|
+
|
|
513
|
+
const publishCurrentNavigationPose = useCallback(() => {
|
|
514
|
+
if (isFirstPersonMode || !controls.current) return
|
|
515
|
+
|
|
516
|
+
controls.current.getTarget(syncTarget, false)
|
|
517
|
+
controls.current.getSpherical(syncSpherical, false)
|
|
518
|
+
const viewWidth = getCameraViewWidth(camera, syncSpherical.radius, viewportSize)
|
|
519
|
+
|
|
520
|
+
const pendingFloorplanPose = pendingFloorplanNavigationPose.current
|
|
521
|
+
if (pendingFloorplanPose) {
|
|
522
|
+
// The camera is still damping toward a 2D-originated pose; do not echo
|
|
523
|
+
// intermediate 3D poses back into the floorplan.
|
|
524
|
+
if (
|
|
525
|
+
isCameraAtNavigationPose(pendingFloorplanPose, syncTarget, syncSpherical.theta, viewWidth)
|
|
526
|
+
) {
|
|
527
|
+
lastPublishedNavigationSync.current = pendingFloorplanPose
|
|
528
|
+
clearPendingFloorplanNavigationPose()
|
|
529
|
+
if (pendingFloorplanPose.publishOnComplete) {
|
|
530
|
+
useEditor.getState().publishNavigationSyncPose({
|
|
531
|
+
source: '3d',
|
|
532
|
+
target: [
|
|
533
|
+
pendingFloorplanPose.target[0],
|
|
534
|
+
pendingFloorplanPose.target[1],
|
|
535
|
+
pendingFloorplanPose.target[2],
|
|
536
|
+
],
|
|
537
|
+
azimuth: pendingFloorplanPose.azimuth,
|
|
538
|
+
viewWidth: pendingFloorplanPose.viewWidth,
|
|
539
|
+
})
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
return
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const previous = lastPublishedNavigationSync.current
|
|
546
|
+
if (
|
|
547
|
+
previous &&
|
|
548
|
+
Math.abs(previous.target[0] - syncTarget.x) < NAVIGATION_SYNC_POSITION_EPSILON &&
|
|
549
|
+
Math.abs(previous.target[1] - syncTarget.y) < NAVIGATION_SYNC_POSITION_EPSILON &&
|
|
550
|
+
Math.abs(previous.target[2] - syncTarget.z) < NAVIGATION_SYNC_POSITION_EPSILON &&
|
|
551
|
+
Math.abs(getAngleDeltaRadians(previous.azimuth, syncSpherical.theta)) <
|
|
552
|
+
NAVIGATION_SYNC_AZIMUTH_EPSILON &&
|
|
553
|
+
Math.abs(previous.viewWidth - viewWidth) < NAVIGATION_SYNC_VIEW_WIDTH_EPSILON
|
|
554
|
+
) {
|
|
555
|
+
return
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
lastPublishedNavigationSync.current = {
|
|
559
|
+
target: [syncTarget.x, syncTarget.y, syncTarget.z],
|
|
560
|
+
azimuth: syncSpherical.theta,
|
|
561
|
+
viewWidth,
|
|
562
|
+
}
|
|
563
|
+
useEditor.getState().publishNavigationSyncPose({
|
|
564
|
+
source: '3d',
|
|
565
|
+
target: [syncTarget.x, syncTarget.y, syncTarget.z],
|
|
566
|
+
azimuth: syncSpherical.theta,
|
|
567
|
+
viewWidth,
|
|
568
|
+
})
|
|
569
|
+
}, [camera, clearPendingFloorplanNavigationPose, isFirstPersonMode, viewportSize])
|
|
570
|
+
|
|
571
|
+
useEffect(() => {
|
|
572
|
+
if (isFirstPersonMode || (!isFloorplanOpen && currentLevelId === null)) return
|
|
573
|
+
|
|
574
|
+
const frame = requestAnimationFrame(() => {
|
|
575
|
+
lastPublishedNavigationSync.current = null
|
|
576
|
+
publishCurrentNavigationPose()
|
|
577
|
+
})
|
|
578
|
+
|
|
579
|
+
return () => {
|
|
580
|
+
cancelAnimationFrame(frame)
|
|
581
|
+
}
|
|
582
|
+
}, [currentLevelId, isFirstPersonMode, isFloorplanOpen, publishCurrentNavigationPose])
|
|
583
|
+
|
|
584
|
+
useFrame((_, delta) => {
|
|
585
|
+
if (isFirstPersonMode || !controls.current) return
|
|
586
|
+
|
|
587
|
+
const panKeys = keyboardPanKeys.current
|
|
588
|
+
const horizontal = (panKeys.right ? 1 : 0) - (panKeys.left ? 1 : 0)
|
|
589
|
+
const vertical = (panKeys.forward ? 1 : 0) - (panKeys.backward ? 1 : 0)
|
|
590
|
+
if (horizontal === 0 && vertical === 0) return
|
|
591
|
+
|
|
592
|
+
const control = controls.current
|
|
593
|
+
|
|
594
|
+
control.getSpherical(keyboardPanSpherical, false)
|
|
595
|
+
const viewWidth = getCameraViewWidth(camera, keyboardPanSpherical.radius, viewportSize)
|
|
596
|
+
const speed = Math.min(
|
|
597
|
+
Math.max(viewWidth * KEYBOARD_PAN_VIEW_WIDTH_PER_SECOND, KEYBOARD_PAN_MIN_SPEED),
|
|
598
|
+
KEYBOARD_PAN_MAX_SPEED,
|
|
599
|
+
)
|
|
600
|
+
const step = (speed * Math.min(delta, 0.05)) / Math.hypot(horizontal, vertical)
|
|
601
|
+
|
|
602
|
+
clearPendingFloorplanNavigationPose()
|
|
603
|
+
if (horizontal !== 0) control.truck(horizontal * step, 0, true)
|
|
604
|
+
if (vertical !== 0) control.forward(vertical * step, true)
|
|
605
|
+
})
|
|
606
|
+
|
|
104
607
|
// Configure mouse buttons based on control mode and camera mode
|
|
105
|
-
const cameraMode = useViewer((state) => state.cameraMode)
|
|
106
608
|
const mouseButtons = useMemo(() => {
|
|
107
609
|
// Use ZOOM for orthographic camera, DOLLY for perspective camera
|
|
108
610
|
const wheelAction =
|
|
@@ -135,12 +637,12 @@ export const CustomCameraControls = () => {
|
|
|
135
637
|
const tool = useEditor((s) => s.tool)
|
|
136
638
|
const mode = useEditor((s) => s.mode)
|
|
137
639
|
const selectionTool = useEditor((s) => s.floorplanSelectionTool)
|
|
138
|
-
const movingNode =
|
|
139
|
-
const
|
|
140
|
-
const
|
|
640
|
+
const movingNode = useMovingNode()
|
|
641
|
+
const endpointReshape = useEndpointReshape()
|
|
642
|
+
const activeHandleDrag = useActiveHandleDrag()
|
|
141
643
|
const isBoxSelectActive = mode === 'select' && selectionTool === 'marquee'
|
|
142
644
|
const isInteracting = Boolean(
|
|
143
|
-
tool || movingNode ||
|
|
645
|
+
tool || movingNode || endpointReshape || activeHandleDrag || isBoxSelectActive,
|
|
144
646
|
)
|
|
145
647
|
const touches = useMemo(() => {
|
|
146
648
|
const twoFingerAction =
|
|
@@ -162,6 +664,8 @@ export const CustomCameraControls = () => {
|
|
|
162
664
|
}, [cameraMode, isPreviewMode, isInteracting])
|
|
163
665
|
|
|
164
666
|
useEffect(() => {
|
|
667
|
+
if (isFirstPersonMode) return
|
|
668
|
+
|
|
165
669
|
const keyState = {
|
|
166
670
|
shiftRight: false,
|
|
167
671
|
shiftLeft: false,
|
|
@@ -169,6 +673,52 @@ export const CustomCameraControls = () => {
|
|
|
169
673
|
controlLeft: false,
|
|
170
674
|
space: false,
|
|
171
675
|
}
|
|
676
|
+
let ownsNavigationCursor = false
|
|
677
|
+
let panPointerId: number | null = null
|
|
678
|
+
let panPointerButton: number | null = null
|
|
679
|
+
|
|
680
|
+
const clearKeyboardPanKeys = () => {
|
|
681
|
+
keyboardPanKeys.current.forward = false
|
|
682
|
+
keyboardPanKeys.current.backward = false
|
|
683
|
+
keyboardPanKeys.current.left = false
|
|
684
|
+
keyboardPanKeys.current.right = false
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const setNavigationCursor = (cursor: 'grab' | 'grabbing') => {
|
|
688
|
+
document.body.style.cursor = cursor
|
|
689
|
+
gl.domElement.style.cursor = cursor
|
|
690
|
+
ownsNavigationCursor = true
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const clearNavigationCursor = () => {
|
|
694
|
+
if (
|
|
695
|
+
ownsNavigationCursor &&
|
|
696
|
+
(document.body.style.cursor === 'grab' || document.body.style.cursor === 'grabbing')
|
|
697
|
+
) {
|
|
698
|
+
document.body.style.cursor = ''
|
|
699
|
+
}
|
|
700
|
+
if (ownsNavigationCursor && gl.domElement.style.cursor === 'grab') {
|
|
701
|
+
gl.domElement.style.cursor = ''
|
|
702
|
+
}
|
|
703
|
+
if (ownsNavigationCursor && gl.domElement.style.cursor === 'grabbing') {
|
|
704
|
+
gl.domElement.style.cursor = ''
|
|
705
|
+
}
|
|
706
|
+
ownsNavigationCursor = false
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const updateNavigationCursor = () => {
|
|
710
|
+
if (panPointerId !== null) {
|
|
711
|
+
setNavigationCursor('grabbing')
|
|
712
|
+
return
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
if (keyState.space) {
|
|
716
|
+
setNavigationCursor('grab')
|
|
717
|
+
return
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
clearNavigationCursor()
|
|
721
|
+
}
|
|
172
722
|
|
|
173
723
|
const updateConfig = () => {
|
|
174
724
|
if (!controls.current) return
|
|
@@ -195,9 +745,24 @@ export const CustomCameraControls = () => {
|
|
|
195
745
|
}
|
|
196
746
|
|
|
197
747
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
748
|
+
if (isKeyboardPanKey(event.code)) {
|
|
749
|
+
if (
|
|
750
|
+
!(event.metaKey || event.ctrlKey || event.altKey) &&
|
|
751
|
+
!isEditableKeyboardTarget(event.target)
|
|
752
|
+
) {
|
|
753
|
+
setKeyboardPanKey(keyboardPanKeys.current, event.code, true)
|
|
754
|
+
clearPendingFloorplanNavigationPose()
|
|
755
|
+
event.preventDefault()
|
|
756
|
+
event.stopPropagation()
|
|
757
|
+
}
|
|
758
|
+
return
|
|
759
|
+
}
|
|
760
|
+
|
|
198
761
|
if (event.code === 'Space') {
|
|
762
|
+
if (isEditableKeyboardTarget(event.target)) return
|
|
763
|
+
event.preventDefault()
|
|
199
764
|
keyState.space = true
|
|
200
|
-
|
|
765
|
+
updateNavigationCursor()
|
|
201
766
|
}
|
|
202
767
|
if (event.code === 'ShiftRight') {
|
|
203
768
|
keyState.shiftRight = true
|
|
@@ -215,9 +780,22 @@ export const CustomCameraControls = () => {
|
|
|
215
780
|
}
|
|
216
781
|
|
|
217
782
|
const onKeyUp = (event: KeyboardEvent) => {
|
|
783
|
+
if (isKeyboardPanKey(event.code)) {
|
|
784
|
+
const changed = setKeyboardPanKey(keyboardPanKeys.current, event.code, false)
|
|
785
|
+
if (changed) {
|
|
786
|
+
event.preventDefault()
|
|
787
|
+
event.stopPropagation()
|
|
788
|
+
}
|
|
789
|
+
return
|
|
790
|
+
}
|
|
791
|
+
|
|
218
792
|
if (event.code === 'Space') {
|
|
219
793
|
keyState.space = false
|
|
220
|
-
|
|
794
|
+
if (panPointerButton === 0) {
|
|
795
|
+
panPointerId = null
|
|
796
|
+
panPointerButton = null
|
|
797
|
+
}
|
|
798
|
+
updateNavigationCursor()
|
|
221
799
|
}
|
|
222
800
|
if (event.code === 'ShiftRight') {
|
|
223
801
|
keyState.shiftRight = false
|
|
@@ -234,15 +812,78 @@ export const CustomCameraControls = () => {
|
|
|
234
812
|
updateConfig()
|
|
235
813
|
}
|
|
236
814
|
|
|
815
|
+
const onPointerDown = (event: PointerEvent) => {
|
|
816
|
+
if (!(event.target instanceof Node) || !gl.domElement.contains(event.target)) return
|
|
817
|
+
clearPendingFloorplanNavigationPose()
|
|
818
|
+
if (event.button !== 1 && !(event.button === 0 && keyState.space)) return
|
|
819
|
+
|
|
820
|
+
panPointerId = event.pointerId
|
|
821
|
+
panPointerButton = event.button
|
|
822
|
+
updateNavigationCursor()
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
const onWheel = () => {
|
|
826
|
+
clearPendingFloorplanNavigationPose()
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
const onPointerUp = (event: PointerEvent) => {
|
|
830
|
+
if (panPointerId === null) return
|
|
831
|
+
if (event.type !== 'pointercancel' && event.pointerId !== panPointerId) return
|
|
832
|
+
if (event.type !== 'pointercancel' && event.button !== panPointerButton) return
|
|
833
|
+
|
|
834
|
+
panPointerId = null
|
|
835
|
+
panPointerButton = null
|
|
836
|
+
updateNavigationCursor()
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
const onBlur = () => {
|
|
840
|
+
keyState.space = false
|
|
841
|
+
clearKeyboardPanKeys()
|
|
842
|
+
panPointerId = null
|
|
843
|
+
panPointerButton = null
|
|
844
|
+
clearNavigationCursor()
|
|
845
|
+
updateConfig()
|
|
846
|
+
}
|
|
847
|
+
|
|
237
848
|
document.addEventListener('keydown', onKeyDown)
|
|
238
849
|
document.addEventListener('keyup', onKeyUp)
|
|
850
|
+
window.addEventListener('pointerdown', onPointerDown, true)
|
|
851
|
+
window.addEventListener('pointerup', onPointerUp, true)
|
|
852
|
+
window.addEventListener('pointercancel', onPointerUp, true)
|
|
853
|
+
window.addEventListener('blur', onBlur)
|
|
854
|
+
gl.domElement.addEventListener('wheel', onWheel, { passive: true })
|
|
239
855
|
updateConfig()
|
|
240
856
|
|
|
241
857
|
return () => {
|
|
242
858
|
document.removeEventListener('keydown', onKeyDown)
|
|
243
859
|
document.removeEventListener('keyup', onKeyUp)
|
|
860
|
+
window.removeEventListener('pointerdown', onPointerDown, true)
|
|
861
|
+
window.removeEventListener('pointerup', onPointerUp, true)
|
|
862
|
+
window.removeEventListener('pointercancel', onPointerUp, true)
|
|
863
|
+
window.removeEventListener('blur', onBlur)
|
|
864
|
+
gl.domElement.removeEventListener('wheel', onWheel)
|
|
865
|
+
clearKeyboardPanKeys()
|
|
866
|
+
clearNavigationCursor()
|
|
244
867
|
}
|
|
245
|
-
}, [cameraMode, isPreviewMode])
|
|
868
|
+
}, [cameraMode, gl, isPreviewMode, isFirstPersonMode, clearPendingFloorplanNavigationPose])
|
|
869
|
+
|
|
870
|
+
// Cancel any in-progress 2D-origin navigation pose when the user starts
|
|
871
|
+
// dragging (right-click orbit, middle-click pan, touch). `controlstart`
|
|
872
|
+
// fires only for user pointer interactions — not for programmatic
|
|
873
|
+
// moveTo/rotateTo which emit `transitionstart` instead.
|
|
874
|
+
useEffect(() => {
|
|
875
|
+
if (isFirstPersonMode) return
|
|
876
|
+
const control = controls.current
|
|
877
|
+
if (!control) return
|
|
878
|
+
|
|
879
|
+
const onControlStart = () => {
|
|
880
|
+
clearPendingFloorplanNavigationPose()
|
|
881
|
+
}
|
|
882
|
+
control.addEventListener('controlstart', onControlStart)
|
|
883
|
+
return () => {
|
|
884
|
+
control.removeEventListener('controlstart', onControlStart)
|
|
885
|
+
}
|
|
886
|
+
}, [isFirstPersonMode, clearPendingFloorplanNavigationPose])
|
|
246
887
|
|
|
247
888
|
// Preview mode: auto-navigate camera to selected node (viewer behavior)
|
|
248
889
|
const previewTargetNodeId = isPreviewMode
|
|
@@ -250,7 +891,7 @@ export const CustomCameraControls = () => {
|
|
|
250
891
|
: null
|
|
251
892
|
|
|
252
893
|
useEffect(() => {
|
|
253
|
-
if (!(isPreviewMode && controls.current)) return
|
|
894
|
+
if (!(isPreviewMode && controls.current) || isFirstPersonMode) return
|
|
254
895
|
|
|
255
896
|
const nodes = useScene.getState().nodes
|
|
256
897
|
let node = previewTargetNodeId ? nodes[previewTargetNodeId] : null
|
|
@@ -310,11 +951,122 @@ export const CustomCameraControls = () => {
|
|
|
310
951
|
tempCenter.z,
|
|
311
952
|
true,
|
|
312
953
|
)
|
|
313
|
-
}, [isPreviewMode, previewTargetNodeId])
|
|
954
|
+
}, [isPreviewMode, isFirstPersonMode, previewTargetNodeId])
|
|
314
955
|
|
|
956
|
+
// Preset capture auto-framing — when `setCaptureMode({ mode: 'preset',
|
|
957
|
+
// isolated })` fires, fly the camera to a pose that fits the union
|
|
958
|
+
// bounds of the isolated subtree inside the locked square crop. The
|
|
959
|
+
// user can still pan / orbit / zoom from there; we only set the
|
|
960
|
+
// initial pose. On exit (`mode: 'idle'`), we restore the previous
|
|
961
|
+
// pose so the user lands back exactly where they were before the
|
|
962
|
+
// modal opened.
|
|
963
|
+
const captureMode = useEditor((s) => s.captureMode)
|
|
315
964
|
useEffect(() => {
|
|
316
|
-
|
|
965
|
+
if (isFirstPersonMode) return
|
|
966
|
+
if (!controls.current) return
|
|
967
|
+
if (captureMode.mode !== 'preset') return
|
|
968
|
+
const ids = captureMode.isolated
|
|
969
|
+
if (ids.length === 0) return
|
|
970
|
+
|
|
971
|
+
// Stash the pre-capture pose so we can restore it on exit. Using
|
|
972
|
+
// a ref keeps the value across the cleanup phase without
|
|
973
|
+
// re-renders.
|
|
974
|
+
const restorePos = new Vector3()
|
|
975
|
+
const restoreTarget = new Vector3()
|
|
976
|
+
controls.current.getPosition(restorePos)
|
|
977
|
+
controls.current.getTarget(restoreTarget)
|
|
978
|
+
|
|
979
|
+
// Union the bounds of every isolated subtree root. `setFromObject`
|
|
980
|
+
// walks the Three.js descendants automatically, so this picks up
|
|
981
|
+
// synthesized children (door/window cutouts under a wall, etc.).
|
|
982
|
+
tempBox.makeEmpty()
|
|
983
|
+
for (const id of ids) {
|
|
984
|
+
const obj = sceneRegistry.nodes.get(id)
|
|
985
|
+
if (!obj) continue
|
|
986
|
+
const sub = new Box3().setFromObject(obj)
|
|
987
|
+
if (!sub.isEmpty()) tempBox.union(sub)
|
|
988
|
+
}
|
|
989
|
+
if (captureMode.framingBounds) {
|
|
990
|
+
const { center, max, min, size } = captureMode.framingBounds
|
|
991
|
+
const fallbackHeight = Math.max(Math.max(size[0], size[1]) * 0.35, 2.5)
|
|
992
|
+
const minY = tempBox.isEmpty() ? 0 : tempBox.min.y
|
|
993
|
+
const maxY = tempBox.isEmpty() ? fallbackHeight : tempBox.max.y
|
|
994
|
+
tempBox.min.set(min[0], minY, min[1])
|
|
995
|
+
tempBox.max.set(max[0], Math.max(maxY, minY + 0.1), max[1])
|
|
996
|
+
tempCenter.set(center[0], (tempBox.min.y + tempBox.max.y) / 2, center[1])
|
|
997
|
+
tempSize.set(size[0], tempBox.max.y - tempBox.min.y, size[1])
|
|
998
|
+
} else {
|
|
999
|
+
if (tempBox.isEmpty()) return
|
|
1000
|
+
|
|
1001
|
+
tempBox.getCenter(tempCenter)
|
|
1002
|
+
tempBox.getSize(tempSize)
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// Distance heuristic: fit the subject inside the 75%-of-shorter-
|
|
1006
|
+
// side square crop with comfortable padding. Multiplier 2.4 leaves
|
|
1007
|
+
// ~25-30% margin around the bounds so the user can frame without
|
|
1008
|
+
// immediately needing to zoom out, but isn't so far away that the
|
|
1009
|
+
// subject reads as small in the thumbnail.
|
|
1010
|
+
const maxDim = Math.max(tempSize.x, tempSize.y, tempSize.z)
|
|
1011
|
+
const distance = Math.max(maxDim * 2.4, 4)
|
|
1012
|
+
|
|
1013
|
+
// Frame the subject from a 3/4 view of its front face. The node's
|
|
1014
|
+
// local +Z is its forward axis in this scene's authoring convention
|
|
1015
|
+
// (the face the user sets up to be photographed). When a single
|
|
1016
|
+
// subtree is isolated we read its yaw and rotate the camera around
|
|
1017
|
+
// the bounds center so the framing follows the user's authored
|
|
1018
|
+
// orientation; for multi-isolate sets we fall back to world +Z.
|
|
1019
|
+
// The 3/4 view offsets the camera by 35° to the right of dead-on
|
|
1020
|
+
// so both the front face and a side are visible — the "nice angle"
|
|
1021
|
+
// that reads as a product shot rather than a flat elevation. ~25°
|
|
1022
|
+
// elevation keeps the top visible without going isometric.
|
|
1023
|
+
const SIDE_OFFSET_RAD = (35 * Math.PI) / 180
|
|
1024
|
+
const ELEVATION_RAD = (25 * Math.PI) / 180
|
|
1025
|
+
let yaw = 0
|
|
1026
|
+
if (ids.length === 1) {
|
|
1027
|
+
const node = useScene.getState().nodes[ids[0] as AnyNodeId]
|
|
1028
|
+
if (node && 'rotation' in node) {
|
|
1029
|
+
const r = (node as { rotation?: unknown }).rotation
|
|
1030
|
+
if (typeof r === 'number') yaw = r
|
|
1031
|
+
else if (Array.isArray(r)) yaw = (r as [number, number, number])[1] ?? 0
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
// World-space direction the camera should sit *along* relative to
|
|
1035
|
+
// bounds center: in front (object's local +Z under yaw) + a right
|
|
1036
|
+
// offset around Y for the 3/4 read.
|
|
1037
|
+
const viewAngle = yaw + SIDE_OFFSET_RAD
|
|
1038
|
+
const horizontal = distance * Math.cos(ELEVATION_RAD)
|
|
1039
|
+
const elevation = distance * Math.sin(ELEVATION_RAD)
|
|
1040
|
+
controls.current.setLookAt(
|
|
1041
|
+
tempCenter.x + Math.sin(viewAngle) * horizontal,
|
|
1042
|
+
tempCenter.y + elevation,
|
|
1043
|
+
tempCenter.z + Math.cos(viewAngle) * horizontal,
|
|
1044
|
+
tempCenter.x,
|
|
1045
|
+
tempCenter.y,
|
|
1046
|
+
tempCenter.z,
|
|
1047
|
+
true,
|
|
1048
|
+
)
|
|
1049
|
+
|
|
1050
|
+
return () => {
|
|
1051
|
+
// Cleanup runs on captureMode change *or* unmount. Restore the
|
|
1052
|
+
// pre-capture pose only if the controls are still around (during
|
|
1053
|
+
// unmount they might be torn down already).
|
|
317
1054
|
if (!controls.current) return
|
|
1055
|
+
controls.current.setLookAt(
|
|
1056
|
+
restorePos.x,
|
|
1057
|
+
restorePos.y,
|
|
1058
|
+
restorePos.z,
|
|
1059
|
+
restoreTarget.x,
|
|
1060
|
+
restoreTarget.y,
|
|
1061
|
+
restoreTarget.z,
|
|
1062
|
+
true,
|
|
1063
|
+
)
|
|
1064
|
+
}
|
|
1065
|
+
}, [captureMode, isFirstPersonMode])
|
|
1066
|
+
|
|
1067
|
+
useEffect(() => {
|
|
1068
|
+
const handleNodeCapture = ({ nodeId }: CameraControlEvent) => {
|
|
1069
|
+
if (isFirstPersonMode || !controls.current) return
|
|
318
1070
|
|
|
319
1071
|
const position = new Vector3()
|
|
320
1072
|
const target = new Vector3()
|
|
@@ -332,12 +1084,13 @@ export const CustomCameraControls = () => {
|
|
|
332
1084
|
})
|
|
333
1085
|
}
|
|
334
1086
|
const handleNodeView = ({ nodeId }: CameraControlEvent) => {
|
|
335
|
-
if (!controls.current) return
|
|
1087
|
+
if (isFirstPersonMode || !controls.current) return
|
|
336
1088
|
|
|
337
1089
|
const node = useScene.getState().nodes[nodeId]
|
|
338
1090
|
if (!node?.camera) return
|
|
339
1091
|
const { position, target } = node.camera
|
|
340
1092
|
|
|
1093
|
+
clearPendingFloorplanNavigationPose()
|
|
341
1094
|
controls.current.setLookAt(
|
|
342
1095
|
position[0],
|
|
343
1096
|
position[1],
|
|
@@ -350,7 +1103,7 @@ export const CustomCameraControls = () => {
|
|
|
350
1103
|
}
|
|
351
1104
|
|
|
352
1105
|
const handleTopView = () => {
|
|
353
|
-
if (!controls.current) return
|
|
1106
|
+
if (isFirstPersonMode || !controls.current) return
|
|
354
1107
|
|
|
355
1108
|
const currentPolarAngle = controls.current.polarAngle
|
|
356
1109
|
|
|
@@ -358,11 +1111,12 @@ export const CustomCameraControls = () => {
|
|
|
358
1111
|
// Otherwise, go to top view (0°)
|
|
359
1112
|
const targetAngle = currentPolarAngle < 0.1 ? Math.PI / 4 : 0
|
|
360
1113
|
|
|
1114
|
+
clearPendingFloorplanNavigationPose()
|
|
361
1115
|
controls.current.rotatePolarTo(targetAngle, true)
|
|
362
1116
|
}
|
|
363
1117
|
|
|
364
1118
|
const handleOrbitCW = () => {
|
|
365
|
-
if (!controls.current) return
|
|
1119
|
+
if (isFirstPersonMode || !controls.current) return
|
|
366
1120
|
|
|
367
1121
|
const currentAzimuth = controls.current.azimuthAngle
|
|
368
1122
|
const currentPolar = controls.current.polarAngle
|
|
@@ -370,11 +1124,12 @@ export const CustomCameraControls = () => {
|
|
|
370
1124
|
const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2)
|
|
371
1125
|
const target = rounded - Math.PI / 2
|
|
372
1126
|
|
|
1127
|
+
clearPendingFloorplanNavigationPose()
|
|
373
1128
|
controls.current.rotateTo(target, currentPolar, true)
|
|
374
1129
|
}
|
|
375
1130
|
|
|
376
1131
|
const handleOrbitCCW = () => {
|
|
377
|
-
if (!controls.current) return
|
|
1132
|
+
if (isFirstPersonMode || !controls.current) return
|
|
378
1133
|
|
|
379
1134
|
const currentAzimuth = controls.current.azimuthAngle
|
|
380
1135
|
const currentPolar = controls.current.polarAngle
|
|
@@ -382,6 +1137,7 @@ export const CustomCameraControls = () => {
|
|
|
382
1137
|
const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2)
|
|
383
1138
|
const target = rounded + Math.PI / 2
|
|
384
1139
|
|
|
1140
|
+
clearPendingFloorplanNavigationPose()
|
|
385
1141
|
controls.current.rotateTo(target, currentPolar, true)
|
|
386
1142
|
}
|
|
387
1143
|
|
|
@@ -390,9 +1146,10 @@ export const CustomCameraControls = () => {
|
|
|
390
1146
|
}
|
|
391
1147
|
|
|
392
1148
|
const handleFitScene = ({ bounds }: CameraControlFitSceneEvent) => {
|
|
393
|
-
if (!controls.current || isPreviewMode) return
|
|
1149
|
+
if (isFirstPersonMode || !controls.current || isPreviewMode) return
|
|
394
1150
|
if (!bounds) {
|
|
395
1151
|
// Restore default framing pose when no bounds were computed.
|
|
1152
|
+
clearPendingFloorplanNavigationPose()
|
|
396
1153
|
controls.current.setLookAt(20, 20, 20, 0, 0, 0, true)
|
|
397
1154
|
return
|
|
398
1155
|
}
|
|
@@ -403,6 +1160,7 @@ export const CustomCameraControls = () => {
|
|
|
403
1160
|
const maxExtent = Math.max(w, d)
|
|
404
1161
|
const distance = Math.max(maxExtent * 1.4, 15)
|
|
405
1162
|
const height = Math.max(maxExtent * 0.8, 10)
|
|
1163
|
+
clearPendingFloorplanNavigationPose()
|
|
406
1164
|
controls.current.setLookAt(cx + distance * 0.7, height, cz + distance * 0.7, cx, 0, cz, true)
|
|
407
1165
|
}
|
|
408
1166
|
|
|
@@ -423,7 +1181,7 @@ export const CustomCameraControls = () => {
|
|
|
423
1181
|
emitter.off('camera-controls:orbit-ccw', handleOrbitCCW)
|
|
424
1182
|
emitter.off('camera-controls:fit-scene', handleFitScene)
|
|
425
1183
|
}
|
|
426
|
-
}, [focusNode, isPreviewMode])
|
|
1184
|
+
}, [clearPendingFloorplanNavigationPose, focusNode, isPreviewMode, isFirstPersonMode])
|
|
427
1185
|
|
|
428
1186
|
const onTransitionStart = useCallback(() => {
|
|
429
1187
|
useViewer.getState().setCameraDragging(true)
|
|
@@ -433,6 +1191,14 @@ export const CustomCameraControls = () => {
|
|
|
433
1191
|
useViewer.getState().setCameraDragging(false)
|
|
434
1192
|
}, [])
|
|
435
1193
|
|
|
1194
|
+
// Preset capture mode frames a single subtree (often a 0.3–2m preset),
|
|
1195
|
+
// so the default 2m minDistance prevents the user from getting close
|
|
1196
|
+
// enough to compose a good thumbnail. Relax the clamp to 0.5m while
|
|
1197
|
+
// capturing presets; reset on exit so general editing keeps the looser
|
|
1198
|
+
// navigation guardrails.
|
|
1199
|
+
const isPresetCapture = captureMode.mode === 'preset'
|
|
1200
|
+
const minDistance = isPresetCapture ? 0.5 : 2
|
|
1201
|
+
|
|
436
1202
|
if (isFirstPersonMode) {
|
|
437
1203
|
return null
|
|
438
1204
|
}
|
|
@@ -442,9 +1208,10 @@ export const CustomCameraControls = () => {
|
|
|
442
1208
|
makeDefault
|
|
443
1209
|
maxDistance={100}
|
|
444
1210
|
maxPolarAngle={maxPolarAngle}
|
|
445
|
-
minDistance={
|
|
1211
|
+
minDistance={minDistance}
|
|
446
1212
|
minPolarAngle={0}
|
|
447
1213
|
mouseButtons={mouseButtons}
|
|
1214
|
+
onUpdate={publishCurrentNavigationPose}
|
|
448
1215
|
onRest={onRest}
|
|
449
1216
|
onSleep={onRest}
|
|
450
1217
|
onTransitionStart={onTransitionStart}
|