@pascal-app/editor 1.0.3 → 1.0.5
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/README.md +106 -0
- package/package.json +42 -38
- package/src/components/editor/__bench__/room-label-placement.bench.ts +108 -0
- package/src/components/editor/action-menu-button.tsx +83 -0
- package/src/components/editor/bake-thumbnail.tsx +307 -5
- package/src/components/editor/capture-level-picker.tsx +72 -0
- package/src/components/editor/construction-reveal-audio.test.tsx +150 -0
- package/src/components/editor/construction-reveal-audio.tsx +40 -0
- package/src/components/editor/custom-camera-controls.tsx +275 -57
- package/src/components/editor/delete-confirmation-dialog.tsx +297 -7
- package/src/components/editor/duplicate-node.ts +244 -0
- package/src/components/editor/editor-layout-mobile.tsx +37 -26
- package/src/components/editor/editor-layout-v2.tsx +132 -59
- package/src/components/editor/export-manager.tsx +47 -26
- package/src/components/editor/first-person/build-collider-world.test.ts +320 -0
- package/src/components/editor/first-person/build-collider-world.ts +28 -7
- package/src/components/editor/first-person/ground-holes.test.ts +128 -0
- package/src/components/editor/first-person/ground-holes.ts +130 -0
- package/src/components/editor/first-person/mechanism-targets.test.ts +162 -0
- package/src/components/editor/first-person/mechanism-targets.ts +88 -0
- package/src/components/editor/first-person-controls.tsx +220 -9
- package/src/components/editor/floating-action-menu.tsx +63 -207
- package/src/components/editor/floor-aware-camera-controls.test.ts +466 -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-mode-coordinator.tsx +2 -2
- package/src/components/editor/floorplan-panel.tsx +414 -363
- package/src/components/editor/follow-pascal.test.tsx +912 -0
- package/src/components/editor/follow-pascal.tsx +354 -0
- package/src/components/editor/footprint-height-handle.tsx +204 -0
- package/src/components/editor/group-actions.ts +56 -2
- package/src/components/editor/group-move-3d.ts +2 -0
- package/src/components/editor/group-rotate-handle.tsx +2 -0
- package/src/components/editor/group-transform-shared.test.ts +460 -0
- package/src/components/editor/handles/handle-hit-priority.test.tsx +63 -0
- package/src/components/editor/handles/handle-hit-priority.tsx +37 -0
- package/src/components/editor/handles/swallow-next-click.test.ts +28 -0
- package/src/components/editor/handles/use-handle-drag.ts +269 -197
- package/src/components/editor/index.tsx +409 -197
- package/src/components/editor/join-walls-pill.tsx +44 -0
- package/src/components/editor/node-action-menu.tsx +68 -70
- package/src/components/editor/node-arrow-handles.tsx +25 -6
- package/src/components/editor/open-wall-ends-3d-layer.tsx +273 -0
- package/src/components/editor/open-wall-ends-hint.tsx +89 -0
- package/src/components/editor/quick-measurement-hud.tsx +3 -2
- package/src/components/editor/registry-action-contributions.test.tsx +63 -0
- package/src/components/editor/registry-action-contributions.tsx +80 -1
- package/src/components/editor/riser-diagram-panel.tsx +14 -7
- 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 +233 -0
- package/src/components/editor/room-labels.test.ts +850 -0
- package/src/components/editor/room-labels.tsx +1890 -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 +474 -419
- package/src/components/editor/slab-hole-highlights.tsx +5 -2
- package/src/components/editor/snapshot-capture-overlay.tsx +7 -2
- package/src/components/editor/snapshot-capture.test.ts +119 -60
- package/src/components/editor/snapshot-capture.ts +36 -5
- package/src/components/editor/thumbnail-generator.test.ts +654 -0
- package/src/components/editor/thumbnail-generator.tsx +1103 -223
- package/src/components/editor/use-floorplan-background-placement.ts +34 -12
- 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 +663 -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.test.tsx +54 -0
- package/src/components/editor/views/view-bar.tsx +343 -0
- package/src/components/editor/wall-justification.test.ts +140 -0
- package/src/components/editor/wall-move-side-handles.tsx +43 -142
- package/src/components/editor/wall-push-arrow.tsx +92 -0
- package/src/components/editor/wall-region-handles.tsx +509 -0
- package/src/components/editor/wall-snap-beacon-layer.tsx +8 -4
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +8 -4
- package/src/components/editor-2d/floorplan-group-move.tsx +3 -0
- 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 +2 -2
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +11 -1
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +116 -106
- package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +156 -0
- package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +278 -0
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +77 -0
- package/src/components/editor-2d/renderers/{floorplan-draft-wall-measurement.tsx → floorplan-draft-measurement.tsx} +8 -8
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +4 -1
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +38 -8
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +319 -233
- package/src/components/editor-2d/renderers/floorplan-reveal.test.tsx +254 -0
- package/src/components/editor-2d/renderers/floorplan-reveal.ts +141 -0
- package/src/components/editor-2d/svg-paths.ts +12 -15
- package/src/components/point-ask/bubble.tsx +538 -0
- package/src/components/point-ask/hover-chip.tsx +91 -0
- package/src/components/point-ask/mode-pill.tsx +92 -0
- package/src/components/point-ask/optional-motion.ts +22 -0
- package/src/components/point-ask/outline-corners.tsx +93 -0
- package/src/components/point-ask/pins.tsx +711 -0
- package/src/components/point-ask/point-ask-layer.tsx +83 -0
- package/src/components/point-ask/point-ask-styles.ts +105 -0
- package/src/components/point-ask/region-marquee-styles.ts +15 -0
- package/src/components/point-ask/region-marquee.tsx +64 -0
- package/src/components/procedural-items/inspector.tsx +40 -15
- package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +12 -2
- 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/roof/roof-edit-system.tsx +1 -0
- package/src/components/systems/zone/zone-system.test.ts +47 -0
- package/src/components/systems/zone/zone-system.tsx +29 -68
- package/src/components/tools/distribution/connection-compatibility.test.ts +34 -0
- package/src/components/tools/distribution/connection-compatibility.ts +43 -0
- package/src/components/tools/distribution/distribution-public.ts +9 -0
- package/src/components/tools/distribution/distribution-run-contract.test.ts +26 -0
- package/src/components/tools/distribution/distribution-run-contract.ts +94 -0
- package/src/components/tools/distribution/distribution-run-tool.test.ts +241 -0
- package/src/components/tools/distribution/distribution-run-tool.test.tsx +125 -0
- package/src/components/tools/distribution/distribution-run-tool.tsx +1323 -0
- package/src/components/tools/distribution/draw-alignment.ts +35 -0
- package/src/components/tools/distribution/ports.test.ts +56 -0
- package/src/components/tools/distribution/ports.ts +378 -0
- package/src/components/tools/distribution/run-cursor.test.ts +135 -0
- package/src/components/tools/distribution/run-cursor.ts +36 -0
- package/src/components/tools/distribution/run-direction-feedback.test.ts +60 -0
- package/src/components/tools/distribution/run-direction-feedback.tsx +251 -0
- package/src/components/tools/distribution/run-port-snap.test.ts +79 -0
- package/src/components/tools/distribution/run-port-snap.ts +25 -0
- package/src/components/tools/distribution/run-screen-direction.test.ts +23 -0
- package/src/components/tools/distribution/run-screen-direction.ts +30 -0
- package/src/components/tools/elevator/elevator-tool.tsx +3 -6
- package/src/components/tools/elevator/move-elevator-tool.tsx +2 -3
- package/src/components/tools/item/item-surface-strategy-resolver.test.ts +67 -68
- package/src/components/tools/item/placement-history.test.tsx +512 -0
- package/src/components/tools/item/placement-strategies.test.ts +46 -1
- package/src/components/tools/item/placement-strategies.ts +11 -10
- package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +56 -56
- package/src/components/tools/item/use-draft-node.test.tsx +432 -1
- package/src/components/tools/item/use-draft-node.ts +166 -95
- package/src/components/tools/item/use-placement-coordinator.tsx +148 -95
- 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 +62 -63
- package/src/components/tools/registry/item-surface-move.ts +2 -0
- package/src/components/tools/registry/item-surface-resolver.test.ts +26 -26
- package/src/components/tools/registry/move-registry-node-tool.test.ts +268 -1
- package/src/components/tools/registry/move-registry-node-tool.tsx +66 -30
- package/src/components/tools/select/box-select-tool.tsx +2 -12
- package/src/components/tools/select/plane-box-select-tool.test.tsx +79 -0
- package/src/components/tools/select/plane-box-select-tool.tsx +35 -22
- package/src/components/tools/select/select-candidates.test.ts +37 -4
- package/src/components/tools/select/select-candidates.ts +15 -11
- package/src/components/tools/shared/cursor-sphere.tsx +36 -12
- package/src/components/tools/site/site-boundary-editor.tsx +1 -2
- package/src/components/tools/tool-manager.tsx +59 -24
- package/src/components/tools/wall/wall-drafting.test.ts +175 -47
- package/src/components/tools/zone/zone-tool.tsx +4 -12
- package/src/components/ui/action-menu/action-button.tsx +9 -1
- package/src/components/ui/action-menu/camera-actions.tsx +9 -4
- package/src/components/ui/action-menu/control-modes.tsx +15 -42
- package/src/components/ui/action-menu/follow-pascal-pill.tsx +32 -0
- package/src/components/ui/action-menu/follow-pascal-toggle.tsx +36 -0
- package/src/components/ui/action-menu/index.tsx +56 -33
- package/src/components/ui/action-menu/measurement-control.tsx +14 -19
- package/src/components/ui/action-menu/placement.ts +20 -0
- package/src/components/ui/action-menu/point-ask-button.tsx +52 -0
- package/src/components/ui/action-menu/view-toggles.tsx +47 -42
- package/src/components/ui/build-panel/build-panel.tsx +242 -0
- package/src/components/ui/command-palette/editor-commands.tsx +7 -42
- package/src/components/ui/command-palette/index.tsx +50 -39
- package/src/components/ui/command-palette/ranked-groups.test.ts +31 -0
- package/src/components/ui/controls/action-button.tsx +5 -2
- package/src/components/ui/controls/finish-swatch.tsx +110 -0
- package/src/components/ui/controls/material-paint-panel.tsx +97 -26
- package/src/components/ui/controls/material-picker.tsx +4 -1
- package/src/components/ui/controls/metric-control.tsx +1 -1
- package/src/components/ui/controls/scene-material-list.tsx +4 -32
- package/src/components/ui/controls/segmented-control.tsx +21 -8
- package/src/components/ui/controls/select-control.tsx +65 -0
- package/src/components/ui/controls/slider-control.tsx +1 -1
- package/src/components/ui/controls/toggle-control.tsx +3 -3
- package/src/components/ui/controls/tool-options-panel.tsx +1 -1
- package/src/components/ui/floating-level-selector.tsx +159 -59
- package/src/components/ui/helpers/building-helper.tsx +4 -1
- package/src/components/ui/helpers/contextual-helper-panel.tsx +181 -72
- package/src/components/ui/helpers/helper-manager.test.tsx +121 -0
- package/src/components/ui/helpers/helper-manager.tsx +331 -14
- package/src/components/ui/helpers/item-helper.tsx +17 -1
- package/src/components/ui/helpers/registered-tool-helper.tsx +8 -1
- package/src/components/ui/icon-ref.tsx +45 -1
- package/src/components/ui/level-duplicate-dialog.tsx +1 -1
- package/src/components/ui/node-icon-image.tsx +16 -0
- package/src/components/ui/panels/collections/collections-popover.tsx +28 -22
- 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 +4 -4
- package/src/components/ui/panels/mobile-selection-bar.tsx +8 -5
- package/src/components/ui/panels/multi-field-value.test.ts +26 -3
- package/src/components/ui/panels/multi-field-value.ts +6 -3
- package/src/components/ui/panels/multi-parametric-inspector.tsx +52 -2
- package/src/components/ui/panels/multi-selection-panel.tsx +161 -5
- 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 +130 -62
- package/src/components/ui/panels/parametric-field-control.tsx +3 -3
- package/src/components/ui/panels/parametric-inspector.tsx +7 -7
- 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 +790 -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/badge.tsx +8 -0
- package/src/components/ui/primitives/color-dot.tsx +36 -24
- package/src/components/ui/primitives/sidebar.tsx +3 -1
- package/src/components/ui/right-stack.test.ts +29 -0
- package/src/components/ui/right-stack.tsx +164 -0
- package/src/components/ui/sidebar/app-sidebar.tsx +1 -1
- package/src/components/ui/sidebar/icon-rail.tsx +8 -3
- package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +1 -1
- package/src/components/ui/sidebar/panels/items-panel/index.tsx +6 -6
- package/src/components/ui/sidebar/panels/settings-panel/animation-speed-setting.tsx +40 -0
- package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +7 -8
- package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.test.tsx +170 -0
- package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.tsx +92 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +138 -27
- package/src/components/ui/sidebar/panels/settings-panel/interface-theme.test.tsx +20 -0
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +5 -5
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +4 -10
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -11
- 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 +181 -223
- 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 +13 -37
- 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 +2 -4
- package/src/components/ui/sidebar/tab-bar.tsx +14 -4
- package/src/components/ui/sidebar/use-plugin-panels.tsx +15 -5
- package/src/components/viewer/floorplan-compass-button.tsx +9 -1
- package/src/components/viewer/floorplan-preview.tsx +5 -6
- package/src/components/viewer/viewer-controls-bar.tsx +18 -6
- package/src/components/viewer/viewer-hover-label.tsx +34 -0
- package/src/components/viewer/viewer-rooms.tsx +191 -0
- package/src/components/viewer/viewer-scene-header.tsx +181 -145
- package/src/components/viewer/viewer-selection-manager.tsx +124 -0
- package/src/components/viewer/viewer-stage.tsx +2 -2
- package/src/components/viewer-zone-system.tsx +3 -8
- package/src/hooks/use-auto-save.integration.test.tsx +215 -43
- package/src/hooks/use-auto-save.ts +60 -9
- package/src/hooks/use-ceiling-events.test.ts +123 -118
- package/src/hooks/use-grid-events.test.ts +105 -105
- package/src/hooks/use-keyboard.test.ts +243 -19
- package/src/hooks/use-keyboard.ts +118 -83
- 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 +71 -0
- package/src/hooks/use-placement-notice.ts +73 -0
- package/src/hooks/use-selected-room.ts +143 -0
- package/src/index.tsx +312 -58
- package/src/lib/__fixtures__/owner-victor-seattle-parking.json +1 -0
- package/src/lib/__fixtures__/owner-villa-aurelia-basement.json +1 -0
- package/src/lib/add-storey.test.ts +177 -0
- package/src/lib/add-storey.ts +59 -0
- package/src/lib/below-grade.test.tsx +171 -0
- package/src/lib/below-grade.ts +133 -0
- package/src/lib/camera-facing.test.ts +24 -0
- package/src/lib/camera-facing.ts +24 -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/continuation.test.ts +19 -1
- package/src/lib/continuation.ts +25 -4
- package/src/lib/direct-manipulation.ts +2 -0
- package/src/lib/door-interaction.ts +14 -0
- package/src/lib/download-project-file.ts +17 -0
- package/src/lib/editor-level-display.test.ts +96 -0
- package/src/lib/editor-level-display.ts +55 -0
- package/src/lib/editor-views.ts +178 -0
- package/src/lib/export-geometry-wait.test.ts +90 -0
- package/src/lib/export-texture-utils.ts +2 -4
- 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 +284 -0
- package/src/lib/floor-opening-draft.ts +218 -0
- package/src/lib/floor-openings.test.ts +216 -0
- package/src/lib/floor-region-geometry.ts +191 -0
- package/src/lib/floor-region-session.test.ts +485 -0
- package/src/lib/floor-region-session.ts +589 -0
- package/src/lib/floor-region-snap.ts +101 -0
- package/src/lib/floor-step-hover.test.ts +32 -0
- package/src/lib/floorplan/annotation-visibility.ts +17 -0
- package/src/lib/floorplan/dimension-drive.test.ts +360 -0
- package/src/lib/floorplan/dimension-drive.ts +479 -0
- package/src/lib/floorplan/floorplan-export.test.ts +12 -8
- package/src/lib/floorplan/floorplan-export.tsx +32 -8
- package/src/lib/floorplan/floorplan-extension.ts +32 -0
- package/src/lib/floorplan/floorplan-mode.test.ts +4 -0
- package/src/lib/floorplan/floorplan-mode.ts +2 -0
- package/src/lib/floorplan/floorplan-pdfkit-document.ts +363 -27
- package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +177 -8
- package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +60 -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/pdfkit-standard-fonts.d.ts +5 -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 +124 -0
- package/src/lib/floorplan/sheet-export.ts +318 -0
- package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +768 -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 +230 -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 +157 -0
- package/src/lib/floorplan/site-plan/geometry.test.ts +269 -0
- package/src/lib/floorplan/site-plan/geometry.ts +142 -0
- package/src/lib/floorplan/site-plan/index.ts +55 -0
- package/src/lib/floorplan/site-plan/site-annotations.test.ts +483 -0
- package/src/lib/floorplan/site-plan/site-annotations.ts +1084 -0
- package/src/lib/floorplan/site-plan/site-parts.ts +287 -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 +70 -0
- package/src/lib/floorplan/types.ts +1 -27
- package/src/lib/follow-pascal-exterior.test-fixture.ts +17 -0
- package/src/lib/follow-pascal-exterior.test.ts +171 -0
- package/src/lib/follow-pascal-exterior.ts +39 -0
- package/src/lib/follow-pascal-presentation.test.ts +203 -0
- package/src/lib/follow-pascal-presentation.ts +129 -0
- package/src/lib/follow-pascal-sight.ts +84 -0
- package/src/lib/follow-pascal-targets.test.ts +406 -0
- package/src/lib/follow-pascal-targets.ts +198 -0
- package/src/lib/follow-pascal.test.ts +350 -0
- package/src/lib/follow-pascal.ts +284 -0
- package/src/lib/fresh-planar-placement.test.ts +148 -0
- package/src/lib/fresh-planar-placement.ts +114 -37
- package/src/lib/geometry-script/author.ts +157 -0
- package/src/lib/geometry-script/client.ts +159 -0
- package/src/lib/geometry-script/finish-scripts.ts +127 -0
- package/src/lib/geometry-script/protocol.ts +13 -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 +636 -126
- package/src/lib/glb-export.ts +241 -120
- package/src/lib/history.test.ts +18 -7
- 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 +22 -0
- package/src/lib/keyboard-pan.test.ts +129 -7
- package/src/lib/keyboard-pan.ts +161 -12
- package/src/lib/level-selection.test.ts +57 -0
- package/src/lib/level-selection.ts +16 -1
- 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 +187 -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 +392 -0
- package/src/lib/lot/index.ts +43 -0
- package/src/lib/lot/lot-patch.test.ts +340 -0
- package/src/lib/lot/lot-patch.ts +394 -0
- package/src/lib/lot/parcel-provider.ts +43 -0
- package/src/lib/lot/terrain.test.ts +161 -0
- package/src/lib/lot/terrain.ts +266 -0
- package/src/lib/material-catalog-model.ts +3 -1
- package/src/lib/material-paint-panel-model.ts +9 -5
- package/src/lib/material-paint.ts +4 -0
- package/src/lib/measurement-kind.test.ts +6 -8
- package/src/lib/measurements.test.ts +23 -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 +201 -0
- package/src/lib/orbit-floor-clearance.ts +154 -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 +122 -0
- package/src/lib/paint-pass-through.ts +78 -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 +267 -25
- package/src/lib/panel-rows.ts +21 -0
- package/src/lib/parametric-node-update.ts +16 -7
- 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 +65 -0
- package/src/lib/plugin-panels.test.ts +25 -0
- package/src/lib/plugin-panels.ts +94 -0
- package/src/lib/point-ask/__fixtures__/consumer-browser.tsx +76 -0
- package/src/lib/point-ask/__fixtures__/house.ts +81 -0
- package/src/lib/point-ask/ask-created.test.ts +195 -0
- package/src/lib/point-ask/ask-flow.test.ts +380 -0
- package/src/lib/point-ask/build-context.test.ts +173 -0
- package/src/lib/point-ask/build-context.ts +94 -0
- package/src/lib/point-ask/capture-crop.ts +129 -0
- package/src/lib/point-ask/choreography.ts +69 -0
- package/src/lib/point-ask/compare.test.ts +145 -0
- package/src/lib/point-ask/compare.ts +73 -0
- package/src/lib/point-ask/describe-target.test.ts +165 -0
- package/src/lib/point-ask/describe-target.ts +234 -0
- package/src/lib/point-ask/dictation.ts +79 -0
- package/src/lib/point-ask/edge-marker.test.ts +61 -0
- package/src/lib/point-ask/edge-marker.ts +27 -0
- package/src/lib/point-ask/frame-loop.ts +25 -0
- package/src/lib/point-ask/history-marks.test.ts +94 -0
- package/src/lib/point-ask/history-marks.ts +39 -0
- package/src/lib/point-ask/host-api.ts +32 -0
- package/src/lib/point-ask/outline-box.test.ts +30 -0
- package/src/lib/point-ask/outline-box.ts +11 -0
- package/src/lib/point-ask/overlay-metrics.ts +6 -0
- package/src/lib/point-ask/pick.test.ts +220 -0
- package/src/lib/point-ask/pick.ts +121 -0
- package/src/lib/point-ask/pin-machine.test.ts +494 -0
- package/src/lib/point-ask/pin-machine.ts +217 -0
- package/src/lib/point-ask/placement.test.ts +203 -0
- package/src/lib/point-ask/placement.ts +141 -0
- package/src/lib/point-ask/pointer-tracker.ts +34 -0
- package/src/lib/point-ask/projector.ts +83 -0
- package/src/lib/point-ask/region.test.ts +157 -0
- package/src/lib/point-ask/region.ts +136 -0
- package/src/lib/point-ask/retry.test.ts +217 -0
- package/src/lib/point-ask/retry.ts +37 -0
- package/src/lib/point-ask/scene-commit.test.ts +312 -0
- package/src/lib/point-ask/scene-commit.ts +32 -0
- package/src/lib/point-ask/session.ts +1391 -0
- package/src/lib/point-ask/spring-keyframes.test.ts +45 -0
- package/src/lib/point-ask/spring-keyframes.ts +38 -0
- package/src/lib/point-ask/spring.test.ts +63 -0
- package/src/lib/point-ask/spring.ts +107 -0
- package/src/lib/point-ask/suggestions.test.ts +80 -0
- package/src/lib/point-ask/suggestions.ts +125 -0
- package/src/lib/point-ask/target-scope.test.ts +622 -0
- package/src/lib/point-ask/target-scope.ts +338 -0
- package/src/lib/point-ask/types.ts +48 -0
- package/src/lib/portable-export.test.ts +34 -1
- package/src/lib/portable-export.ts +119 -53
- package/src/lib/print-feature-thickness.ts +10 -1
- package/src/lib/print-roof-solids.test.ts +39 -1
- package/src/lib/print-roof-solids.ts +43 -10
- package/src/lib/print-shell-compiler-manifold-core.ts +1 -1
- 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 +265 -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 +1017 -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 +610 -1
- package/src/lib/scene-clipboard.ts +269 -58
- package/src/lib/scene-material-refs.test.ts +79 -0
- package/src/lib/scene-material-refs.ts +79 -0
- package/src/lib/scene.test.ts +24 -36
- package/src/lib/scene.ts +33 -57
- package/src/lib/selection-routing.test.ts +129 -45
- package/src/lib/selection-routing.ts +54 -75
- package/src/lib/session-writes.test.ts +120 -0
- package/src/lib/session-writes.ts +205 -0
- package/src/lib/sfx-bus.ts +13 -0
- package/src/lib/sfx-player.test.ts +70 -9
- package/src/lib/sfx-player.ts +6 -0
- package/src/lib/sidebar-panel.ts +62 -0
- package/src/lib/site-boundary.test.ts +4 -6
- package/src/lib/site-boundary.ts +1 -1
- package/src/lib/snapping-mode.test.ts +4 -0
- package/src/lib/snapping-mode.ts +8 -1
- package/src/lib/stair-levels.test.ts +25 -0
- package/src/lib/stair-levels.ts +22 -3
- package/src/lib/terrace-draft.test.ts +145 -0
- package/src/lib/terrace-draft.ts +214 -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 +580 -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-placement-pointer.test.ts +42 -0
- package/src/lib/wall-placement-pointer.ts +57 -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-changes.ts +35 -0
- package/src/lib/window-interaction.ts +14 -0
- package/src/lib/zone-content.ts +4 -1
- package/src/lib/zone-name.test.ts +17 -0
- package/src/lib/zone-name.ts +17 -0
- package/src/store/opens-in-select-mode.test.ts +223 -0
- package/src/store/paint-mode.test.ts +188 -0
- package/src/store/terrain-sculpt-mode.test.ts +19 -21
- package/src/store/tool-mode.test.ts +137 -6
- package/src/store/use-audio.test.ts +146 -0
- package/src/store/use-audio.tsx +127 -3
- package/src/store/use-construction-reveal.test.ts +173 -0
- package/src/store/use-construction-reveal.ts +145 -0
- package/src/store/use-corner-snap-hint.test.ts +24 -0
- package/src/store/use-corner-snap-hint.ts +47 -0
- package/src/store/use-delete-confirmation.ts +28 -1
- package/src/store/use-drawing-view.test.ts +24 -3
- package/src/store/use-drawing-view.ts +29 -1
- package/src/store/use-editor.tsx +432 -321
- package/src/store/use-floorplan-draft-preview.ts +13 -0
- package/src/store/use-floorplan-marquee.ts +12 -2
- package/src/store/use-follow-pascal.test.ts +62 -0
- package/src/store/use-follow-pascal.ts +53 -0
- package/src/store/use-point-ask.ts +247 -0
- package/src/store/use-quick-measurement-hud.ts +3 -3
- package/src/store/use-stair-build-preview.ts +33 -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 +201 -0
- package/src/store/view-layout.ts +199 -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/render-current-markup.tsx +16 -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/tools/stair/stair-click-guard.test.ts +0 -58
- package/src/components/tools/stair/stair-click-guard.ts +0 -72
- package/src/components/tools/stair/stair-defaults.ts +0 -18
- package/src/components/tools/stair/stair-tool.tsx +0 -634
- 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,350 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { LevelNode, sceneRegistry, useScene } from '@pascal-app/core'
|
|
3
|
+
import { BATCHED_LAYER, OVERLAY_LAYER, SHADOW_ONLY_LAYER, useViewer } from '@pascal-app/viewer'
|
|
4
|
+
import { BoxGeometry, DoubleSide, Group, Mesh, MeshBasicMaterial, Raycaster, Vector3 } from 'three'
|
|
5
|
+
import {
|
|
6
|
+
type Box,
|
|
7
|
+
clearFrame,
|
|
8
|
+
FOLLOW_DWELL_S,
|
|
9
|
+
FOLLOW_ELEVATION,
|
|
10
|
+
FOLLOW_GATHER_S,
|
|
11
|
+
Follower,
|
|
12
|
+
type FollowWant,
|
|
13
|
+
frameOf,
|
|
14
|
+
mergeWants,
|
|
15
|
+
OrbitSpring,
|
|
16
|
+
} from './follow-pascal'
|
|
17
|
+
import { FollowSight } from './follow-pascal-sight'
|
|
18
|
+
|
|
19
|
+
const box = (min: [number, number, number], max: [number, number, number]): Box => ({ min, max })
|
|
20
|
+
const want = (kind: FollowWant['kind'], b: Box, at = 0, normal?: [number, number]): FollowWant => ({
|
|
21
|
+
kind,
|
|
22
|
+
box: b,
|
|
23
|
+
at,
|
|
24
|
+
...(normal ? { normal } : {}),
|
|
25
|
+
})
|
|
26
|
+
const VIEW = { fov: 45, aspect: 1.6, azimuth: 0.6 }
|
|
27
|
+
|
|
28
|
+
// Follow Pascal (the owner, 2026-10-08): the camera eases to what the current step is working on,
|
|
29
|
+
// at the distance its scale needs; about 1.5 s on each target at least, several things landing
|
|
30
|
+
// together framed as one; a level horizon; touching the camera pauses it.
|
|
31
|
+
describe('what the camera looks at next', () => {
|
|
32
|
+
test('waits to gather what lands together, and then to dwell on the last target', () => {
|
|
33
|
+
const follower = new Follower()
|
|
34
|
+
follower.want(want('floor', box([0, 0, 0], [10, 3, 8]), 0))
|
|
35
|
+
expect(follower.due(0.1)).toBeNull()
|
|
36
|
+
expect(follower.due(FOLLOW_GATHER_S - 0.01)).toBeNull()
|
|
37
|
+
const first = follower.due(FOLLOW_GATHER_S + 0.01)
|
|
38
|
+
expect(first?.kind).toBe('floor')
|
|
39
|
+
// Nothing is due while it dwells, however much is waiting.
|
|
40
|
+
follower.want(want('detail', box([1, 1, 0], [2, 2, 0.2]), 0.5))
|
|
41
|
+
expect(follower.due(FOLLOW_GATHER_S + 0.9)).toBeNull()
|
|
42
|
+
expect(follower.due(FOLLOW_GATHER_S + 0.01 + FOLLOW_DWELL_S - 0.02)).toBeNull()
|
|
43
|
+
expect(follower.due(FOLLOW_GATHER_S + 0.01 + FOLLOW_DWELL_S + 0.02)?.kind).toBe('detail')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('several things landing together are one target: their union', () => {
|
|
47
|
+
const merged = mergeWants([
|
|
48
|
+
want('detail', box([0, 0, 0], [1, 2, 0.2])),
|
|
49
|
+
want('detail', box([4, 0, 0], [5, 2, 0.2])),
|
|
50
|
+
want('detail', box([8, 0, 0], [9, 2, 0.2])),
|
|
51
|
+
])
|
|
52
|
+
expect(merged.box).toEqual(box([0, 0, 0], [9, 2, 0.2]))
|
|
53
|
+
expect(merged.kind).toBe('detail')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('different kinds together are a group', () => {
|
|
57
|
+
expect(
|
|
58
|
+
mergeWants([
|
|
59
|
+
want('detail', box([0, 0, 0], [1, 1, 1])),
|
|
60
|
+
want('roof', box([0, 3, 0], [8, 5, 6])),
|
|
61
|
+
]).kind,
|
|
62
|
+
).toBe('group')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('targets on different floors are visited separately, not framed across hidden floors', () => {
|
|
66
|
+
const follower = new Follower()
|
|
67
|
+
follower.want({ ...want('furnish', box([0, 0, 0], [1, 1, 1])), levelId: 'level_lower' })
|
|
68
|
+
follower.want({
|
|
69
|
+
...want('furnish', box([0, 3, 0], [1, 4, 1]), 0.1),
|
|
70
|
+
levelId: 'level_upper',
|
|
71
|
+
})
|
|
72
|
+
expect(follower.due(0.5)?.levelId).toBe('level_lower')
|
|
73
|
+
expect(follower.due(0.6)).toBeNull()
|
|
74
|
+
expect(follower.due(2)?.levelId).toBe('level_upper')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('a shared direction survives the merge; a mixed one does not', () => {
|
|
78
|
+
const facing = mergeWants([
|
|
79
|
+
want('detail', box([0, 0, 0], [1, 1, 1]), 0, [0, 1]),
|
|
80
|
+
want('detail', box([2, 0, 0], [3, 1, 1]), 0, [0.2, 0.98]),
|
|
81
|
+
])
|
|
82
|
+
expect(facing.normal?.[1]).toBeGreaterThan(0.9)
|
|
83
|
+
const mixed = mergeWants([
|
|
84
|
+
want('detail', box([0, 0, 0], [1, 1, 1]), 0, [0, 1]),
|
|
85
|
+
want('detail', box([2, 0, 0], [3, 1, 1]), 0, [0, -1]),
|
|
86
|
+
])
|
|
87
|
+
expect(mixed.normal).toBeUndefined()
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('a blocked follower (the person is pointing at something) holds what it has', () => {
|
|
91
|
+
const follower = new Follower()
|
|
92
|
+
follower.want(want('floor', box([0, 0, 0], [10, 3, 8]), 0))
|
|
93
|
+
expect(follower.due(5, true)).toBeNull()
|
|
94
|
+
expect(follower.due(5)?.kind).toBe('floor')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('a reset forgets what waits and starts its dwell over', () => {
|
|
98
|
+
const follower = new Follower()
|
|
99
|
+
follower.want(want('floor', box([0, 0, 0], [10, 3, 8]), 0))
|
|
100
|
+
follower.due(1)
|
|
101
|
+
follower.want(want('roof', box([0, 3, 0], [10, 5, 8]), 1.2))
|
|
102
|
+
follower.reset()
|
|
103
|
+
expect(follower.due(10)).toBeNull()
|
|
104
|
+
follower.want(want('roof', box([0, 3, 0], [10, 5, 8]), 10))
|
|
105
|
+
expect(follower.due(10.5)?.kind).toBe('roof')
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
describe('how far the camera stands', () => {
|
|
110
|
+
const house = box([0, 0, 0], [12, 3, 8])
|
|
111
|
+
|
|
112
|
+
test('a floor is seen whole: the camera stands back until it fits', () => {
|
|
113
|
+
const goal = frameOf(want('floor', house), VIEW)
|
|
114
|
+
expect([goal.x, goal.z]).toEqual([6, 4])
|
|
115
|
+
expect(goal.el).toBe(FOLLOW_ELEVATION.floor)
|
|
116
|
+
// Half of the wide side over the tangent of the narrower half-angle, with room to spare.
|
|
117
|
+
const needed = 6 / Math.tan(Math.atan(Math.tan((45 * Math.PI) / 360) * 1.6) * 1)
|
|
118
|
+
expect(goal.r).toBeGreaterThan(Math.min(needed, 6 / Math.tan((45 * Math.PI) / 360)) * 0.9)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('a window is seen close, a floor far, a group in between', () => {
|
|
122
|
+
const window = frameOf(want('detail', box([3, 0.9, -0.1], [4.2, 2.4, 0.1])), VIEW)
|
|
123
|
+
const floor = frameOf(want('floor', house), VIEW)
|
|
124
|
+
const group = frameOf(want('group', box([0, 0, 0], [6, 3, 4])), VIEW)
|
|
125
|
+
expect(window.r).toBeLessThan(floor.r / 2)
|
|
126
|
+
expect(group.r).toBeLessThan(floor.r)
|
|
127
|
+
expect(group.r).toBeGreaterThan(window.r)
|
|
128
|
+
// The sill of a close-up is seen from nearly level; the furniture, from above.
|
|
129
|
+
expect(window.el).toBeLessThan(frameOf(want('furnish', box([1, 0, 1], [2, 1, 2])), VIEW).el)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('a narrow window of the screen needs more distance than a wide one', () => {
|
|
133
|
+
const wide = frameOf(want('floor', house), { ...VIEW, aspect: 2 })
|
|
134
|
+
const narrow = frameOf(want('floor', house), { ...VIEW, aspect: 0.8 })
|
|
135
|
+
expect(narrow.r).toBeGreaterThan(wide.r)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('what faces a wall is looked at from its outside, on the side nearest the way it already looks', () => {
|
|
139
|
+
const goal = frameOf(want('detail', box([3, 0.9, -0.1], [4.2, 2.4, 0.1]), 0, [0, -1]), {
|
|
140
|
+
...VIEW,
|
|
141
|
+
azimuth: 0.3,
|
|
142
|
+
})
|
|
143
|
+
// The wall's outside is -z: the eye stands at -z, within half a radian of facing it.
|
|
144
|
+
const toEye = Math.atan2(Math.sin(goal.az), Math.cos(goal.az))
|
|
145
|
+
expect(Math.abs(Math.abs(toEye) - Math.PI)).toBeLessThan(0.6)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('the roof is looked at from above its ridge line, the floor from its middle height', () => {
|
|
149
|
+
const roof = frameOf(want('roof', box([0, 3, 0], [12, 5.2, 8])), VIEW)
|
|
150
|
+
expect(roof.y).toBeLessThan(4.1 + 1e-9)
|
|
151
|
+
expect(roof.el).toBeGreaterThan(FOLLOW_ELEVATION.floor)
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
describe('an eye that must clear something', () => {
|
|
156
|
+
const item = box([4, 0, 3], [4.6, 0.9, 3.6])
|
|
157
|
+
|
|
158
|
+
test('stands back until it is above what it must clear', () => {
|
|
159
|
+
const plain = frameOf(want('furnish', item), VIEW)
|
|
160
|
+
const high = frameOf({ ...want('furnish', item), eyeAbove: 8 }, VIEW)
|
|
161
|
+
const eyeY = (goal: ReturnType<typeof frameOf>) => goal.y + goal.r * Math.sin(goal.el)
|
|
162
|
+
expect(eyeY(plain)).toBeLessThan(8)
|
|
163
|
+
expect(eyeY(high)).toBeGreaterThanOrEqual(8 - 1e-9)
|
|
164
|
+
expect(high.r).toBeGreaterThan(plain.r)
|
|
165
|
+
// Same place, same direction: only further back.
|
|
166
|
+
expect([high.x, high.y, high.z, high.el]).toEqual([plain.x, plain.y, plain.z, plain.el])
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
test('is not pushed back when it is already clear', () => {
|
|
170
|
+
const roomy = box([0, 0, 0], [12, 3, 8])
|
|
171
|
+
const goal = frameOf({ ...want('floor', roomy), eyeAbove: 2 }, VIEW)
|
|
172
|
+
expect(goal.r).toBe(frameOf(want('floor', roomy), VIEW).r)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
test('what lands together keeps the highest it must clear', () => {
|
|
176
|
+
const merged = mergeWants([
|
|
177
|
+
{ ...want('furnish', item), eyeAbove: 6 },
|
|
178
|
+
{ ...want('furnish', item), eyeAbove: 9 },
|
|
179
|
+
want('furnish', item),
|
|
180
|
+
])
|
|
181
|
+
expect(merged.eyeAbove).toBe(9)
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
describe('keeping the followed target in sight', () => {
|
|
186
|
+
test('sight queries ignore the target, hidden helpers and upper floors, but detect batch sources', () => {
|
|
187
|
+
const scene = new Group()
|
|
188
|
+
scene.name = 'scene-renderer'
|
|
189
|
+
const target = new Mesh(new BoxGeometry(1, 1, 1), new MeshBasicMaterial())
|
|
190
|
+
target.position.set(0, 1, 0)
|
|
191
|
+
const upperRoot = new Group()
|
|
192
|
+
const obstacle = new Mesh(new BoxGeometry(8, 8, 1), new MeshBasicMaterial())
|
|
193
|
+
obstacle.position.set(0, 4, 5)
|
|
194
|
+
upperRoot.add(obstacle)
|
|
195
|
+
scene.add(target, upperRoot)
|
|
196
|
+
sceneRegistry.nodes.set('item_sight', target)
|
|
197
|
+
sceneRegistry.nodes.set('level_sight_upper', upperRoot)
|
|
198
|
+
const beforeScene = useScene.getState()
|
|
199
|
+
const beforeViewer = useViewer.getState()
|
|
200
|
+
const lower = LevelNode.parse({ id: 'level_sight_lower', level: 0 })
|
|
201
|
+
const upper = LevelNode.parse({ id: 'level_sight_upper', level: 1 })
|
|
202
|
+
const request = {
|
|
203
|
+
...want('furnish', box([-0.5, 0.5, -0.5], [0.5, 1.5, 0.5])),
|
|
204
|
+
nodeIds: ['item_sight'],
|
|
205
|
+
}
|
|
206
|
+
const pose = { position: [0, 3, 10], target: [0, 1, 0], projection: 'perspective' } as const
|
|
207
|
+
const sight = new FollowSight(scene)
|
|
208
|
+
const clear = () => {
|
|
209
|
+
sight.refresh(request)
|
|
210
|
+
return sight.clear({ ...pose, position: [...pose.position], target: [...pose.target] })
|
|
211
|
+
}
|
|
212
|
+
try {
|
|
213
|
+
useScene.setState({ nodes: { [lower.id]: lower, [upper.id]: upper } })
|
|
214
|
+
useViewer.getState().setSelection({ levelId: lower.id })
|
|
215
|
+
useViewer.setState({ hideLevelsAboveSelection: false })
|
|
216
|
+
expect(clear()).toBe(false)
|
|
217
|
+
upperRoot.visible = false
|
|
218
|
+
expect(clear()).toBe(true)
|
|
219
|
+
upperRoot.visible = true
|
|
220
|
+
obstacle.layers.set(OVERLAY_LAYER)
|
|
221
|
+
expect(clear()).toBe(true)
|
|
222
|
+
obstacle.layers.set(SHADOW_ONLY_LAYER)
|
|
223
|
+
expect(clear()).toBe(true)
|
|
224
|
+
obstacle.layers.set(BATCHED_LAYER)
|
|
225
|
+
expect(clear()).toBe(false)
|
|
226
|
+
useViewer.setState({ hideLevelsAboveSelection: true })
|
|
227
|
+
expect(clear()).toBe(true)
|
|
228
|
+
// The storey a reveal is playing on shows, so what is batched on it stands in the way again.
|
|
229
|
+
useViewer.setState({ levelsRevealing: [upper.id] })
|
|
230
|
+
expect(clear()).toBe(false)
|
|
231
|
+
useViewer.setState({ levelsRevealing: [], hideLevelsAboveSelection: false })
|
|
232
|
+
;(obstacle.material as MeshBasicMaterial).opacity = 0
|
|
233
|
+
expect(clear()).toBe(true)
|
|
234
|
+
} finally {
|
|
235
|
+
useScene.setState(beforeScene)
|
|
236
|
+
useViewer.setState(beforeViewer)
|
|
237
|
+
sceneRegistry.nodes.delete('item_sight')
|
|
238
|
+
sceneRegistry.nodes.delete('level_sight_upper')
|
|
239
|
+
for (const mesh of [target, obstacle]) {
|
|
240
|
+
mesh.geometry.dispose()
|
|
241
|
+
;(mesh.material as MeshBasicMaterial).dispose()
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
test('raises the camera to a clear line of sight past an obstacle without changing its target or distance', () => {
|
|
247
|
+
const goal = frameOf(want('furnish', box([-0.5, 0, -0.5], [0.5, 1, 0.5])), {
|
|
248
|
+
...VIEW,
|
|
249
|
+
azimuth: 0,
|
|
250
|
+
})
|
|
251
|
+
const obstacle = new Mesh(
|
|
252
|
+
new BoxGeometry(8, 5, 0.5),
|
|
253
|
+
new MeshBasicMaterial({ side: DoubleSide }),
|
|
254
|
+
)
|
|
255
|
+
obstacle.position.set(0, 2.5, 2)
|
|
256
|
+
obstacle.updateMatrixWorld(true)
|
|
257
|
+
const ray = new Raycaster()
|
|
258
|
+
const sees = (pose: ReturnType<OrbitSpring['pose']>) => {
|
|
259
|
+
const eye = new Vector3(...pose.position)
|
|
260
|
+
const direction = new Vector3(...pose.target).sub(eye)
|
|
261
|
+
ray.set(eye, direction.clone().normalize())
|
|
262
|
+
ray.far = direction.length() - 0.01
|
|
263
|
+
return ray.intersectObject(obstacle).length === 0
|
|
264
|
+
}
|
|
265
|
+
expect(sees(new OrbitSpring(goal).pose())).toBe(false)
|
|
266
|
+
const clear = clearFrame(goal, sees)!
|
|
267
|
+
expect(clear).not.toBeNull()
|
|
268
|
+
expect(sees(new OrbitSpring(clear).pose())).toBe(true)
|
|
269
|
+
expect([clear.x, clear.y, clear.z, clear.r]).toEqual([goal.x, goal.y, goal.z, goal.r])
|
|
270
|
+
expect(clear.el).toBeGreaterThan(goal.el)
|
|
271
|
+
expect(clear.el).toBeLessThanOrEqual((75 * Math.PI) / 180)
|
|
272
|
+
expect(clearFrame(clear, sees)).toEqual(clear)
|
|
273
|
+
obstacle.geometry.dispose()
|
|
274
|
+
;(obstacle.material as MeshBasicMaterial).dispose()
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
test('does not change an unobstructed view and reports when the bounded search has no clear view', () => {
|
|
278
|
+
const goal = frameOf(want('detail', box([0, 0, 0], [1, 1, 1])), VIEW)
|
|
279
|
+
expect(clearFrame(goal, () => true)).toEqual(goal)
|
|
280
|
+
expect(clearFrame(goal, () => false)).toBeNull()
|
|
281
|
+
})
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
describe('the camera moves like a spring', () => {
|
|
285
|
+
const goalA = { x: 0, y: 1, z: 0, r: 20, az: 0.5, el: 0.5 }
|
|
286
|
+
const goalB = { x: 6, y: 1.2, z: 3, r: 9, az: 1.4, el: 0.3 }
|
|
287
|
+
|
|
288
|
+
test('reaches its goal without overshooting any of its six values', () => {
|
|
289
|
+
const spring = new OrbitSpring(goalA)
|
|
290
|
+
spring.to(goalB)
|
|
291
|
+
const worst = { r: Math.min(), az: Math.max(), x: Math.max() }
|
|
292
|
+
for (let t = 0; t < 4; t += 1 / 60) {
|
|
293
|
+
spring.step(1 / 60)
|
|
294
|
+
const now = spring.value()
|
|
295
|
+
worst.r = Math.min(worst.r, now.r)
|
|
296
|
+
worst.az = Math.max(worst.az, now.az)
|
|
297
|
+
worst.x = Math.max(worst.x, now.x)
|
|
298
|
+
}
|
|
299
|
+
expect(worst.r).toBeGreaterThanOrEqual(9 - 1e-6)
|
|
300
|
+
expect(worst.az).toBeLessThanOrEqual(1.4 + 1e-6)
|
|
301
|
+
expect(worst.x).toBeLessThanOrEqual(6 + 1e-6)
|
|
302
|
+
const end = spring.value()
|
|
303
|
+
for (const key of Object.keys(goalB) as (keyof typeof goalB)[])
|
|
304
|
+
expect(end[key]).toBeCloseTo(goalB[key], 3)
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
test('goes round the short way, however the angle was written', () => {
|
|
308
|
+
const spring = new OrbitSpring({ ...goalA, az: 3.0 })
|
|
309
|
+
spring.to({ ...goalA, az: -3.0 })
|
|
310
|
+
// -3.0 is 0.28 rad past pi: the way round is 0.28 rad, not 6.
|
|
311
|
+
spring.step(0.05)
|
|
312
|
+
const early = spring.value().az
|
|
313
|
+
expect(Math.abs(early - 3.0)).toBeLessThan(0.3)
|
|
314
|
+
for (let t = 0; t < 4; t += 1 / 60) spring.step(1 / 60)
|
|
315
|
+
const end = spring.value().az
|
|
316
|
+
expect(Math.abs(Math.sin(end - -3.0))).toBeLessThan(1e-3)
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
test('a new goal turns it round from where it is, without a jump', () => {
|
|
320
|
+
const spring = new OrbitSpring(goalA)
|
|
321
|
+
spring.to(goalB)
|
|
322
|
+
for (let index = 0; index < 20; index += 1) spring.step(1 / 60)
|
|
323
|
+
const before = spring.value()
|
|
324
|
+
spring.to(goalA)
|
|
325
|
+
spring.step(1 / 600)
|
|
326
|
+
const after = spring.value()
|
|
327
|
+
expect(Math.abs(after.x - before.x)).toBeLessThan(0.05)
|
|
328
|
+
expect(Math.abs(after.r - before.r)).toBeLessThan(0.1)
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
test('a pose is the eye at its radius and angles about its centre, level with the horizon', () => {
|
|
332
|
+
const spring = new OrbitSpring({ x: 2, y: 1, z: 3, r: 10, az: 0, el: Math.PI / 6 })
|
|
333
|
+
const pose = spring.pose()
|
|
334
|
+
expect(pose.target).toEqual([2, 1, 3])
|
|
335
|
+
// az 0 looks along -z from +z; the eye is r away, 30 degrees up.
|
|
336
|
+
expect(pose.position[0]).toBeCloseTo(2)
|
|
337
|
+
expect(pose.position[1]).toBeCloseTo(1 + 10 * Math.sin(Math.PI / 6))
|
|
338
|
+
expect(pose.position[2]).toBeCloseTo(3 + 10 * Math.cos(Math.PI / 6))
|
|
339
|
+
expect(pose.projection).toBe('perspective')
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
test('it can be set where the camera already is, so following starts from the view the person has', () => {
|
|
343
|
+
const spring = new OrbitSpring(goalA)
|
|
344
|
+
spring.fromPose({ position: [10, 6, 10], target: [0, 1, 0], projection: 'perspective' })
|
|
345
|
+
const value = spring.value()
|
|
346
|
+
expect(value.r).toBeCloseTo(Math.hypot(10, 5, 10))
|
|
347
|
+
expect(value.el).toBeCloseTo(Math.asin(5 / Math.hypot(10, 5, 10)))
|
|
348
|
+
expect(Math.sin(value.az)).toBeCloseTo(10 / Math.hypot(10, 10))
|
|
349
|
+
})
|
|
350
|
+
})
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { type CameraPose, criticallyDamped } from '@pascal-app/core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Follow Pascal: the camera follows what the agent is building (the owner, 2026-10-08). It eases to
|
|
5
|
+
* what the current step works on, at the distance its scale needs; stays about 1.5 s on each target at
|
|
6
|
+
* least; frames what lands together as one; keeps the horizon level (it moves as an orbit about the
|
|
7
|
+
* thing it looks at, never as a free flight); and moves like a critically damped spring.
|
|
8
|
+
*
|
|
9
|
+
* Everything here is arithmetic on boxes and angles, so it is tested without a canvas; the component
|
|
10
|
+
* that feeds it the reveal's events and writes its poses to the camera is `FollowPascal`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type Vec3 = [number, number, number]
|
|
14
|
+
export type Box = { min: Vec3; max: Vec3 }
|
|
15
|
+
|
|
16
|
+
/** What kind of thing a step works on decides how far and from how high it is looked at. */
|
|
17
|
+
export type FollowKind = 'floor' | 'detail' | 'roof' | 'furnish' | 'group'
|
|
18
|
+
|
|
19
|
+
export type FollowWant = {
|
|
20
|
+
box: Box
|
|
21
|
+
kind: FollowKind
|
|
22
|
+
levelId?: string
|
|
23
|
+
nodeIds?: readonly string[]
|
|
24
|
+
/** The outward direction of the wall it is on, in the plan (x, z), for what is looked at square on. */
|
|
25
|
+
normal?: [number, number]
|
|
26
|
+
/** The eye must stand above this height (metres): a roof lifted out of the way is looked down through, not from within. */
|
|
27
|
+
eyeAbove?: number
|
|
28
|
+
/**
|
|
29
|
+
* What it works on is seen from outside (an exterior wall bent, a window moved on one, a roof, the
|
|
30
|
+
* ground): the view is from outdoors with no cutaway, whatever hides the target.
|
|
31
|
+
*/
|
|
32
|
+
exterior?: boolean
|
|
33
|
+
/** When it was asked for, in seconds on the follower's clock. */
|
|
34
|
+
at: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The camera stays on a target at least this long. */
|
|
38
|
+
export const FOLLOW_DWELL_S = 1.5
|
|
39
|
+
/** What lands within this of the first is framed with it. */
|
|
40
|
+
export const FOLLOW_GATHER_S = 0.35
|
|
41
|
+
/** How long a flight takes: a spring's response, not a duration. */
|
|
42
|
+
export const FOLLOW_RESPONSE_S = 1.1
|
|
43
|
+
|
|
44
|
+
/** The eye's elevation above the horizon for each kind, in radians. */
|
|
45
|
+
export const FOLLOW_ELEVATION: Record<FollowKind, number> = {
|
|
46
|
+
floor: 0.5,
|
|
47
|
+
detail: 0.27,
|
|
48
|
+
roof: 0.61,
|
|
49
|
+
furnish: 0.92,
|
|
50
|
+
group: 0.45,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const centreOf = ({ min, max }: Box): Vec3 => [
|
|
54
|
+
(min[0] + max[0]) / 2,
|
|
55
|
+
(min[1] + max[1]) / 2,
|
|
56
|
+
(min[2] + max[2]) / 2,
|
|
57
|
+
]
|
|
58
|
+
const sizeOf = ({ min, max }: Box): Vec3 => [max[0] - min[0], max[1] - min[1], max[2] - min[2]]
|
|
59
|
+
|
|
60
|
+
/** What lands together, as one target: the union of their boxes, a common direction if they share one. */
|
|
61
|
+
export function mergeWants(wants: readonly FollowWant[]): FollowWant {
|
|
62
|
+
const first = wants[0]!
|
|
63
|
+
const min: Vec3 = [...first.box.min]
|
|
64
|
+
const max: Vec3 = [...first.box.max]
|
|
65
|
+
for (const { box } of wants) {
|
|
66
|
+
for (let axis = 0; axis < 3; axis += 1) {
|
|
67
|
+
min[axis] = Math.min(min[axis]!, box.min[axis]!)
|
|
68
|
+
max[axis] = Math.max(max[axis]!, box.max[axis]!)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const kind = wants.every((want) => want.kind === first.kind) ? first.kind : 'group'
|
|
72
|
+
let normal: [number, number] | undefined
|
|
73
|
+
if (wants.every((want) => want.normal)) {
|
|
74
|
+
const sum: [number, number] = [0, 0]
|
|
75
|
+
for (const want of wants) {
|
|
76
|
+
sum[0] += want.normal![0]
|
|
77
|
+
sum[1] += want.normal![1]
|
|
78
|
+
}
|
|
79
|
+
const length = Math.hypot(sum[0], sum[1])
|
|
80
|
+
// They share a direction only when they mostly agree: the length of the sum over their count.
|
|
81
|
+
if (length / wants.length > 0.6) normal = [sum[0] / length, sum[1] / length]
|
|
82
|
+
}
|
|
83
|
+
const eyes = wants.flatMap((want) => (want.eyeAbove === undefined ? [] : [want.eyeAbove]))
|
|
84
|
+
const nodeIds = [...new Set(wants.flatMap((want) => want.nodeIds ?? []))]
|
|
85
|
+
return {
|
|
86
|
+
box: { min, max },
|
|
87
|
+
kind,
|
|
88
|
+
...(first.levelId ? { levelId: first.levelId } : {}),
|
|
89
|
+
...(nodeIds.length ? { nodeIds } : {}),
|
|
90
|
+
...(normal ? { normal } : {}),
|
|
91
|
+
...(eyes.length ? { eyeAbove: Math.max(...eyes) } : {}),
|
|
92
|
+
// Seen from outside when any of what lands together is: a mixed step takes the outside view.
|
|
93
|
+
...(wants.some((want) => want.exterior) ? { exterior: true } : {}),
|
|
94
|
+
at: Math.max(...wants.map((want) => want.at)),
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Decides when the camera changes target. Wants are gathered for a moment so things landing together
|
|
100
|
+
* are framed as one, and the camera never leaves a target before its dwell is over.
|
|
101
|
+
*/
|
|
102
|
+
export class Follower {
|
|
103
|
+
private pending: FollowWant[] = []
|
|
104
|
+
private last = Number.NEGATIVE_INFINITY
|
|
105
|
+
|
|
106
|
+
want(want: FollowWant): void {
|
|
107
|
+
this.pending.push(want)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The target to fly to now, if one is due. `blocked` holds it (the person is pointing at something). */
|
|
111
|
+
due(now: number, blocked = false): FollowWant | null {
|
|
112
|
+
const oldest = this.pending[0]
|
|
113
|
+
if (!oldest || blocked) return null
|
|
114
|
+
if (now - this.last < FOLLOW_DWELL_S || now - oldest.at < FOLLOW_GATHER_S) return null
|
|
115
|
+
const nextFloor = this.pending.findIndex((want) => want.levelId !== oldest.levelId)
|
|
116
|
+
const count = nextFloor < 0 ? this.pending.length : nextFloor
|
|
117
|
+
const merged = mergeWants(this.pending.splice(0, count))
|
|
118
|
+
this.last = now
|
|
119
|
+
return merged
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Forgets what waits and starts its dwell over (a new build; the person took the camera back). */
|
|
123
|
+
reset(): void {
|
|
124
|
+
this.pending = []
|
|
125
|
+
this.last = Number.NEGATIVE_INFINITY
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export type OrbitValue = { x: number; y: number; z: number; r: number; az: number; el: number }
|
|
130
|
+
|
|
131
|
+
export type FollowView = {
|
|
132
|
+
/** The camera's vertical field of view, in degrees. */
|
|
133
|
+
fov: number
|
|
134
|
+
/** The canvas' width over its height. */
|
|
135
|
+
aspect: number
|
|
136
|
+
/** Where the camera looks from now, so a flight takes the nearest way round. */
|
|
137
|
+
azimuth: number
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value))
|
|
141
|
+
const TAU = Math.PI * 2
|
|
142
|
+
|
|
143
|
+
/** The angle `to` written as the nearest equivalent of `from`, so it never goes the long way round. */
|
|
144
|
+
export function nearestTurn(to: number, from: number): number {
|
|
145
|
+
let angle = to
|
|
146
|
+
while (angle - from > Math.PI) angle -= TAU
|
|
147
|
+
while (angle - from < -Math.PI) angle += TAU
|
|
148
|
+
return angle
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Where the camera stands to see `want`: the box's centre, its scale decides how far (a window up
|
|
153
|
+
* close, a floor whole, a group between) at the elevation of its kind, from the side nearest the way
|
|
154
|
+
* it already looks, or square to the wall it is on.
|
|
155
|
+
*/
|
|
156
|
+
export function frameOf(want: FollowWant, view: FollowView): OrbitValue {
|
|
157
|
+
const [cx, cy, cz] = centreOf(want.box)
|
|
158
|
+
const [sx, sy, sz] = sizeOf(want.box)
|
|
159
|
+
const detail = want.kind === 'detail'
|
|
160
|
+
const extent = Math.max(sx, sz, sy * 1.4, detail ? 1.2 : 3)
|
|
161
|
+
const halfVertical = (view.fov * Math.PI) / 360
|
|
162
|
+
const halfHorizontal = Math.atan(Math.tan(halfVertical) * Math.max(0.5, view.aspect))
|
|
163
|
+
const fits = (extent * 0.5) / Math.tan(Math.min(halfVertical, halfHorizontal))
|
|
164
|
+
let r = clamp(fits * 1.25 + sy * 0.3, detail ? 2.8 : 6, 90)
|
|
165
|
+
const el = FOLLOW_ELEVATION[want.kind]
|
|
166
|
+
const eyeY = Math.max(cy, 0.9) + r * Math.sin(el)
|
|
167
|
+
if (want.eyeAbove !== undefined && eyeY < want.eyeAbove) {
|
|
168
|
+
// Further back along the same line of sight, until the eye is above what it must clear.
|
|
169
|
+
r = (want.eyeAbove - Math.max(cy, 0.9)) / Math.sin(el)
|
|
170
|
+
}
|
|
171
|
+
let az = view.azimuth
|
|
172
|
+
if (want.normal) {
|
|
173
|
+
const facing = Math.atan2(want.normal[0], want.normal[1])
|
|
174
|
+
// A little off square, to the side it is already seen from.
|
|
175
|
+
const [left, right] = [facing + 0.5, facing - 0.5]
|
|
176
|
+
az =
|
|
177
|
+
Math.abs(nearestTurn(left, view.azimuth) - view.azimuth) <=
|
|
178
|
+
Math.abs(nearestTurn(right, view.azimuth) - view.azimuth)
|
|
179
|
+
? left
|
|
180
|
+
: right
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
x: cx,
|
|
184
|
+
y: want.kind === 'roof' ? cy - 0.6 : Math.max(cy, 0.9),
|
|
185
|
+
z: cz,
|
|
186
|
+
r,
|
|
187
|
+
az: nearestTurn(az, view.azimuth),
|
|
188
|
+
el,
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const KEYS = ['x', 'y', 'z', 'r', 'az', 'el'] as const
|
|
193
|
+
|
|
194
|
+
function orbitPose({ x, y, z, r, az, el }: OrbitValue): CameraPose {
|
|
195
|
+
const flat = Math.cos(el)
|
|
196
|
+
return {
|
|
197
|
+
position: [x + r * flat * Math.sin(az), y + r * Math.sin(el), z + r * flat * Math.cos(az)],
|
|
198
|
+
target: [x, y, z],
|
|
199
|
+
projection: 'perspective',
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Try a higher eye before turning away; never reorient an already clear view. */
|
|
204
|
+
export function clearFrame(
|
|
205
|
+
goal: OrbitValue,
|
|
206
|
+
sees: (pose: CameraPose) => boolean,
|
|
207
|
+
): OrbitValue | null {
|
|
208
|
+
for (const offset of [
|
|
209
|
+
0,
|
|
210
|
+
Math.PI / 6,
|
|
211
|
+
-Math.PI / 6,
|
|
212
|
+
Math.PI / 3,
|
|
213
|
+
-Math.PI / 3,
|
|
214
|
+
Math.PI / 2,
|
|
215
|
+
-Math.PI / 2,
|
|
216
|
+
]) {
|
|
217
|
+
for (const elevation of [goal.el, 0.9, 1.2, (75 * Math.PI) / 180]) {
|
|
218
|
+
const candidate = { ...goal, az: goal.az + offset, el: Math.max(goal.el, elevation) }
|
|
219
|
+
if (sees(orbitPose(candidate))) return candidate
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return null
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** The camera as six springs on an orbit: the point it looks at, how far, and which way round. */
|
|
226
|
+
export class OrbitSpring {
|
|
227
|
+
private states = Object.fromEntries(KEYS.map((key) => [key, { x: 0, v: 0 }])) as Record<
|
|
228
|
+
(typeof KEYS)[number],
|
|
229
|
+
{ x: number; v: number }
|
|
230
|
+
>
|
|
231
|
+
private goal: OrbitValue
|
|
232
|
+
|
|
233
|
+
constructor(
|
|
234
|
+
start: OrbitValue,
|
|
235
|
+
private response = FOLLOW_RESPONSE_S,
|
|
236
|
+
) {
|
|
237
|
+
this.goal = { ...start }
|
|
238
|
+
this.snap(start)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
snap(value: OrbitValue): void {
|
|
242
|
+
this.goal = { ...value }
|
|
243
|
+
for (const key of KEYS) this.states[key] = { x: value[key], v: 0 }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Flies to `value`, from where it is, carrying its speed. */
|
|
247
|
+
to(value: OrbitValue): void {
|
|
248
|
+
this.goal = { ...value, az: nearestTurn(value.az, this.states.az.x) }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
step(dt: number): void {
|
|
252
|
+
for (const key of KEYS) criticallyDamped(this.states[key], this.goal[key], this.response, dt)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
value(): OrbitValue {
|
|
256
|
+
return Object.fromEntries(KEYS.map((key) => [key, this.states[key].x])) as OrbitValue
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** The orbit read off a camera that is already somewhere. */
|
|
260
|
+
fromPose(pose: CameraPose): void {
|
|
261
|
+
const dx = pose.position[0] - pose.target[0]
|
|
262
|
+
const dy = pose.position[1] - pose.target[1]
|
|
263
|
+
const dz = pose.position[2] - pose.target[2]
|
|
264
|
+
const r = Math.hypot(dx, dy, dz) || 1
|
|
265
|
+
this.snap({
|
|
266
|
+
x: pose.target[0],
|
|
267
|
+
y: pose.target[1],
|
|
268
|
+
z: pose.target[2],
|
|
269
|
+
r,
|
|
270
|
+
az: Math.atan2(dx, dz),
|
|
271
|
+
el: Math.asin(clamp(dy / r, -1, 1)),
|
|
272
|
+
})
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Where the camera is now: at the radius and angles about its centre, always looking at it, level. */
|
|
276
|
+
pose(): CameraPose {
|
|
277
|
+
return orbitPose(this.value())
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** The goal it is flying to. */
|
|
281
|
+
destination(): OrbitValue {
|
|
282
|
+
return { ...this.goal }
|
|
283
|
+
}
|
|
284
|
+
}
|