@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,128 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
BoxGeometry,
|
|
4
|
+
BufferAttribute,
|
|
5
|
+
BufferGeometry,
|
|
6
|
+
Mesh,
|
|
7
|
+
MeshBasicMaterial,
|
|
8
|
+
Raycaster,
|
|
9
|
+
Vector3,
|
|
10
|
+
} from 'three'
|
|
11
|
+
import { cutGroundCollider } from './ground-holes'
|
|
12
|
+
|
|
13
|
+
/** A 10 × 10 m square on the plane y = 0.1 x (two triangles, facing up), plan 0..10 × 0..10. */
|
|
14
|
+
function slopedSquare(flip = false) {
|
|
15
|
+
const corner = (x: number, z: number): [number, number, number] => [x, 0.1 * x, z]
|
|
16
|
+
const up = [
|
|
17
|
+
corner(0, 0),
|
|
18
|
+
corner(0, 10),
|
|
19
|
+
corner(10, 0),
|
|
20
|
+
corner(10, 0),
|
|
21
|
+
corner(0, 10),
|
|
22
|
+
corner(10, 10),
|
|
23
|
+
]
|
|
24
|
+
const vertices = flip ? [...up].reverse() : up
|
|
25
|
+
const geometry = new BufferGeometry()
|
|
26
|
+
geometry.setAttribute('position', new BufferAttribute(new Float32Array(vertices.flat()), 3))
|
|
27
|
+
const slope = new Vector3(-0.1, 1, 0).normalize()
|
|
28
|
+
const normal = flip ? slope.clone().negate() : slope
|
|
29
|
+
geometry.setAttribute(
|
|
30
|
+
'normal',
|
|
31
|
+
new BufferAttribute(
|
|
32
|
+
new Float32Array(vertices.flatMap(() => [normal.x, normal.y, normal.z])),
|
|
33
|
+
3,
|
|
34
|
+
),
|
|
35
|
+
)
|
|
36
|
+
return geometry
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const HOLE: Array<[number, number]> = [
|
|
40
|
+
[3, 3],
|
|
41
|
+
[7, 3],
|
|
42
|
+
[7, 6],
|
|
43
|
+
[3, 6],
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
/** The up (1) or down (-1) facing of every triangle of a non-indexed geometry. */
|
|
47
|
+
function triangles(geometry: BufferGeometry) {
|
|
48
|
+
const position = geometry.getAttribute('position')
|
|
49
|
+
const out: Array<{ facing: number }> = []
|
|
50
|
+
for (let i = 0; i < position.count; i += 3) {
|
|
51
|
+
const [a, b, c] = [0, 1, 2].map(
|
|
52
|
+
(k) => new Vector3(position.getX(i + k), position.getY(i + k), position.getZ(i + k)),
|
|
53
|
+
) as [Vector3, Vector3, Vector3]
|
|
54
|
+
const normal = b.clone().sub(a).cross(c.clone().sub(a))
|
|
55
|
+
out.push({ facing: Math.sign(normal.y) })
|
|
56
|
+
}
|
|
57
|
+
return out
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function hitUnder(geometry: BufferGeometry, x: number, z: number): number | null {
|
|
61
|
+
const mesh = new Mesh(geometry, new MeshBasicMaterial())
|
|
62
|
+
mesh.updateMatrixWorld(true)
|
|
63
|
+
const raycaster = new Raycaster()
|
|
64
|
+
raycaster.set(new Vector3(x, 50, z), new Vector3(0, -1, 0))
|
|
65
|
+
return raycaster.intersectObject(mesh, false)[0]?.point.y ?? null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe('cutGroundCollider', () => {
|
|
69
|
+
test('without holes the geometry is returned as it is', () => {
|
|
70
|
+
const geometry = slopedSquare()
|
|
71
|
+
expect(cutGroundCollider(geometry, [])).toBe(geometry)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('the ground is gone over the hole and is the same surface everywhere else', () => {
|
|
75
|
+
const cut = cutGroundCollider(slopedSquare(), [HOLE])
|
|
76
|
+
expect(hitUnder(cut, 5, 4)).toBeNull()
|
|
77
|
+
expect(hitUnder(cut, 3.2, 3.2)).toBeNull()
|
|
78
|
+
expect(hitUnder(cut, 6.8, 5.8)).toBeNull()
|
|
79
|
+
// Past the rim, on the slope the uncut triangle had: y = 0.1 x.
|
|
80
|
+
for (const [x, z] of [
|
|
81
|
+
[2.8, 4],
|
|
82
|
+
[7.2, 4],
|
|
83
|
+
[5, 2.8],
|
|
84
|
+
[5, 6.2],
|
|
85
|
+
[1, 1],
|
|
86
|
+
[9, 9],
|
|
87
|
+
] as const)
|
|
88
|
+
expect(hitUnder(cut, x, z)).toBeCloseTo(0.1 * x, 5)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('what is left has the area of the ground less the hole, and faces as the ground did', () => {
|
|
92
|
+
for (const flip of [false, true]) {
|
|
93
|
+
const cut = cutGroundCollider(slopedSquare(flip), [HOLE])
|
|
94
|
+
const pieces = triangles(cut)
|
|
95
|
+
// The slope stretches the surface, so compare in plan: 100 - 12 m².
|
|
96
|
+
const position = cut.getAttribute('position')
|
|
97
|
+
let plan = 0
|
|
98
|
+
for (let i = 0; i < position.count; i += 3) {
|
|
99
|
+
const [a, b, c] = [0, 1, 2].map((k) => [position.getX(i + k), position.getZ(i + k)]) as [
|
|
100
|
+
[number, number],
|
|
101
|
+
[number, number],
|
|
102
|
+
[number, number],
|
|
103
|
+
]
|
|
104
|
+
plan += Math.abs((b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])) / 2
|
|
105
|
+
}
|
|
106
|
+
expect(plan).toBeCloseTo(100 - 12, 3)
|
|
107
|
+
for (const piece of pieces) expect(piece.facing).toBe(flip ? -1 : 1)
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('the normals follow the ground and stay unit length', () => {
|
|
112
|
+
const cut = cutGroundCollider(slopedSquare(), [HOLE])
|
|
113
|
+
const normal = cut.getAttribute('normal')
|
|
114
|
+
for (let i = 0; i < normal.count; i += 1) {
|
|
115
|
+
expect(Math.hypot(normal.getX(i), normal.getY(i), normal.getZ(i))).toBeCloseTo(1, 5)
|
|
116
|
+
expect(normal.getX(i)).toBeCloseTo(-0.1 / Math.hypot(0.1, 1), 5)
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test('a triangle wholly inside the hole is dropped, vertical faces are kept', () => {
|
|
121
|
+
const box = new BoxGeometry(2, 2, 2).toNonIndexed()
|
|
122
|
+
box.translate(5, 0, 4.5)
|
|
123
|
+
const before = box.getAttribute('position').count
|
|
124
|
+
const cut = cutGroundCollider(box, [HOLE])
|
|
125
|
+
// Top and bottom faces lie wholly inside the hole and go; the four sides (no plan area) stay.
|
|
126
|
+
expect(cut.getAttribute('position').count).toBe(before - 12)
|
|
127
|
+
})
|
|
128
|
+
})
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Holes in the site ground's collision geometry.
|
|
3
|
+
*
|
|
4
|
+
* The walkthrough stands on one merged BVH mesh, and the site's ground (a flat slab, or the terrain)
|
|
5
|
+
* is a sheet across the whole lot. Where the house stands over a basement the ground is drawn open
|
|
6
|
+
* (`coveredBelowGradeFootprints`), but the sheet under the stairwell stayed: the player descending
|
|
7
|
+
* the stair met it at grade and could go no deeper. Cutting the collider by the same rings the
|
|
8
|
+
* ground is drawn open over lets the player follow the building's floors and stairs there.
|
|
9
|
+
*
|
|
10
|
+
* Every triangle that reaches a hole is cut against it in plan; the pieces keep the source
|
|
11
|
+
* triangle's plane (height and normal interpolated from its corners) and its facing, so the cut
|
|
12
|
+
* surface is exactly the one the uncut triangle was.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { difference, type Ring, union } from '@pascal-app/core'
|
|
16
|
+
import { BufferAttribute, BufferGeometry, ShapeUtils, Vector2 } from 'three'
|
|
17
|
+
|
|
18
|
+
/** Triangles flatter than this in plan (m²) are vertical faces: they hold nothing up. */
|
|
19
|
+
const MIN_PLAN_AREA = 1e-9
|
|
20
|
+
|
|
21
|
+
type Corner = { x: number; y: number; z: number; nx: number; ny: number; nz: number }
|
|
22
|
+
|
|
23
|
+
/** Twice the signed plan area of `a b c` (x right, z down). */
|
|
24
|
+
function orient(a: [number, number], b: [number, number], c: [number, number]): number {
|
|
25
|
+
return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* `geometry` (non-indexed, `position` and `normal`, in the frame the rings are in) without the part
|
|
30
|
+
* of it that lies over `holes`. A triangle the clip fails on is kept whole: the ground closed is
|
|
31
|
+
* the safe side. The geometry is returned as it is when there are no holes.
|
|
32
|
+
*/
|
|
33
|
+
export function cutGroundCollider(
|
|
34
|
+
geometry: BufferGeometry,
|
|
35
|
+
holes: readonly Ring[],
|
|
36
|
+
): BufferGeometry {
|
|
37
|
+
if (holes.length === 0) return geometry
|
|
38
|
+
const cutouts = union(holes)
|
|
39
|
+
if (cutouts.length === 0) return geometry
|
|
40
|
+
const boxes = cutouts.map(({ outer }) => ({
|
|
41
|
+
minX: Math.min(...outer.map(([x]) => x)),
|
|
42
|
+
maxX: Math.max(...outer.map(([x]) => x)),
|
|
43
|
+
minZ: Math.min(...outer.map(([, z]) => z)),
|
|
44
|
+
maxZ: Math.max(...outer.map(([, z]) => z)),
|
|
45
|
+
}))
|
|
46
|
+
|
|
47
|
+
const position = geometry.getAttribute('position')
|
|
48
|
+
const normal = geometry.getAttribute('normal')
|
|
49
|
+
const positions: number[] = []
|
|
50
|
+
const normals: number[] = []
|
|
51
|
+
const push = (corner: Corner) => {
|
|
52
|
+
positions.push(corner.x, corner.y, corner.z)
|
|
53
|
+
normals.push(corner.nx, corner.ny, corner.nz)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
for (let i = 0; i + 2 < position.count; i += 3) {
|
|
57
|
+
const corners: Corner[] = [0, 1, 2].map((k) => ({
|
|
58
|
+
x: position.getX(i + k),
|
|
59
|
+
y: position.getY(i + k),
|
|
60
|
+
z: position.getZ(i + k),
|
|
61
|
+
nx: normal.getX(i + k),
|
|
62
|
+
ny: normal.getY(i + k),
|
|
63
|
+
nz: normal.getZ(i + k),
|
|
64
|
+
}))
|
|
65
|
+
const [a, b, c] = corners as [Corner, Corner, Corner]
|
|
66
|
+
const minX = Math.min(a.x, b.x, c.x)
|
|
67
|
+
const maxX = Math.max(a.x, b.x, c.x)
|
|
68
|
+
const minZ = Math.min(a.z, b.z, c.z)
|
|
69
|
+
const maxZ = Math.max(a.z, b.z, c.z)
|
|
70
|
+
const reached = boxes.some(
|
|
71
|
+
(box) => box.maxX > minX && box.minX < maxX && box.maxZ > minZ && box.minZ < maxZ,
|
|
72
|
+
)
|
|
73
|
+
const plan: [[number, number], [number, number], [number, number]] = [
|
|
74
|
+
[a.x, a.z],
|
|
75
|
+
[b.x, b.z],
|
|
76
|
+
[c.x, c.z],
|
|
77
|
+
]
|
|
78
|
+
const determinant = orient(plan[0], plan[1], plan[2])
|
|
79
|
+
if (!reached || Math.abs(determinant) < MIN_PLAN_AREA) {
|
|
80
|
+
corners.forEach(push)
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let pieces: ReturnType<typeof difference>
|
|
85
|
+
try {
|
|
86
|
+
pieces = difference(plan, cutouts, { throwOnError: true })
|
|
87
|
+
} catch {
|
|
88
|
+
corners.forEach(push)
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const at = (x: number, z: number): Corner => {
|
|
93
|
+
const wa = ((b.z - c.z) * (x - c.x) + (c.x - b.x) * (z - c.z)) / determinant
|
|
94
|
+
const wb = ((c.z - a.z) * (x - c.x) + (a.x - c.x) * (z - c.z)) / determinant
|
|
95
|
+
const wc = 1 - wa - wb
|
|
96
|
+
const nx = a.nx * wa + b.nx * wb + c.nx * wc
|
|
97
|
+
const ny = a.ny * wa + b.ny * wb + c.ny * wc
|
|
98
|
+
const nz = a.nz * wa + b.nz * wb + c.nz * wc
|
|
99
|
+
const length = Math.hypot(nx, ny, nz) || 1
|
|
100
|
+
return {
|
|
101
|
+
x,
|
|
102
|
+
y: a.y * wa + b.y * wb + c.y * wc,
|
|
103
|
+
z,
|
|
104
|
+
nx: nx / length,
|
|
105
|
+
ny: ny / length,
|
|
106
|
+
nz: nz / length,
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
for (const piece of pieces) {
|
|
111
|
+
const rings = [piece.outer, ...piece.holes]
|
|
112
|
+
const points = rings.flat()
|
|
113
|
+
const contour = piece.outer.map(([x, z]) => new Vector2(x, z))
|
|
114
|
+
const voids = piece.holes.map((ring) => ring.map(([x, z]) => new Vector2(x, z)))
|
|
115
|
+
for (const [p, q, r] of ShapeUtils.triangulateShape(contour, voids)) {
|
|
116
|
+
const corner = [p, q, r].map((index) => points[index as number] as [number, number])
|
|
117
|
+
const swap =
|
|
118
|
+
Math.sign(orient(corner[0]!, corner[1]!, corner[2]!)) !== Math.sign(determinant)
|
|
119
|
+
const ordered = swap ? [corner[0]!, corner[2]!, corner[1]!] : corner
|
|
120
|
+
for (const [x, z] of ordered) push(at(x, z))
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const cut = new BufferGeometry()
|
|
126
|
+
cut.setAttribute('position', new BufferAttribute(new Float32Array(positions), 3))
|
|
127
|
+
cut.setAttribute('normal', new BufferAttribute(new Float32Array(normals), 3))
|
|
128
|
+
geometry.dispose()
|
|
129
|
+
return cut
|
|
130
|
+
}
|
|
@@ -1,14 +1,21 @@
|
|
|
1
|
-
import { afterEach, describe, expect, test } from 'bun:test'
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
2
2
|
import {
|
|
3
3
|
type AnyNode,
|
|
4
4
|
type AnyNodeId,
|
|
5
|
+
nodeRegistry,
|
|
5
6
|
registerNode,
|
|
6
7
|
sceneRegistry,
|
|
7
8
|
useInteractive,
|
|
8
9
|
useScene,
|
|
9
10
|
} from '@pascal-app/core'
|
|
10
11
|
import { Object3D } from 'three'
|
|
11
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
hasNativeLeaf,
|
|
14
|
+
mechanismHudInteract,
|
|
15
|
+
mechanismTargetIds,
|
|
16
|
+
nativeOpeningIds,
|
|
17
|
+
watchScriptedDoors,
|
|
18
|
+
} from './mechanism-targets'
|
|
12
19
|
|
|
13
20
|
const switchMechanism = {
|
|
14
21
|
has: (node: AnyNode) => (node as unknown as { joints: number }).joints > 0,
|
|
@@ -73,3 +80,83 @@ describe('walkthrough mechanism targets', () => {
|
|
|
73
80
|
expect(mechanismHudInteract(mount(registerKind({}), 'plain_hud'))).toBeNull()
|
|
74
81
|
})
|
|
75
82
|
})
|
|
83
|
+
|
|
84
|
+
// A door or window built from a script has no native leaf or sash: the walkthrough's own door and
|
|
85
|
+
// window targets only start a native swing on it, which draws nothing. Its `open` clip runs
|
|
86
|
+
// through its mechanism, as an authored object's does.
|
|
87
|
+
describe('walkthrough doors and windows', () => {
|
|
88
|
+
const script = { source: { kind: 'script', script: 'b'.repeat(64), artifact: 'a'.repeat(64) } }
|
|
89
|
+
|
|
90
|
+
test('the doors and windows it swings itself are the ones with a native leaf', () => {
|
|
91
|
+
const native = mount('door', 'door_native')
|
|
92
|
+
const scripted = mount('door', 'door_scripted', script)
|
|
93
|
+
mount('window', 'window_native')
|
|
94
|
+
mount('window', 'window_scripted', script)
|
|
95
|
+
|
|
96
|
+
expect(nativeOpeningIds().sort()).toEqual(['door_native', 'window_native'] as AnyNodeId[])
|
|
97
|
+
expect(hasNativeLeaf(native)).toBe(true)
|
|
98
|
+
expect(hasNativeLeaf(scripted)).toBe(false)
|
|
99
|
+
expect(hasNativeLeaf(undefined)).toBe(true)
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
// The walkthrough's collider world is rebuilt when a native door's tween ends. A door built from a
|
|
104
|
+
// script opens by a clip that no tween system reports: its collider follows its mechanism, so
|
|
105
|
+
// the world has to be rebuilt when that flips.
|
|
106
|
+
describe('walkthrough scripted doors', () => {
|
|
107
|
+
const mechanism = {
|
|
108
|
+
verb: 'open',
|
|
109
|
+
has: (node: AnyNode) => Boolean((node as unknown as { source?: unknown }).source),
|
|
110
|
+
isOn: (node: AnyNode, state: ReturnType<typeof useInteractive.getState>) =>
|
|
111
|
+
Boolean(state.items[node.id as AnyNodeId]?.controlValues[0]),
|
|
112
|
+
set: () => {},
|
|
113
|
+
}
|
|
114
|
+
let restoreRegistry = () => {}
|
|
115
|
+
|
|
116
|
+
beforeEach(() => {
|
|
117
|
+
restoreRegistry = nodeRegistry._snapshot()
|
|
118
|
+
registerNode({
|
|
119
|
+
kind: 'door',
|
|
120
|
+
schemaVersion: 1,
|
|
121
|
+
category: 'structure',
|
|
122
|
+
defaults: () => ({}),
|
|
123
|
+
capabilities: { mechanism },
|
|
124
|
+
} as never)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
afterEach(() => {
|
|
128
|
+
useInteractive.setState({ items: {} } as never)
|
|
129
|
+
restoreRegistry()
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('a rebuild is asked for when a scripted door opens or closes, and for nothing else', () => {
|
|
133
|
+
mount('door', 'door_scripted_watch', { source: { kind: 'script' } })
|
|
134
|
+
mount('door', 'door_native_watch')
|
|
135
|
+
let rebuilds = 0
|
|
136
|
+
const stop = watchScriptedDoors(() => rebuilds++)
|
|
137
|
+
const setControls = (scripted: boolean, native: boolean) =>
|
|
138
|
+
useInteractive.setState({
|
|
139
|
+
items: {
|
|
140
|
+
door_scripted_watch: { controlValues: [scripted] },
|
|
141
|
+
door_native_watch: { controlValues: [native] },
|
|
142
|
+
},
|
|
143
|
+
} as never)
|
|
144
|
+
try {
|
|
145
|
+
// its model initialising the item closed is not a change
|
|
146
|
+
setControls(false, false)
|
|
147
|
+
expect(rebuilds).toBe(0)
|
|
148
|
+
setControls(true, false)
|
|
149
|
+
expect(rebuilds).toBe(1)
|
|
150
|
+
// the same state again, and a native door's control, are not its business
|
|
151
|
+
setControls(true, false)
|
|
152
|
+
setControls(true, true)
|
|
153
|
+
expect(rebuilds).toBe(1)
|
|
154
|
+
setControls(false, true)
|
|
155
|
+
expect(rebuilds).toBe(2)
|
|
156
|
+
} finally {
|
|
157
|
+
stop()
|
|
158
|
+
}
|
|
159
|
+
setControls(true, true)
|
|
160
|
+
expect(rebuilds).toBe(2)
|
|
161
|
+
})
|
|
162
|
+
})
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type AnyNode,
|
|
3
3
|
type AnyNodeId,
|
|
4
|
+
isScriptedNode,
|
|
4
5
|
nodeMechanism,
|
|
5
6
|
nodeRegistry,
|
|
6
7
|
sceneRegistry,
|
|
@@ -9,6 +10,27 @@ import {
|
|
|
9
10
|
} from '@pascal-app/core'
|
|
10
11
|
import type { WalkthroughInteract } from '../../../store/use-first-person-hud'
|
|
11
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Whether the walkthrough swings this door or window itself, through the native `operationState`
|
|
15
|
+
* its door and window systems draw. One built from a script has no native leaf or sash: a swing
|
|
16
|
+
* would draw nothing, so its clips run through its mechanism, as an authored object's do.
|
|
17
|
+
*/
|
|
18
|
+
export function hasNativeLeaf(node: AnyNode | undefined): boolean {
|
|
19
|
+
return !isScriptedNode(node)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Mounted doors and windows the walkthrough's own door and window targets cover. */
|
|
23
|
+
export function nativeOpeningIds(): AnyNodeId[] {
|
|
24
|
+
const nodes = useScene.getState().nodes
|
|
25
|
+
const ids: AnyNodeId[] = []
|
|
26
|
+
for (const kind of ['door', 'window'] as const) {
|
|
27
|
+
for (const id of sceneRegistry.byType[kind] ?? []) {
|
|
28
|
+
if (hasNativeLeaf(nodes[id as AnyNodeId])) ids.push(id as AnyNodeId)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return ids
|
|
32
|
+
}
|
|
33
|
+
|
|
12
34
|
/**
|
|
13
35
|
* Mounted nodes the walkthrough runs through `capabilities.mechanism`, any kind
|
|
14
36
|
* included. `covered` holds the nodes a finer target already tested (procedural
|
|
@@ -34,3 +56,33 @@ export function mechanismHudInteract(node: AnyNode): WalkthroughInteract {
|
|
|
34
56
|
const verb = mechanism.verb === 'open' ? (on ? 'close' : 'open') : on ? 'turn off' : 'turn on'
|
|
35
57
|
return { label: node.name ?? 'item', verb }
|
|
36
58
|
}
|
|
59
|
+
|
|
60
|
+
/** Ids of the mounted doors built from a script that are open, by their mechanism. */
|
|
61
|
+
function openScriptedDoors(): string {
|
|
62
|
+
const nodes = useScene.getState().nodes
|
|
63
|
+
const state = useInteractive.getState()
|
|
64
|
+
const open: string[] = []
|
|
65
|
+
for (const id of sceneRegistry.byType.door ?? []) {
|
|
66
|
+
const node = nodes[id as AnyNodeId]
|
|
67
|
+
if (isScriptedNode(node) && nodeMechanism(node)?.isOn(node, state)) open.push(id)
|
|
68
|
+
}
|
|
69
|
+
return open.sort().join(',')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Calls `onChange` when a mounted door built from a script opens or closes. The walkthrough's
|
|
74
|
+
* collider follows the door's mechanism, and its model's clip is moved by no tween system that
|
|
75
|
+
* would report the end, as a native door's does: without this the world keeps the leaf that was
|
|
76
|
+
* there when it was last built. It fires at the toggle: the doorway is passable as the leaves
|
|
77
|
+
* start to move, and solid again as they start to close.
|
|
78
|
+
*/
|
|
79
|
+
export function watchScriptedDoors(onChange: () => void): () => void {
|
|
80
|
+
let known = openScriptedDoors()
|
|
81
|
+
return useInteractive.subscribe((state, previous) => {
|
|
82
|
+
if (state.items === previous.items) return
|
|
83
|
+
const next = openScriptedDoors()
|
|
84
|
+
if (next === known) return
|
|
85
|
+
known = next
|
|
86
|
+
onChange()
|
|
87
|
+
})
|
|
88
|
+
}
|
|
@@ -81,6 +81,7 @@ import {
|
|
|
81
81
|
isOperationDoorType,
|
|
82
82
|
toggleDoorOpenState,
|
|
83
83
|
} from '../../lib/door-interaction'
|
|
84
|
+
import { isTypingTarget } from '../../lib/typing-target'
|
|
84
85
|
import {
|
|
85
86
|
closeWindowOpenState,
|
|
86
87
|
getDisplayedWindowValue,
|
|
@@ -97,7 +98,13 @@ import {
|
|
|
97
98
|
type FirstPersonColliderWorld,
|
|
98
99
|
type FirstPersonSpawn,
|
|
99
100
|
} from './first-person/build-collider-world'
|
|
100
|
-
import {
|
|
101
|
+
import {
|
|
102
|
+
hasNativeLeaf,
|
|
103
|
+
mechanismHudInteract,
|
|
104
|
+
mechanismTargetIds,
|
|
105
|
+
nativeOpeningIds,
|
|
106
|
+
watchScriptedDoors,
|
|
107
|
+
} from './first-person/mechanism-targets'
|
|
101
108
|
|
|
102
109
|
const CAMERA_EYE_OFFSET = 0.45
|
|
103
110
|
const LOOK_SENSITIVITY = 0.002
|
|
@@ -515,7 +522,7 @@ function createElevatorColliderMesh(
|
|
|
515
522
|
;(bvhGeometry as any).computeBoundsTree = computeBoundsTree
|
|
516
523
|
;(bvhGeometry as any).disposeBoundsTree = disposeBoundsTree
|
|
517
524
|
bvhGeometry.computeBoundsTree?.({
|
|
518
|
-
|
|
525
|
+
targetLeafSize: 12,
|
|
519
526
|
strategy: 0,
|
|
520
527
|
} as never)
|
|
521
528
|
bvhGeometry.computeBoundingBox()
|
|
@@ -838,6 +845,7 @@ export const FirstPersonControls = () => {
|
|
|
838
845
|
const node = nodes[doorId as AnyNodeId]
|
|
839
846
|
if (node?.type !== 'door') continue
|
|
840
847
|
if (node.openingKind === 'opening') continue
|
|
848
|
+
if (!hasNativeLeaf(node)) continue
|
|
841
849
|
if (node.segments.every((segment) => segment.type === 'empty')) continue
|
|
842
850
|
|
|
843
851
|
const object = sceneRegistry.nodes.get(doorId)
|
|
@@ -936,6 +944,7 @@ export const FirstPersonControls = () => {
|
|
|
936
944
|
const node = nodes[windowId as AnyNodeId]
|
|
937
945
|
if (node?.type !== 'window') continue
|
|
938
946
|
if (node.openingKind === 'opening') continue
|
|
947
|
+
if (!hasNativeLeaf(node)) continue
|
|
939
948
|
if (!isOperableWindowType(node.windowType)) continue
|
|
940
949
|
|
|
941
950
|
const object = sceneRegistry.nodes.get(windowId)
|
|
@@ -1082,9 +1091,9 @@ export const FirstPersonControls = () => {
|
|
|
1082
1091
|
closestDistance = hit.distance
|
|
1083
1092
|
}
|
|
1084
1093
|
}
|
|
1085
|
-
// Doors and windows have their own walkthrough targets (resolveInteractable*Id)
|
|
1086
|
-
|
|
1087
|
-
for (const
|
|
1094
|
+
// Doors and windows have their own walkthrough targets (resolveInteractable*Id), except those
|
|
1095
|
+
// built from a script: their clips run through the mechanism target below.
|
|
1096
|
+
for (const id of nativeOpeningIds()) covered.add(id)
|
|
1088
1097
|
for (const id of mechanismTargetIds(covered)) {
|
|
1089
1098
|
const object = sceneRegistry.nodes.get(id)
|
|
1090
1099
|
const hit = object && proceduralInteractionRaycaster.intersectObject(object, true)[0]
|
|
@@ -1298,9 +1307,12 @@ export const FirstPersonControls = () => {
|
|
|
1298
1307
|
if (isDroneMode) return
|
|
1299
1308
|
emitter.on('door:animation-completed', rebuildColliderWorld)
|
|
1300
1309
|
emitter.on('window:animation-completed', rebuildColliderWorld)
|
|
1310
|
+
// A door built from a script opens by its model's clip, which reports no animation end.
|
|
1311
|
+
const stopWatching = watchScriptedDoors(rebuildColliderWorld)
|
|
1301
1312
|
return () => {
|
|
1302
1313
|
emitter.off('door:animation-completed', rebuildColliderWorld)
|
|
1303
1314
|
emitter.off('window:animation-completed', rebuildColliderWorld)
|
|
1315
|
+
stopWatching()
|
|
1304
1316
|
}
|
|
1305
1317
|
}, [isDroneMode, rebuildColliderWorld])
|
|
1306
1318
|
|
|
@@ -1456,9 +1468,7 @@ export const FirstPersonControls = () => {
|
|
|
1456
1468
|
const canvas = gl.domElement
|
|
1457
1469
|
|
|
1458
1470
|
const applyMovementKey = (event: KeyboardEvent, active: boolean) => {
|
|
1459
|
-
if (event.target
|
|
1460
|
-
return false
|
|
1461
|
-
}
|
|
1471
|
+
if (isTypingTarget(event.target)) return false
|
|
1462
1472
|
|
|
1463
1473
|
const movement = getMovementInputForKey(event.code, active)
|
|
1464
1474
|
if (!movement) return false
|
|
@@ -1473,9 +1483,7 @@ export const FirstPersonControls = () => {
|
|
|
1473
1483
|
const handledMovement = applyMovementKey(event, true)
|
|
1474
1484
|
if (handledMovement) return
|
|
1475
1485
|
|
|
1476
|
-
if (event.target
|
|
1477
|
-
return
|
|
1478
|
-
}
|
|
1486
|
+
if (isTypingTarget(event.target)) return
|
|
1479
1487
|
|
|
1480
1488
|
if (event.code === 'ControlLeft' || event.code === 'ControlRight') {
|
|
1481
1489
|
// While paused (P), crouch is frozen as-is — ⌃⇧⌘4 (clipboard
|
|
@@ -2,11 +2,13 @@ import { afterAll, beforeAll, expect, test } from 'bun:test'
|
|
|
2
2
|
import { CameraControlsImpl } from '@react-three/drei'
|
|
3
3
|
import * as THREE from 'three'
|
|
4
4
|
import {
|
|
5
|
+
diveFloorY,
|
|
5
6
|
LOOK_UP_PITCH_GAIN,
|
|
6
7
|
LOOK_UP_PUSH_HALF_ANGLE,
|
|
7
8
|
MAX_ORBIT_POLAR_ANGLE,
|
|
8
9
|
ORBIT_FLOOR_CLEARANCE,
|
|
9
10
|
ORBIT_TARGET_HEIGHT,
|
|
11
|
+
orbitFloorMinY,
|
|
10
12
|
} from '../../lib/orbit-floor-clearance'
|
|
11
13
|
import { FloorAwareCameraControls } from './floor-aware-camera-controls'
|
|
12
14
|
|
|
@@ -440,3 +442,25 @@ test('once the user moves the camera, a written low pose eases up to the clearan
|
|
|
440
442
|
}
|
|
441
443
|
expect(controls.camera.position.y).toBeCloseTo(0.05 + GROUND_MIN_Y, 4)
|
|
442
444
|
})
|
|
445
|
+
|
|
446
|
+
test('with the dive on the eye goes under the ground and stops at the dive floor', () => {
|
|
447
|
+
const controls = groundFloorControls()
|
|
448
|
+
const diveFloor = diveFloorY(-0.05, null)
|
|
449
|
+
controls.floorMinY = orbitFloorMinY(0, diveFloor)
|
|
450
|
+
// The dive turns the surface off: the ground and the floor would hold the eye over them.
|
|
451
|
+
controls.floorSurface = null
|
|
452
|
+
const { distance } = controls
|
|
453
|
+
orbitTo(controls, 100)
|
|
454
|
+
// The pivot is at the ground's level: 10° past the horizon the eye is about a metre under it.
|
|
455
|
+
expect(controls.camera.position.y).toBeCloseTo(distance * Math.cos(degrees(100)), 6)
|
|
456
|
+
expect(controls.camera.position.y).toBeLessThan(0)
|
|
457
|
+
orbitTo(controls, 150)
|
|
458
|
+
expect(controls.camera.position.y).toBeCloseTo(diveFloor + ORBIT_FLOOR_CLEARANCE, 6)
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
test('without the dive the same orbit stays over the ground floor', () => {
|
|
462
|
+
const controls = groundFloorControls()
|
|
463
|
+
controls.floorSurface = { revision: 0, topAt: () => 0 }
|
|
464
|
+
orbitTo(controls, 100)
|
|
465
|
+
expect(controls.camera.position.y).toBeGreaterThanOrEqual(GROUND_MIN_Y - 1e-6)
|
|
466
|
+
})
|
|
@@ -59,7 +59,7 @@ export function FloorplanModeCoordinator() {
|
|
|
59
59
|
<span>{notice.message}</span>
|
|
60
60
|
{notice.kind === 'switch-to-expert' ? (
|
|
61
61
|
<button
|
|
62
|
-
className="shrink-0 rounded-md bg-cyan-500/15 px-2.5 py-1 font-medium text-cyan-400 hover:bg-cyan-500/25"
|
|
62
|
+
className="shrink-0 rounded-md bg-cyan-500/15 px-2.5 py-1 font-medium text-cyan-700 dark:text-cyan-400 hover:bg-cyan-500/25"
|
|
63
63
|
onClick={() => {
|
|
64
64
|
setFloorplanMode('expert')
|
|
65
65
|
dismissNotice()
|
|
@@ -71,7 +71,7 @@ export function FloorplanModeCoordinator() {
|
|
|
71
71
|
) : null}
|
|
72
72
|
<button
|
|
73
73
|
aria-label="Dismiss"
|
|
74
|
-
className="shrink-0 rounded p-1 text-muted-foreground hover:bg-white/10 hover:text-foreground"
|
|
74
|
+
className="shrink-0 rounded p-1 text-muted-foreground hover:bg-black/6 dark:hover:bg-white/10 hover:text-foreground"
|
|
75
75
|
onClick={dismissNotice}
|
|
76
76
|
type="button"
|
|
77
77
|
>
|