@pascal-app/editor 1.0.4 → 1.0.6
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 +40 -38
- package/src/components/editor/__bench__/room-label-placement.bench.ts +108 -0
- package/src/components/editor/bake-thumbnail.tsx +6 -15
- 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 +81 -8
- package/src/components/editor/editor-layout-mobile.tsx +1 -1
- package/src/components/editor/editor-layout-v2.tsx +1 -1
- package/src/components/editor/export-manager.tsx +14 -19
- package/src/components/editor/first-person/build-collider-world.test.ts +269 -0
- package/src/components/editor/first-person/build-collider-world.ts +22 -5
- 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 +89 -2
- package/src/components/editor/first-person/mechanism-targets.ts +52 -0
- package/src/components/editor/first-person-controls.tsx +19 -11
- package/src/components/editor/floor-aware-camera-controls.test.ts +24 -0
- package/src/components/editor/floorplan-mode-coordinator.tsx +2 -2
- package/src/components/editor/floorplan-panel.tsx +72 -23
- package/src/components/editor/follow-pascal.test.tsx +912 -0
- package/src/components/editor/follow-pascal.tsx +354 -0
- package/src/components/editor/group-actions.ts +2 -0
- 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/index.tsx +166 -79
- package/src/components/editor/riser-diagram-panel.tsx +14 -7
- package/src/components/editor/room-highlight.tsx +13 -5
- package/src/components/editor/room-labels.test.ts +621 -128
- package/src/components/editor/room-labels.tsx +1090 -190
- package/src/components/editor/selection-manager.tsx +38 -117
- package/src/components/editor/slab-hole-highlights.tsx +2 -2
- package/src/components/editor/snapshot-capture-overlay.tsx +3 -2
- package/src/components/editor/snapshot-capture.test.ts +69 -58
- package/src/components/editor/snapshot-capture.ts +7 -1
- package/src/components/editor/thumbnail-generator.test.ts +350 -4
- package/src/components/editor/thumbnail-generator.tsx +128 -54
- package/src/components/editor/use-floorplan-background-placement.ts +3 -1
- package/src/components/editor/views/plugins-view.tsx +1 -8
- package/src/components/editor/views/view-bar.test.tsx +54 -0
- package/src/components/editor/views/view-bar.tsx +140 -17
- package/src/components/editor/wall-snap-beacon-layer.tsx +5 -4
- package/src/components/editor-2d/floorplan-group-move.tsx +3 -0
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +2 -2
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +3 -4
- package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +1 -1
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +29 -7
- 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/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/systems/ceiling/ceiling-bracket-batch.test.ts +12 -2
- package/src/components/systems/roof/roof-edit-system.tsx +1 -0
- 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 +38 -0
- package/src/components/tools/item/placement-strategies.test.ts +46 -1
- package/src/components/tools/item/placement-strategies.ts +3 -6
- package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +56 -56
- package/src/components/tools/item/use-draft-node.test.tsx +94 -94
- package/src/components/tools/item/use-placement-coordinator.tsx +55 -49
- 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 +63 -29
- package/src/components/tools/tool-manager.tsx +14 -5
- package/src/components/tools/wall/wall-drafting.test.ts +170 -57
- package/src/components/ui/action-menu/action-button.tsx +6 -1
- package/src/components/ui/action-menu/camera-actions.tsx +7 -3
- package/src/components/ui/action-menu/control-modes.tsx +13 -7
- 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 +11 -9
- 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 +10 -3
- package/src/components/ui/command-palette/editor-commands.tsx +3 -12
- package/src/components/ui/command-palette/index.tsx +45 -15
- 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/metric-control.tsx +1 -1
- package/src/components/ui/controls/segmented-control.tsx +21 -8
- package/src/components/ui/controls/select-control.tsx +1 -1
- 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 +150 -45
- package/src/components/ui/helpers/contextual-helper-panel.tsx +32 -7
- package/src/components/ui/helpers/helper-manager.test.tsx +54 -2
- package/src/components/ui/helpers/helper-manager.tsx +7 -1
- package/src/components/ui/helpers/item-helper.tsx +9 -1
- package/src/components/ui/panels/collections/collections-popover.tsx +8 -8
- package/src/components/ui/panels/mobile-panel-sheet.tsx +2 -2
- package/src/components/ui/panels/mobile-selection-bar.tsx +6 -3
- package/src/components/ui/panels/multi-selection-panel.tsx +120 -3
- package/src/components/ui/panels/panel-wrapper.tsx +53 -27
- package/src/components/ui/panels/parametric-field-control.tsx +2 -2
- package/src/components/ui/panels/parametric-inspector.tsx +3 -3
- package/src/components/ui/panels/room-panel.tsx +65 -0
- package/src/components/ui/primitives/badge.tsx +8 -0
- package/src/components/ui/primitives/sidebar.tsx +1 -1
- package/src/components/ui/right-stack.tsx +21 -2
- package/src/components/ui/sidebar/app-sidebar.tsx +1 -1
- package/src/components/ui/sidebar/icon-rail.tsx +3 -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 +107 -23
- package/src/components/ui/sidebar/panels/settings-panel/interface-theme.test.tsx +20 -0
- 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/index.tsx +5 -12
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +13 -10
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +2 -2
- package/src/components/ui/sidebar/tab-bar.tsx +4 -4
- package/src/components/viewer/viewer-controls-bar.tsx +18 -6
- package/src/components/viewer/viewer-rooms.tsx +16 -0
- package/src/hooks/use-auto-save.integration.test.tsx +96 -38
- package/src/hooks/use-auto-save.ts +26 -1
- 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 +65 -16
- package/src/hooks/use-keyboard.ts +29 -12
- package/src/hooks/use-placement-notice.test.ts +24 -1
- package/src/hooks/use-placement-notice.ts +37 -6
- package/src/index.tsx +71 -24
- 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/direct-manipulation.ts +2 -0
- package/src/lib/download-project-file.ts +17 -0
- package/src/lib/editor-level-display.ts +9 -2
- package/src/lib/editor-views.ts +35 -1
- package/src/lib/export-texture-utils.ts +2 -4
- package/src/lib/floor-foundation-support.test.ts +1 -0
- package/src/lib/floorplan/floorplan-pdfkit-document.ts +31 -16
- package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +39 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +4 -0
- package/src/lib/floorplan/pdfkit-standard-fonts.d.ts +5 -0
- package/src/lib/floorplan/sheet-export.test.ts +24 -0
- package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +34 -45
- package/src/lib/floorplan/site-plan/coverage.ts +2 -4
- package/src/lib/floorplan/site-plan/front-edge.ts +2 -1
- package/src/lib/floorplan/site-plan/geometry.test.ts +9 -15
- package/src/lib/floorplan/site-plan/geometry.ts +1 -416
- package/src/lib/floorplan/site-plan/index.ts +0 -16
- package/src/lib/floorplan/site-plan/site-annotations.test.ts +117 -0
- package/src/lib/floorplan/site-plan/site-annotations.ts +3 -2
- package/src/lib/floorplan/site-plan/site-parts.ts +14 -106
- package/src/lib/floorplan/site-plan/yards.test.ts +2 -1
- 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 +112 -0
- package/src/lib/fresh-planar-placement.ts +57 -3
- package/src/lib/geometry-script/author.ts +6 -1
- package/src/lib/geometry-script/client.ts +8 -1
- package/src/lib/geometry-script/finish-scripts.ts +127 -0
- package/src/lib/geometry-script/protocol.ts +1 -0
- package/src/lib/glb-export.test.ts +187 -162
- package/src/lib/glb-export.ts +4 -1
- package/src/lib/history.test.ts +12 -7
- package/src/lib/interaction/scope.ts +4 -0
- package/src/lib/level-selection.test.ts +57 -0
- package/src/lib/level-selection.ts +16 -1
- package/src/lib/lot/clean-ring.test.ts +2 -1
- package/src/lib/lot/drop-in.ts +9 -24
- package/src/lib/lot/lot-patch.test.ts +1 -1
- package/src/lib/lot/lot-patch.ts +2 -2
- package/src/lib/measurement-kind.test.ts +6 -8
- package/src/lib/measurements.test.ts +23 -0
- package/src/lib/orbit-floor-clearance.test.ts +36 -0
- package/src/lib/orbit-floor-clearance.ts +48 -0
- package/src/lib/paint-pass-through.test.ts +11 -0
- package/src/lib/paint-pass-through.ts +4 -2
- package/src/lib/paint-scope.ts +1 -0
- package/src/lib/parametric-node-update.ts +16 -7
- package/src/lib/plugin-chat.ts +15 -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/room-assembly-overlay.ts +2 -3
- package/src/lib/room-selection.test.ts +45 -1
- package/src/lib/scene-clipboard.test.ts +246 -1
- package/src/lib/scene-clipboard.ts +44 -14
- package/src/lib/selection-routing.ts +1 -1
- 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/stair-levels.test.ts +25 -0
- package/src/lib/stair-levels.ts +22 -3
- package/src/lib/terrace-draft.ts +2 -1
- package/src/lib/viewer-selection.ts +22 -11
- package/src/lib/wall-placement-pointer.test.ts +42 -0
- package/src/lib/wall-placement-pointer.ts +57 -0
- package/src/lib/window-changes.ts +35 -0
- 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-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-stair-build-preview.ts +25 -5
- package/src/store/view-layout.test.ts +27 -1
- package/src/store/view-layout.ts +4 -1
- package/src/test-utils/render-current-markup.tsx +16 -0
- 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 -15
- package/src/components/tools/stair/stair-tool.tsx +0 -682
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { type AnyNode, type AnyNodeId, findLevelAncestorId, nodeRegistry } from '@pascal-app/core'
|
|
2
|
+
import { sceneViewBounds, sceneViewFacing } from '@pascal-app/core/agent-operations'
|
|
3
|
+
import type { RevealEvent } from '@pascal-app/viewer'
|
|
4
|
+
import type { Box, FollowWant } from './follow-pascal'
|
|
5
|
+
import { anyExterior } from './follow-pascal-exterior'
|
|
6
|
+
|
|
7
|
+
type Nodes = Readonly<Record<string, AnyNode>>
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The box `view_scene` would frame for `id`, in the site (a building away from the origin is framed
|
|
11
|
+
* where it stands): the same bounds the agent's own views take, so the person sees what the agent
|
|
12
|
+
* would look at. Null for a node there is nothing to frame of.
|
|
13
|
+
*/
|
|
14
|
+
function boxOf(nodes: Nodes, id: string): Box | null {
|
|
15
|
+
try {
|
|
16
|
+
const { min, max } = sceneViewBounds(nodes, id)
|
|
17
|
+
return { min, max }
|
|
18
|
+
} catch {
|
|
19
|
+
return null
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function unionOf(boxes: readonly (Box | null)[]): Box | null {
|
|
24
|
+
const known = boxes.filter((box): box is Box => box !== null)
|
|
25
|
+
const first = known[0]
|
|
26
|
+
if (!first) return null
|
|
27
|
+
const min: Box['min'] = [...first.min]
|
|
28
|
+
const max: Box['max'] = [...first.max]
|
|
29
|
+
for (const box of known) {
|
|
30
|
+
for (let axis = 0; axis < 3; axis += 1) {
|
|
31
|
+
min[axis] = Math.min(min[axis]!, box.min[axis]!)
|
|
32
|
+
max[axis] = Math.max(max[axis]!, box.max[axis]!)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return { min, max }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function buildingOf(nodes: Nodes, id: string): string | null {
|
|
39
|
+
let node = nodes[id]
|
|
40
|
+
for (let guard = 0; node && guard < 32; guard += 1) {
|
|
41
|
+
if (node.type === 'building') return node.id
|
|
42
|
+
node = node.parentId ? nodes[node.parentId] : undefined
|
|
43
|
+
}
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A roof that lifts for the furniture is looked down through: the eye clears its lifted top by this. */
|
|
48
|
+
const LIFTED_ROOF_MARGIN = 1.5
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* How high the eye must stand to look at something in a building: above every roof of it that lifts
|
|
52
|
+
* out of the way of the furnishing, where it is lifted to. Undefined when nothing lifts.
|
|
53
|
+
*/
|
|
54
|
+
function clearanceAbove(nodes: Nodes, id: string): number | undefined {
|
|
55
|
+
const building = buildingOf(nodes, id)
|
|
56
|
+
if (!building) return undefined
|
|
57
|
+
let highest: number | undefined
|
|
58
|
+
for (const node of Object.values(nodes)) {
|
|
59
|
+
if (node.type !== 'roof' || buildingOf(nodes, node.id) !== building) continue
|
|
60
|
+
const clears = nodeRegistry.get(node.type)?.capabilities?.reveal?.clears
|
|
61
|
+
const box = boxOf(nodes, node.id)
|
|
62
|
+
if (!(clears && box)) continue
|
|
63
|
+
highest = Math.max(highest ?? Number.NEGATIVE_INFINITY, box.max[1] + clears.height)
|
|
64
|
+
}
|
|
65
|
+
return highest === undefined ? undefined : highest + LIFTED_ROOF_MARGIN
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The building `id` is in, roof and all, seen as a group; the eye clears a roof that is lifted. */
|
|
69
|
+
function wholeHouse(nodes: Nodes, id: string, at: number): FollowWant | null {
|
|
70
|
+
const building = buildingOf(nodes, id)
|
|
71
|
+
if (!building) return null
|
|
72
|
+
const roofs = Object.values(nodes).filter(
|
|
73
|
+
(node) => node.type === 'roof' && buildingOf(nodes, node.id) === building,
|
|
74
|
+
)
|
|
75
|
+
const box = unionOf([boxOf(nodes, building), ...roofs.map((roof) => boxOf(nodes, roof.id))])
|
|
76
|
+
if (!box) return null
|
|
77
|
+
const eyeAbove = clearanceAbove(nodes, id)
|
|
78
|
+
return { kind: 'group', box, ...(eyeAbove === undefined ? {} : { eyeAbove }), at }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A look at the whole house (the furniture has landed; a build has finished), made a look at the
|
|
83
|
+
* level the person is already on: the level whole, as when its floor starts, with no floors above
|
|
84
|
+
* to unhide and no lifted roof to clear.
|
|
85
|
+
*/
|
|
86
|
+
export function stayOnLevel(want: FollowWant, nodes: Nodes, levelId: string): FollowWant {
|
|
87
|
+
const box = boxOf(nodes, levelId)
|
|
88
|
+
return box ? { kind: 'floor', box, levelId, at: want.at } : { ...want, levelId }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* What the camera should look at for something the reveal said, or null when it says nothing about
|
|
93
|
+
* where to look. A floor, whole, when its slabs and walls start (the walls are not targets one by
|
|
94
|
+
* one: the floor already is); each opening close up from outside; each piece of furniture from above;
|
|
95
|
+
* the roof from the group that assembles it; the whole house, roof and all, when it is finished. An
|
|
96
|
+
* undo's reverse play is followed the same way, group by group, on what goes.
|
|
97
|
+
*/
|
|
98
|
+
export function wantFromEvent(event: RevealEvent, nodes: Nodes, at: number): FollowWant | null {
|
|
99
|
+
let want = targetFromEvent(event, nodes, at)
|
|
100
|
+
if (want && (want.kind === 'detail' || want.kind === 'furnish' || want.kind === 'roof')) {
|
|
101
|
+
const nodeIds = 'id' in event ? [event.id] : 'nodeIds' in event ? event.nodeIds : []
|
|
102
|
+
want = { ...want, nodeIds }
|
|
103
|
+
}
|
|
104
|
+
if (want && exteriorStep(event, nodes)) want = { ...want, exterior: true }
|
|
105
|
+
if (!want || event.type === 'complete' || event.type === 'finale' || event.type === 'settle')
|
|
106
|
+
return want
|
|
107
|
+
let level = event.levelId ? nodes[event.levelId] : undefined
|
|
108
|
+
if (level?.type !== 'level') {
|
|
109
|
+
const id = 'id' in event ? event.id : event.nodeIds[0]
|
|
110
|
+
const levelId = id ? findLevelAncestorId(id as AnyNodeId, nodes) : null
|
|
111
|
+
level = levelId ? nodes[levelId] : undefined
|
|
112
|
+
}
|
|
113
|
+
return level?.type === 'level' ? { ...want, levelId: level.id } : want
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Whether the step is a change seen from outside, not through a cutaway into a room: a piece the
|
|
118
|
+
* agent edited or moved that stands outdoors (a wall with an exterior face, an opening in one, a
|
|
119
|
+
* roof, the ground). Only edits and moves: a build is followed as it always was, whatever it builds
|
|
120
|
+
* (the owner's call, 15 Oct), and furnishing and interior walls are not outside either.
|
|
121
|
+
*/
|
|
122
|
+
function exteriorStep(event: RevealEvent, nodes: Nodes): boolean {
|
|
123
|
+
if (!('changed' in event) || event.changed !== true) return false
|
|
124
|
+
const ids = 'id' in event ? [event.id] : 'nodeIds' in event ? [...event.nodeIds] : []
|
|
125
|
+
return anyExterior(nodes, ids)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function targetFromEvent(event: RevealEvent, nodes: Nodes, at: number): FollowWant | null {
|
|
129
|
+
switch (event.type) {
|
|
130
|
+
case 'start': {
|
|
131
|
+
// Hidden floors settle instantly and say only the group, not each node's start.
|
|
132
|
+
if (event.instant && (event.phase === 'openings' || event.phase === 'furnishing')) {
|
|
133
|
+
const box = unionOf(event.nodeIds.map((id) => boxOf(nodes, id)))
|
|
134
|
+
return box ? { kind: event.phase === 'openings' ? 'detail' : 'furnish', box, at } : null
|
|
135
|
+
}
|
|
136
|
+
if (event.phase === 'roof') {
|
|
137
|
+
const box = unionOf(event.nodeIds.map((id) => boxOf(nodes, id)))
|
|
138
|
+
return box ? { kind: 'roof', box, at } : null
|
|
139
|
+
}
|
|
140
|
+
if (
|
|
141
|
+
event.phase !== 'foundation' &&
|
|
142
|
+
event.phase !== 'structure' &&
|
|
143
|
+
event.phase !== 'circulation'
|
|
144
|
+
)
|
|
145
|
+
return null
|
|
146
|
+
const box =
|
|
147
|
+
(event.levelId ? boxOf(nodes, event.levelId) : null) ??
|
|
148
|
+
unionOf(event.nodeIds.map((id) => boxOf(nodes, id)))
|
|
149
|
+
return box ? { kind: 'floor', box, at } : null
|
|
150
|
+
}
|
|
151
|
+
case 'node-start': {
|
|
152
|
+
if (event.phase === 'openings') {
|
|
153
|
+
const box = boxOf(nodes, event.id)
|
|
154
|
+
if (!box) return null
|
|
155
|
+
const normal = sceneViewFacing(nodes, event.id)
|
|
156
|
+
return { kind: 'detail', box, ...(normal ? { normal } : {}), at }
|
|
157
|
+
}
|
|
158
|
+
if (event.phase === 'furnishing') {
|
|
159
|
+
const box = boxOf(nodes, event.id)
|
|
160
|
+
if (!box) return null
|
|
161
|
+
const eyeAbove = clearanceAbove(nodes, event.id)
|
|
162
|
+
return { kind: 'furnish', box, ...(eyeAbove === undefined ? {} : { eyeAbove }), at }
|
|
163
|
+
}
|
|
164
|
+
// A piece the agent edited (a wall bent, a slab resized) plays again: the camera goes to it,
|
|
165
|
+
// from the side that faces outdoors when it has one.
|
|
166
|
+
if (event.changed) {
|
|
167
|
+
const box = boxOf(nodes, event.id)
|
|
168
|
+
if (!box) return null
|
|
169
|
+
const normal = sceneViewFacing(nodes, event.id)
|
|
170
|
+
return { kind: 'detail', box, ...(normal ? { normal } : {}), at }
|
|
171
|
+
}
|
|
172
|
+
return null
|
|
173
|
+
}
|
|
174
|
+
case 'settle': {
|
|
175
|
+
// A piece the agent moved: nothing is coming down on the camera, it stays on the piece.
|
|
176
|
+
if (event.changed) return null
|
|
177
|
+
// The furniture has landed: pull back to the whole house while the roof is still up, so the
|
|
178
|
+
// roof does not come down on a camera that is close above it.
|
|
179
|
+
const id = event.phase === 'furnishing' ? event.nodeIds[0] : undefined
|
|
180
|
+
return id ? wholeHouse(nodes, id, at) : null
|
|
181
|
+
}
|
|
182
|
+
case 'finale':
|
|
183
|
+
return wholeHouse(nodes, event.id, at)
|
|
184
|
+
case 'retract-start': {
|
|
185
|
+
// An undo plays the build backwards: the camera is on what goes, a phase at a time.
|
|
186
|
+
const box = unionOf(event.nodeIds.map((id) => boxOf(nodes, id)))
|
|
187
|
+
if (!box) return null
|
|
188
|
+
if (event.phase === 'roof') return { kind: 'roof', box, at }
|
|
189
|
+
if (event.phase === 'furnishing') {
|
|
190
|
+
const eyeAbove = clearanceAbove(nodes, event.nodeIds[0] ?? '')
|
|
191
|
+
return { kind: 'furnish', box, ...(eyeAbove === undefined ? {} : { eyeAbove }), at }
|
|
192
|
+
}
|
|
193
|
+
return { kind: event.phase === 'openings' ? 'group' : 'floor', box, at }
|
|
194
|
+
}
|
|
195
|
+
default:
|
|
196
|
+
return null
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -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
|
+
})
|