@pascal-app/editor 0.9.2 → 1.0.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +9 -7
- package/src/components/editor/bake-exporter.tsx +3 -1
- package/src/components/editor/bake-thumbnail.tsx +95 -0
- package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
- package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
- package/src/components/editor/custom-camera-controls.tsx +307 -212
- package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
- package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
- package/src/components/editor/first-person/build-collider-world.test.ts +66 -2
- package/src/components/editor/first-person/build-collider-world.ts +20 -0
- package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
- package/src/components/editor/first-person/terrain-collider.ts +212 -0
- package/src/components/editor/first-person-controls.tsx +295 -132
- package/src/components/editor/floating-action-menu.tsx +59 -65
- package/src/components/editor/floorplan-background-selection.test.ts +16 -2
- package/src/components/editor/floorplan-background-selection.ts +4 -18
- package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
- package/src/components/editor/floorplan-camera-sync.ts +213 -0
- package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
- package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
- package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
- package/src/components/editor/floorplan-panel.tsx +931 -861
- package/src/components/editor/group-actions.ts +155 -15
- package/src/components/editor/group-floating-action-menu.tsx +1 -1
- package/src/components/editor/group-rotate-handle.tsx +1 -1
- package/src/components/editor/group-selection-box-3d.tsx +1 -1
- package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
- package/src/components/editor/handles/handle-drag-history.ts +14 -0
- package/src/components/editor/handles/preview-overrides.test.ts +26 -0
- package/src/components/editor/handles/preview-overrides.ts +13 -0
- package/src/components/editor/handles/resize-snap.test.ts +52 -0
- package/src/components/editor/handles/resize-snap.ts +23 -0
- package/src/components/editor/handles/use-handle-drag.ts +17 -7
- package/src/components/editor/index.tsx +42 -9
- package/src/components/editor/measurement-pill.tsx +6 -1
- package/src/components/editor/node-arrow-handles.tsx +51 -15
- package/src/components/editor/quick-measurement-card.tsx +9 -3
- package/src/components/editor/quick-measurement-hud.tsx +7 -1
- package/src/components/editor/selection-manager.tsx +3 -1
- package/src/components/editor/site-edge-labels.tsx +24 -2
- package/src/components/editor/snapshot-capture-overlay.tsx +4 -1
- package/src/components/editor/thumbnail-generator.tsx +66 -294
- package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
- package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
- package/src/components/editor/use-mesh-settle-epoch.ts +1 -0
- package/src/components/editor/wall-measurement-label.tsx +8 -16
- package/src/components/editor/wall-move-side-handles.tsx +193 -18
- package/src/components/editor/wall-opening-highlights.tsx +63 -32
- package/src/components/editor/wall-snap-beacon-layer.tsx +6 -4
- package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +39 -8
- package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
- package/src/components/editor-2d/floorplan-group-move.tsx +47 -4
- package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +21 -5
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +93 -59
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +27 -0
- package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
- package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
- package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
- package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
- package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +75 -1
- package/src/components/editor-2d/renderers/floorplan-label-angle.ts +92 -0
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +93 -28
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +279 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +621 -281
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +12 -7
- package/src/components/tools/fence/fence-drafting.ts +27 -0
- package/src/components/tools/item/use-draft-node.ts +131 -72
- package/src/components/tools/item/use-placement-coordinator.tsx +52 -4
- package/src/components/tools/registry/move-registry-node-tool.tsx +75 -4
- package/src/components/tools/roof/roof-tool.tsx +16 -0
- package/src/components/tools/shared/floor-stack-preview.ts +4 -0
- package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
- package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
- package/src/components/tools/shared/pointer-support-cap.ts +273 -0
- package/src/components/tools/site/site-boundary-editor.tsx +13 -6
- package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
- package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
- package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
- package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
- package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
- package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
- package/src/components/tools/stair/stair-click-guard.ts +72 -0
- package/src/components/tools/stair/stair-tool.tsx +98 -47
- package/src/components/tools/tool-manager.tsx +67 -9
- package/src/components/tools/wall/wall-drafting.test.ts +118 -1
- package/src/components/tools/wall/wall-drafting.ts +128 -9
- package/src/components/tools/wall/wall-snap-geometry.test.ts +8 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +70 -3
- package/src/components/tools/zone/zone-tool.tsx +16 -0
- package/src/components/ui/action-menu/control-modes.tsx +4 -1
- package/src/components/ui/action-menu/measurement-control.tsx +107 -28
- package/src/components/ui/command-palette/editor-commands.tsx +14 -1
- package/src/components/ui/controls/material-paint-panel.tsx +7 -1
- package/src/components/ui/controls/material-picker.tsx +86 -7
- package/src/components/ui/controls/terrain-sculpt-panel.tsx +193 -0
- package/src/components/ui/floating-level-selector.tsx +73 -19
- package/src/components/ui/helpers/helper-manager.tsx +46 -0
- package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
- package/src/components/ui/panels/panel-manager.tsx +14 -1
- package/src/components/ui/panels/parametric-inspector.tsx +4 -2
- package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
- package/src/components/ui/panels/selection-breakdown.ts +20 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +14 -4
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +16 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +2 -0
- package/src/components/viewer/viewer-controls-bar.tsx +466 -0
- package/src/components/viewer/viewer-scene-header.tsx +235 -0
- package/src/components/viewer-overlay.tsx +20 -655
- package/src/components/walkthrough-hud.tsx +111 -0
- package/src/hooks/use-grid-events.ts +24 -0
- package/src/hooks/use-keyboard.ts +53 -30
- package/src/index.tsx +137 -4
- package/src/lib/active-placement-surface.test.ts +24 -0
- package/src/lib/active-placement-surface.ts +8 -1
- package/src/lib/camera-pose.test.ts +237 -0
- package/src/lib/camera-pose.ts +172 -0
- package/src/lib/direct-manipulation.test.ts +83 -0
- package/src/lib/direct-manipulation.ts +9 -0
- package/src/lib/door-interaction.ts +1 -1
- package/src/lib/elevation-guides.test.ts +107 -0
- package/src/lib/elevation-guides.ts +265 -0
- package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
- package/src/lib/floorplan/annotation-visibility.ts +133 -0
- package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
- package/src/lib/floorplan/drawing-coordination.ts +13 -0
- package/src/lib/floorplan/floorplan-export.test.ts +319 -0
- package/src/lib/floorplan/floorplan-export.tsx +745 -131
- package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
- package/src/lib/floorplan/floorplan-extension.ts +181 -0
- package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
- package/src/lib/floorplan/floorplan-mode.ts +73 -0
- package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
- package/src/lib/floorplan/geometry.ts +2 -3
- package/src/lib/fresh-planar-placement.test.ts +44 -1
- package/src/lib/fresh-planar-placement.ts +21 -4
- package/src/lib/glb-export.test.ts +116 -1
- package/src/lib/glb-export.ts +204 -7
- package/src/lib/ground-surface.ts +95 -0
- package/src/lib/history.test.ts +96 -0
- package/src/lib/history.ts +22 -0
- package/src/lib/interaction/overlay-policy.test.ts +2 -1
- package/src/lib/interaction/scope.ts +40 -8
- package/src/lib/level-duplication.test.ts +2 -1
- package/src/lib/measurements.test.ts +5 -0
- package/src/lib/measurements.ts +12 -1
- package/src/lib/quick-action-feedback.ts +33 -0
- package/src/lib/quick-action-nodes.test.ts +94 -0
- package/src/lib/quick-action-nodes.ts +47 -0
- package/src/lib/scene-clipboard.test.ts +130 -1
- package/src/lib/scene-clipboard.ts +237 -13
- package/src/lib/selection-routing.test.ts +16 -1
- package/src/lib/selection-routing.ts +5 -0
- package/src/lib/sfx-bus.ts +33 -2
- package/src/lib/sfx-player.test.ts +148 -6
- package/src/lib/sfx-player.ts +156 -8
- package/src/lib/site-boundary.test.ts +53 -0
- package/src/lib/site-boundary.ts +27 -0
- package/src/lib/snapping-mode.test.ts +29 -2
- package/src/lib/snapping-mode.ts +15 -3
- package/src/lib/stair-duplication.test.ts +103 -0
- package/src/lib/stair-duplication.ts +57 -85
- package/src/lib/stair-levels.ts +2 -0
- package/src/lib/terrain-sculpt.test.ts +429 -0
- package/src/lib/terrain-sculpt.ts +301 -0
- package/src/lib/terrain-verb-color.test.ts +51 -0
- package/src/lib/terrain-verb-color.ts +58 -0
- package/src/lib/touch-gesture-priority.test.ts +45 -0
- package/src/lib/touch-gesture-priority.ts +30 -0
- package/src/lib/window-interaction.ts +1 -1
- package/src/store/camera-pose-store.test.ts +29 -0
- package/src/store/camera-pose-store.ts +27 -0
- package/src/store/live-draft-preview-stores.test.ts +136 -0
- package/src/store/navigation-sync-pose-store.test.ts +34 -0
- package/src/store/navigation-sync-pose-store.ts +27 -0
- package/src/store/terrain-sculpt-mode.test.ts +223 -0
- package/src/store/use-delete-confirmation.ts +27 -0
- package/src/store/use-drawing-view.test.ts +23 -0
- package/src/store/use-drawing-view.ts +84 -0
- package/src/store/use-editor.tsx +184 -15
- package/src/store/use-elevation-guides.ts +40 -0
- package/src/store/use-fence-curve-draft.ts +38 -8
- package/src/store/use-first-person-hud.ts +40 -0
- package/src/store/use-floorplan-annotation-visibility.ts +60 -0
- package/src/store/use-floorplan-draft-preview.ts +54 -4
- package/src/store/use-floorplan-mode.ts +110 -0
- package/src/store/use-interaction-scope.test.ts +23 -3
- package/src/store/use-interaction-scope.ts +8 -0
- package/src/store/use-measurement-draft.test.ts +29 -0
- package/src/store/use-measurement-draft.ts +33 -6
- package/src/store/use-path-draft-preview.ts +90 -0
- package/src/store/use-stair-build-preview.ts +12 -2
- package/src/lib/floorplan/selection-tool.ts +0 -271
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react'
|
|
4
|
+
import useDeleteConfirmation from '../../store/use-delete-confirmation'
|
|
5
|
+
import {
|
|
6
|
+
Dialog,
|
|
7
|
+
DialogContent,
|
|
8
|
+
DialogDescription,
|
|
9
|
+
DialogFooter,
|
|
10
|
+
DialogHeader,
|
|
11
|
+
DialogTitle,
|
|
12
|
+
} from '../ui/primitives/dialog'
|
|
13
|
+
|
|
14
|
+
export function DeleteConfirmationDialog() {
|
|
15
|
+
const request = useDeleteConfirmation((state) => state.request)
|
|
16
|
+
const cancel = useDeleteConfirmation((state) => state.cancel)
|
|
17
|
+
const confirm = useDeleteConfirmation((state) => state.confirm)
|
|
18
|
+
|
|
19
|
+
useEffect(() => cancel, [cancel])
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<Dialog onOpenChange={(open) => !open && cancel()} open={request !== null}>
|
|
23
|
+
<DialogContent
|
|
24
|
+
className="border-border/70 bg-background/95 shadow-2xl backdrop-blur-xl sm:max-w-md"
|
|
25
|
+
data-delete-confirmation-dialog
|
|
26
|
+
showCloseButton={false}
|
|
27
|
+
>
|
|
28
|
+
<DialogHeader>
|
|
29
|
+
<DialogTitle>Delete {request?.count ?? 0} elements?</DialogTitle>
|
|
30
|
+
<DialogDescription>
|
|
31
|
+
This removes every selected element. You can undo the deletion while it remains in the
|
|
32
|
+
editor history.
|
|
33
|
+
</DialogDescription>
|
|
34
|
+
</DialogHeader>
|
|
35
|
+
<DialogFooter>
|
|
36
|
+
<button
|
|
37
|
+
className="rounded-full border border-border px-4 py-2 text-sm transition-colors hover:bg-accent"
|
|
38
|
+
onClick={cancel}
|
|
39
|
+
type="button"
|
|
40
|
+
>
|
|
41
|
+
Cancel
|
|
42
|
+
</button>
|
|
43
|
+
<button
|
|
44
|
+
className="rounded-full bg-red-600 px-4 py-2 text-sm text-white transition-colors hover:bg-red-700"
|
|
45
|
+
onClick={confirm}
|
|
46
|
+
type="button"
|
|
47
|
+
>
|
|
48
|
+
Delete
|
|
49
|
+
</button>
|
|
50
|
+
</DialogFooter>
|
|
51
|
+
</DialogContent>
|
|
52
|
+
</Dialog>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { sceneRegistry } from '@pascal-app/core'
|
|
4
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
5
|
+
import { Html } from '@react-three/drei'
|
|
6
|
+
import { useFrame } from '@react-three/fiber'
|
|
7
|
+
import { memo, useMemo, useRef } from 'react'
|
|
8
|
+
import { BoxGeometry, CircleGeometry, type Group } from 'three'
|
|
9
|
+
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
|
10
|
+
import { EDITOR_LAYER } from '../../lib/constants'
|
|
11
|
+
import useElevationGuides from '../../store/use-elevation-guides'
|
|
12
|
+
import { formatMeasurement } from './measurement-pill'
|
|
13
|
+
|
|
14
|
+
const LINE_COLOR = 0x81_8c_f8
|
|
15
|
+
const PILL_COLOR = '#6366f1'
|
|
16
|
+
const GUIDE_LIFT = 0.025
|
|
17
|
+
const GUIDE_LENGTH = 2.4
|
|
18
|
+
const DASH_LENGTH = 0.16
|
|
19
|
+
const DASH_GAP = 0.1
|
|
20
|
+
const LINE_WIDTH = 0.035
|
|
21
|
+
const DOT_RADIUS = 0.07
|
|
22
|
+
|
|
23
|
+
const guideMaterial = new MeshBasicNodeMaterial({
|
|
24
|
+
color: LINE_COLOR,
|
|
25
|
+
depthTest: false,
|
|
26
|
+
depthWrite: false,
|
|
27
|
+
toneMapped: false,
|
|
28
|
+
transparent: true,
|
|
29
|
+
opacity: 0.9,
|
|
30
|
+
})
|
|
31
|
+
const dotMaterial = new MeshBasicNodeMaterial({
|
|
32
|
+
color: LINE_COLOR,
|
|
33
|
+
depthTest: false,
|
|
34
|
+
depthWrite: false,
|
|
35
|
+
toneMapped: false,
|
|
36
|
+
transparent: true,
|
|
37
|
+
opacity: 0.95,
|
|
38
|
+
})
|
|
39
|
+
const DASH_GEOMETRY = new BoxGeometry(1, 1, 1)
|
|
40
|
+
const DOT_GEOMETRY = new CircleGeometry(1, 24)
|
|
41
|
+
|
|
42
|
+
type Vec3 = [number, number, number]
|
|
43
|
+
|
|
44
|
+
export const Elevation3DGuideLayer = memo(function Elevation3DGuideLayer() {
|
|
45
|
+
const guide = useElevationGuides((state) => state.guide)
|
|
46
|
+
const unit = useViewer((state) => state.unit)
|
|
47
|
+
const groupRef = useRef<Group>(null)
|
|
48
|
+
|
|
49
|
+
useFrame(() => {
|
|
50
|
+
const group = groupRef.current
|
|
51
|
+
if (!(group && guide)) return
|
|
52
|
+
const levelObject = sceneRegistry.nodes.get(guide.levelId)
|
|
53
|
+
group.position.y = (levelObject?.position.y ?? 0) + guide.elevation + GUIDE_LIFT
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
const dashes = useMemo(() => {
|
|
57
|
+
if (!guide) return [] as Vec3[]
|
|
58
|
+
const [cx, cz] = guide.center
|
|
59
|
+
const [dx, dz] = guide.direction
|
|
60
|
+
const start = -GUIDE_LENGTH / 2
|
|
61
|
+
const period = DASH_LENGTH + DASH_GAP
|
|
62
|
+
const centers: Vec3[] = []
|
|
63
|
+
for (let offset = start + DASH_LENGTH / 2; offset < GUIDE_LENGTH / 2; offset += period) {
|
|
64
|
+
centers.push([cx + dx * offset, 0, cz + dz * offset])
|
|
65
|
+
}
|
|
66
|
+
return centers
|
|
67
|
+
}, [guide])
|
|
68
|
+
|
|
69
|
+
if (!guide) return null
|
|
70
|
+
|
|
71
|
+
const angleY = -Math.atan2(guide.direction[1], guide.direction[0])
|
|
72
|
+
const labelPosition: Vec3 = [
|
|
73
|
+
guide.center[0] - guide.direction[1] * 0.65,
|
|
74
|
+
0.16,
|
|
75
|
+
guide.center[1] + guide.direction[0] * 0.65,
|
|
76
|
+
]
|
|
77
|
+
const elevationText =
|
|
78
|
+
Math.abs(guide.elevation) < 1e-6
|
|
79
|
+
? formatMeasurement(0, unit)
|
|
80
|
+
: `${guide.elevation < 0 ? '-' : '+'}${formatMeasurement(Math.abs(guide.elevation), unit)}`
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<group ref={groupRef}>
|
|
84
|
+
{dashes.map((position, index) => (
|
|
85
|
+
<mesh
|
|
86
|
+
geometry={DASH_GEOMETRY}
|
|
87
|
+
key={index}
|
|
88
|
+
layers={EDITOR_LAYER}
|
|
89
|
+
material={guideMaterial}
|
|
90
|
+
position={position}
|
|
91
|
+
renderOrder={1010}
|
|
92
|
+
rotation={[0, angleY, 0]}
|
|
93
|
+
scale={[DASH_LENGTH, 0.002, LINE_WIDTH]}
|
|
94
|
+
/>
|
|
95
|
+
))}
|
|
96
|
+
<mesh
|
|
97
|
+
geometry={DOT_GEOMETRY}
|
|
98
|
+
layers={EDITOR_LAYER}
|
|
99
|
+
material={dotMaterial}
|
|
100
|
+
position={[guide.center[0], 0.002, guide.center[1]]}
|
|
101
|
+
renderOrder={1011}
|
|
102
|
+
rotation={[-Math.PI / 2, 0, 0]}
|
|
103
|
+
scale={[DOT_RADIUS, DOT_RADIUS, DOT_RADIUS]}
|
|
104
|
+
/>
|
|
105
|
+
<Html
|
|
106
|
+
center
|
|
107
|
+
position={labelPosition}
|
|
108
|
+
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
|
109
|
+
zIndexRange={[25, 0]}
|
|
110
|
+
>
|
|
111
|
+
<div
|
|
112
|
+
className="whitespace-nowrap rounded-[3px] px-[5px] py-[2px] font-medium font-sans text-[11px] text-white"
|
|
113
|
+
style={{ backgroundColor: PILL_COLOR }}
|
|
114
|
+
>
|
|
115
|
+
{`${guide.label} · Y ${elevationText}`}
|
|
116
|
+
</div>
|
|
117
|
+
</Html>
|
|
118
|
+
</group>
|
|
119
|
+
)
|
|
120
|
+
})
|
|
@@ -2,9 +2,14 @@ import { afterEach, describe, expect, test } from 'bun:test'
|
|
|
2
2
|
import {
|
|
3
3
|
type AnyNode,
|
|
4
4
|
type AnyNodeDefinition,
|
|
5
|
+
applyHeightPatch,
|
|
6
|
+
BuildingNode,
|
|
5
7
|
CeilingNode,
|
|
6
8
|
ColumnNode,
|
|
9
|
+
createTerrainField,
|
|
7
10
|
ElevatorNode,
|
|
11
|
+
encodeTerrainField,
|
|
12
|
+
flattenPatch,
|
|
8
13
|
LevelNode,
|
|
9
14
|
nodeRegistry,
|
|
10
15
|
registerNode,
|
|
@@ -13,7 +18,7 @@ import {
|
|
|
13
18
|
sceneRegistry,
|
|
14
19
|
useScene,
|
|
15
20
|
} from '@pascal-app/core'
|
|
16
|
-
import { BoxGeometry, Group, Mesh, MeshBasicMaterial } from 'three'
|
|
21
|
+
import { BoxGeometry, Group, Mesh, MeshBasicMaterial, Raycaster, Vector3 } from 'three'
|
|
17
22
|
import { buildFirstPersonColliderWorldFromRegistry } from './build-collider-world'
|
|
18
23
|
|
|
19
24
|
function registerColliderDefinition(
|
|
@@ -46,8 +51,9 @@ function mountNode(
|
|
|
46
51
|
sceneRegistry.byType[node.type]!.add(node.id)
|
|
47
52
|
}
|
|
48
53
|
|
|
49
|
-
function mountRegistryGroup(node: AnyNode) {
|
|
54
|
+
function mountRegistryGroup(node: AnyNode, position: [number, number, number] = [0, 0, 0]) {
|
|
50
55
|
const group = new Group()
|
|
56
|
+
group.position.set(position[0], position[1], position[2])
|
|
51
57
|
group.updateMatrixWorld(true)
|
|
52
58
|
sceneRegistry.nodes.set(node.id, group)
|
|
53
59
|
sceneRegistry.byType[node.type]!.add(node.id)
|
|
@@ -160,6 +166,34 @@ describe('buildFirstPersonColliderWorldFromRegistry', () => {
|
|
|
160
166
|
world?.dispose()
|
|
161
167
|
})
|
|
162
168
|
|
|
169
|
+
test('adds a fallback floor only for the lowest slab-less level in a building', () => {
|
|
170
|
+
const building = BuildingNode.parse({
|
|
171
|
+
id: 'building_test',
|
|
172
|
+
children: ['level_ground', 'level_upper'],
|
|
173
|
+
})
|
|
174
|
+
const groundLevel = LevelNode.parse({
|
|
175
|
+
id: 'level_ground',
|
|
176
|
+
parentId: building.id,
|
|
177
|
+
level: 0,
|
|
178
|
+
height: 3,
|
|
179
|
+
})
|
|
180
|
+
const upperLevel = LevelNode.parse({
|
|
181
|
+
id: 'level_upper',
|
|
182
|
+
parentId: building.id,
|
|
183
|
+
level: 1,
|
|
184
|
+
})
|
|
185
|
+
setSceneNodes([building, groundLevel, upperLevel])
|
|
186
|
+
mountRegistryGroup(groundLevel)
|
|
187
|
+
mountRegistryGroup(upperLevel, [0, 3, 0])
|
|
188
|
+
|
|
189
|
+
const world = buildFirstPersonColliderWorldFromRegistry()
|
|
190
|
+
|
|
191
|
+
expect(world).not.toBeNull()
|
|
192
|
+
expect(world?.bounds?.min.y).toBeCloseTo(-0.08)
|
|
193
|
+
expect(world?.bounds?.max.y).toBeCloseTo(0)
|
|
194
|
+
world?.dispose()
|
|
195
|
+
})
|
|
196
|
+
|
|
163
197
|
test('adds a site ground collider so a spawn on bare ground has a floor', () => {
|
|
164
198
|
const site = SiteNode.parse({ id: 'site_test' })
|
|
165
199
|
setSceneNodes([site])
|
|
@@ -179,4 +213,34 @@ describe('buildFirstPersonColliderWorldFromRegistry', () => {
|
|
|
179
213
|
expect(world?.bounds?.max.z).toBeCloseTo(1000)
|
|
180
214
|
world?.dispose()
|
|
181
215
|
})
|
|
216
|
+
|
|
217
|
+
test('a sculpted site walks on its terrain instead of the flat ground slab', () => {
|
|
218
|
+
const base = createTerrainField({ cols: 17, rows: 17, spacing: 1, origin: [-8, -8] })
|
|
219
|
+
const patch = flattenPatch(base, { minX: 2, minZ: 2, maxX: 5, maxZ: 5 }, 2.5)
|
|
220
|
+
const field = applyHeightPatch(base, patch as never)
|
|
221
|
+
const site = SiteNode.parse({ id: 'site_test', terrain: encodeTerrainField(field) })
|
|
222
|
+
setSceneNodes([site])
|
|
223
|
+
mountRegistryGroup(site)
|
|
224
|
+
|
|
225
|
+
const world = buildFirstPersonColliderWorldFromRegistry()
|
|
226
|
+
expect(world).not.toBeNull()
|
|
227
|
+
if (!world) return
|
|
228
|
+
|
|
229
|
+
// The hill is in the collider: a flat slab would top out at 0.
|
|
230
|
+
expect(world.bounds?.max.y).toBeCloseTo(2.5)
|
|
231
|
+
// And it still reaches past the site so stepping out does not drop the player.
|
|
232
|
+
expect(world.bounds?.min.x).toBeCloseTo(-1008)
|
|
233
|
+
expect(world.bounds?.max.x).toBeCloseTo(1008)
|
|
234
|
+
|
|
235
|
+
// What the player actually stands on, on the plateau and off it.
|
|
236
|
+
const raycaster = new Raycaster()
|
|
237
|
+
const standOn = (x: number, z: number) => {
|
|
238
|
+
raycaster.set(new Vector3(x, 500, z), new Vector3(0, -1, 0))
|
|
239
|
+
return raycaster.intersectObject(world.mesh, false)[0]?.point.y ?? null
|
|
240
|
+
}
|
|
241
|
+
expect(standOn(3, 3)).toBeCloseTo(2.5, 4)
|
|
242
|
+
expect(standOn(-3, -3)).toBeCloseTo(0, 4)
|
|
243
|
+
|
|
244
|
+
world.dispose()
|
|
245
|
+
})
|
|
182
246
|
})
|
|
@@ -3,9 +3,11 @@ import {
|
|
|
3
3
|
type AnyNodeId,
|
|
4
4
|
type DoorNode,
|
|
5
5
|
getGarageVisibleOpeningRatio,
|
|
6
|
+
getLevelElevations,
|
|
6
7
|
isOperationDoorType,
|
|
7
8
|
nodeRegistry,
|
|
8
9
|
sceneRegistry,
|
|
10
|
+
terrainFieldOf,
|
|
9
11
|
useInteractive,
|
|
10
12
|
useScene,
|
|
11
13
|
} from '@pascal-app/core'
|
|
@@ -13,6 +15,7 @@ import * as THREE from 'three'
|
|
|
13
15
|
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
|
14
16
|
import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh'
|
|
15
17
|
import { computeSceneBoundsXZ } from '../../../lib/scene-bounds'
|
|
18
|
+
import { createTerrainColliderGeometry } from './terrain-collider'
|
|
16
19
|
|
|
17
20
|
const SKIPPED_MESH_NAMES = new Set(['cutout', 'collision-mesh'])
|
|
18
21
|
const COLLIDER_NODE_CATEGORIES = new Set(['structure', 'furnish'])
|
|
@@ -129,10 +132,12 @@ function createLevelFallbackFloorGeometry(level: LevelNode, nodes: SceneNodes) {
|
|
|
129
132
|
|
|
130
133
|
function collectLevelFallbackFloorGeometries(nodes: SceneNodes) {
|
|
131
134
|
const geometries: THREE.BufferGeometry[] = []
|
|
135
|
+
const levelElevations = getLevelElevations(nodes)
|
|
132
136
|
|
|
133
137
|
for (const levelId of sceneRegistry.byType.level!) {
|
|
134
138
|
const node = nodes[levelId as AnyNodeId]
|
|
135
139
|
if (node?.type !== 'level') continue
|
|
140
|
+
if (levelElevations.get(node.id)?.baseY !== 0) continue
|
|
136
141
|
|
|
137
142
|
const geometry = createLevelFallbackFloorGeometry(node, nodes)
|
|
138
143
|
if (geometry) geometries.push(geometry)
|
|
@@ -150,12 +155,27 @@ function collectLevelFallbackFloorGeometries(nodes: SceneNodes) {
|
|
|
150
155
|
// is effectively unbounded (not sized to the site polygon): the ground plane must
|
|
151
156
|
// keep holding the player up even after they step past the site boundary,
|
|
152
157
|
// otherwise they fall below the ground plane into the void.
|
|
158
|
+
//
|
|
159
|
+
// A sculpted site takes the terrain path instead: the flat slab would hold the
|
|
160
|
+
// player at the datum inside an excavation and bury them inside a hill. Derived
|
|
161
|
+
// from the field rather than the rendered terrain mesh for the same
|
|
162
|
+
// mount-timing reason as the flat slab.
|
|
153
163
|
function createSiteGroundColliderGeometry(site: SiteNode, nodes: SceneNodes) {
|
|
154
164
|
if (site.visible === false) return null
|
|
155
165
|
|
|
156
166
|
const siteObject = sceneRegistry.nodes.get(site.id)
|
|
157
167
|
if (!siteObject?.visible) return null
|
|
158
168
|
|
|
169
|
+
const field = terrainFieldOf(site)
|
|
170
|
+
if (field) {
|
|
171
|
+
const terrainGeometry = createTerrainColliderGeometry(field)
|
|
172
|
+
if (terrainGeometry) {
|
|
173
|
+
siteObject.updateWorldMatrix(true, false)
|
|
174
|
+
terrainGeometry.applyMatrix4(siteObject.matrixWorld)
|
|
175
|
+
return terrainGeometry
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
159
179
|
const bounds = computeSceneBoundsXZ(nodes)
|
|
160
180
|
const [centerX, centerZ] = bounds?.center ?? [0, 0]
|
|
161
181
|
const [boundsWidth, boundsDepth] = bounds?.size ?? [0, 0]
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
applyHeightPatch,
|
|
4
|
+
createTerrainField,
|
|
5
|
+
flattenPatch,
|
|
6
|
+
heightAt,
|
|
7
|
+
type TerrainField,
|
|
8
|
+
} from '@pascal-app/core'
|
|
9
|
+
import { Mesh, MeshBasicMaterial, Raycaster, Vector3 } from 'three'
|
|
10
|
+
import { createTerrainColliderGeometry } from './terrain-collider'
|
|
11
|
+
|
|
12
|
+
const DOWN = new Vector3(0, -1, 0)
|
|
13
|
+
const UP = new Vector3(0, 1, 0)
|
|
14
|
+
|
|
15
|
+
/** A 2.5 m plateau over x,z ∈ [2,5] on an otherwise flat field. */
|
|
16
|
+
function plateauField(): TerrainField {
|
|
17
|
+
const base = createTerrainField({ cols: 17, rows: 17, spacing: 1, origin: [-8, -8] })
|
|
18
|
+
const patch = flattenPatch(base, { minX: 2, minZ: 2, maxX: 5, maxZ: 5 }, 2.5)
|
|
19
|
+
return applyHeightPatch(base, patch as never)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A field sloping 0.1 m per metre along +x, so every sample differs. */
|
|
23
|
+
function slopedField(): TerrainField {
|
|
24
|
+
const field = createTerrainField({ cols: 9, rows: 9, spacing: 1, origin: [-4, -4] })
|
|
25
|
+
const heights = new Int16Array(field.heights)
|
|
26
|
+
for (let r = 0; r < field.rows; r++) {
|
|
27
|
+
for (let c = 0; c < field.cols; c++) {
|
|
28
|
+
heights[r * field.cols + c] = Math.round((c * 0.1) /* m */ / field.step)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return { ...field, heights }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function colliderMesh(field: TerrainField) {
|
|
35
|
+
const geometry = createTerrainColliderGeometry(field)
|
|
36
|
+
if (!geometry) throw new Error('no collider geometry')
|
|
37
|
+
const mesh = new Mesh(geometry, new MeshBasicMaterial())
|
|
38
|
+
mesh.updateMatrixWorld(true)
|
|
39
|
+
return mesh
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The height a downward ray finds — what the walkthrough player stands on. */
|
|
43
|
+
function groundUnder(mesh: Mesh, x: number, z: number): number | null {
|
|
44
|
+
const raycaster = new Raycaster()
|
|
45
|
+
raycaster.set(new Vector3(x, 500, z), DOWN)
|
|
46
|
+
const hits = raycaster.intersectObject(mesh, false)
|
|
47
|
+
const floor = hits.find((hit) => {
|
|
48
|
+
if (!hit.face) return true
|
|
49
|
+
return hit.face.normal.clone().transformDirection(mesh.matrixWorld).dot(UP) > 0.2
|
|
50
|
+
})
|
|
51
|
+
return floor ? floor.point.y : null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe('createTerrainColliderGeometry', () => {
|
|
55
|
+
test('the walkable surface is the field, not a plane', () => {
|
|
56
|
+
const field = plateauField()
|
|
57
|
+
const mesh = colliderMesh(field)
|
|
58
|
+
|
|
59
|
+
// On the plateau, off it, and on the slope between: the collider has to agree
|
|
60
|
+
// with `heightAt` at each, or what you see is not what you stand on.
|
|
61
|
+
for (const [x, z] of [
|
|
62
|
+
[3, 3],
|
|
63
|
+
[-3, -3],
|
|
64
|
+
[1.5, 3],
|
|
65
|
+
[4.5, 4.5],
|
|
66
|
+
] as Array<[number, number]>) {
|
|
67
|
+
expect(groundUnder(mesh, x, z)).toBeCloseTo(heightAt(field, x, z), 4)
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('a sloped field is sampled per vertex, not averaged', () => {
|
|
72
|
+
const field = slopedField()
|
|
73
|
+
const mesh = colliderMesh(field)
|
|
74
|
+
|
|
75
|
+
// Sample midpoints, where a flat or decimated collider would disagree most.
|
|
76
|
+
for (const x of [-3.5, -0.5, 1.5, 3.5]) {
|
|
77
|
+
expect(groundUnder(mesh, x, 0)).toBeCloseTo(heightAt(field, x, 0), 4)
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('the skirt keeps holding the player far past the field', () => {
|
|
82
|
+
const field = slopedField()
|
|
83
|
+
const mesh = colliderMesh(field)
|
|
84
|
+
|
|
85
|
+
// Past every edge and past a corner: `heightAt` clamps to the border there, so
|
|
86
|
+
// the collider must too. Without the skirt these are null and the player falls.
|
|
87
|
+
for (const [x, z] of [
|
|
88
|
+
[400, 0],
|
|
89
|
+
[-400, 0],
|
|
90
|
+
[0, 400],
|
|
91
|
+
[0, -400],
|
|
92
|
+
[400, 400],
|
|
93
|
+
[-400, -400],
|
|
94
|
+
] as Array<[number, number]>) {
|
|
95
|
+
expect(groundUnder(mesh, x, z)).toBeCloseTo(heightAt(field, x, z), 4)
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('every triangle carries a normal, and the surface faces up', () => {
|
|
100
|
+
const geometry = createTerrainColliderGeometry(plateauField())
|
|
101
|
+
if (!geometry) throw new Error('no collider geometry')
|
|
102
|
+
|
|
103
|
+
const position = geometry.getAttribute('position')
|
|
104
|
+
const normal = geometry.getAttribute('normal')
|
|
105
|
+
expect(normal).toBeDefined()
|
|
106
|
+
expect(normal.count).toBe(position.count)
|
|
107
|
+
// Non-indexed triangles — `mergeGeometries` needs every contributor to match.
|
|
108
|
+
expect(geometry.index).toBeNull()
|
|
109
|
+
expect(position.count % 3).toBe(0)
|
|
110
|
+
|
|
111
|
+
for (let i = 0; i < normal.count; i++) {
|
|
112
|
+
expect(normal.getY(i)).toBeGreaterThan(0)
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('decimates rather than exploding on an absurd field', () => {
|
|
117
|
+
// 1025² is inside the schema's decode-bomb guard but 1M cells — an exact
|
|
118
|
+
// collider would be 2M triangles for a surface nobody can walk that far across.
|
|
119
|
+
const field = createTerrainField({ cols: 1025, rows: 1025, spacing: 0.5 })
|
|
120
|
+
const geometry = createTerrainColliderGeometry(field)
|
|
121
|
+
if (!geometry) throw new Error('no collider geometry')
|
|
122
|
+
|
|
123
|
+
const triangles = geometry.getAttribute('position').count / 3
|
|
124
|
+
expect(triangles).toBeLessThan(200_000)
|
|
125
|
+
// Still a real surface, not an empty one.
|
|
126
|
+
expect(triangles).toBeGreaterThan(1000)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('returns null for a degenerate field', () => {
|
|
130
|
+
expect(createTerrainColliderGeometry(createTerrainField({ cols: 1, rows: 1 }))).toBeNull()
|
|
131
|
+
})
|
|
132
|
+
})
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sculpted ground as walkthrough collision geometry.
|
|
3
|
+
*
|
|
4
|
+
* The first-person collider world is a single merged BVH mesh, and before terrain
|
|
5
|
+
* the ground contributed one flat 2 km box to it. That box is wrong in both
|
|
6
|
+
* directions on a sculpted site: it holds the player up at the datum inside an
|
|
7
|
+
* excavation, and buries them inside a hill. This module replaces it.
|
|
8
|
+
*
|
|
9
|
+
* Two properties it has to hold, because the whole point is that walking agrees
|
|
10
|
+
* with every other consumer of the field:
|
|
11
|
+
*
|
|
12
|
+
* - **The surface is the field**, sampled at every vertex — the same
|
|
13
|
+
* `heightAtSample`/`normalAt` the rendered mesh uses (`terrain-geometry.ts`),
|
|
14
|
+
* so what you see is what you stand on. It is deliberately not a decimated
|
|
15
|
+
* proxy at realistic field sizes.
|
|
16
|
+
* - **It extends past the field**, because `heightAt` clamps to the border
|
|
17
|
+
* outside it. A collider that stopped at the field edge would drop the player
|
|
18
|
+
* into the void one step past the site, which is exactly the bug the flat box
|
|
19
|
+
* existed to prevent. The skirt extrudes each border sample outward at its own
|
|
20
|
+
* height, so the collider and `heightAt` agree everywhere, not just inside.
|
|
21
|
+
*
|
|
22
|
+
* Non-indexed triangles with position + normal, matching what the other collider
|
|
23
|
+
* contributors emit — `mergeGeometries` requires every input to agree on both.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { heightAtSample, normalAt, type TerrainField } from '@pascal-app/core'
|
|
27
|
+
import * as THREE from 'three'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* How far past the field the ground keeps holding the player, in metres. Matches
|
|
31
|
+
* the flat box's half-extent so a site's collider bounds do not change size when
|
|
32
|
+
* it gains terrain.
|
|
33
|
+
*/
|
|
34
|
+
const SKIRT_REACH = 1000
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Cell budget before the collider decimates.
|
|
38
|
+
*
|
|
39
|
+
* 66k cells is a 257² field — the largest anything realistic produces, and ~131k
|
|
40
|
+
* triangles, which the BVH builds in well under a frame's worth of budget. The
|
|
41
|
+
* schema allows up to 4097² (a decode-bomb guard, not a real terrain); at that
|
|
42
|
+
* size an exact collider would be 100M triangles, so past the budget this steps
|
|
43
|
+
* the sample grid and the collider becomes a faithful-but-coarser version of the
|
|
44
|
+
* same surface rather than an out-of-memory crash.
|
|
45
|
+
*/
|
|
46
|
+
const MAX_COLLIDER_CELLS = 66_000
|
|
47
|
+
|
|
48
|
+
function colliderStride(field: TerrainField): number {
|
|
49
|
+
const cells = Math.max(1, (field.cols - 1) * (field.rows - 1))
|
|
50
|
+
if (cells <= MAX_COLLIDER_CELLS) return 1
|
|
51
|
+
return Math.ceil(Math.sqrt(cells / MAX_COLLIDER_CELLS))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The sample indices the collider walks: every `stride`-th, always including the border. */
|
|
55
|
+
function sampleLine(count: number, stride: number): number[] {
|
|
56
|
+
const line: number[] = []
|
|
57
|
+
for (let i = 0; i < count - 1; i += stride) line.push(i)
|
|
58
|
+
line.push(count - 1)
|
|
59
|
+
return line
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
type Vertex = { x: number; y: number; z: number }
|
|
63
|
+
|
|
64
|
+
class TriangleSink {
|
|
65
|
+
private readonly positions: number[] = []
|
|
66
|
+
private readonly normals: number[] = []
|
|
67
|
+
|
|
68
|
+
constructor(private readonly field: TerrainField) {}
|
|
69
|
+
|
|
70
|
+
private push(v: Vertex): void {
|
|
71
|
+
this.positions.push(v.x, v.y, v.z)
|
|
72
|
+
const [nx, ny, nz] = normalAt(this.field, v.x, v.z)
|
|
73
|
+
this.normals.push(nx, ny, nz)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* One cell, given its four corners named by axis order. Winding matches
|
|
78
|
+
* `buildTerrainMesh`: `(p00, p01, p10)` and `(p10, p01, p11)` faces +Y, which is
|
|
79
|
+
* what the spawn resolver's `normal.dot(UP) > 0.2` floor test needs.
|
|
80
|
+
*/
|
|
81
|
+
quad(p00: Vertex, p10: Vertex, p01: Vertex, p11: Vertex): void {
|
|
82
|
+
this.push(p00)
|
|
83
|
+
this.push(p01)
|
|
84
|
+
this.push(p10)
|
|
85
|
+
this.push(p10)
|
|
86
|
+
this.push(p01)
|
|
87
|
+
this.push(p11)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
build(): THREE.BufferGeometry | null {
|
|
91
|
+
if (this.positions.length === 0) return null
|
|
92
|
+
const geometry = new THREE.BufferGeometry()
|
|
93
|
+
geometry.setAttribute(
|
|
94
|
+
'position',
|
|
95
|
+
new THREE.BufferAttribute(new Float32Array(this.positions), 3),
|
|
96
|
+
)
|
|
97
|
+
geometry.setAttribute('normal', new THREE.BufferAttribute(new Float32Array(this.normals), 3))
|
|
98
|
+
return geometry
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Terrain collision geometry in the site's own frame — the caller applies the
|
|
104
|
+
* site's world matrix, exactly as it did for the flat box it replaces.
|
|
105
|
+
*/
|
|
106
|
+
export function createTerrainColliderGeometry(field: TerrainField): THREE.BufferGeometry | null {
|
|
107
|
+
if (field.cols < 2 || field.rows < 2) return null
|
|
108
|
+
|
|
109
|
+
const stride = colliderStride(field)
|
|
110
|
+
const cols = sampleLine(field.cols, stride)
|
|
111
|
+
const rows = sampleLine(field.rows, stride)
|
|
112
|
+
const sink = new TriangleSink(field)
|
|
113
|
+
|
|
114
|
+
const xAt = (col: number) => field.origin[0] + col * field.spacing
|
|
115
|
+
const zAt = (row: number) => field.origin[1] + row * field.spacing
|
|
116
|
+
const at = (col: number, row: number): Vertex => ({
|
|
117
|
+
x: xAt(col),
|
|
118
|
+
y: heightAtSample(field, col, row),
|
|
119
|
+
z: zAt(row),
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
for (let ri = 0; ri < rows.length - 1; ri++) {
|
|
123
|
+
const r0 = rows[ri] as number
|
|
124
|
+
const r1 = rows[ri + 1] as number
|
|
125
|
+
for (let ci = 0; ci < cols.length - 1; ci++) {
|
|
126
|
+
const c0 = cols[ci] as number
|
|
127
|
+
const c1 = cols[ci + 1] as number
|
|
128
|
+
sink.quad(at(c0, r0), at(c1, r0), at(c0, r1), at(c1, r1))
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// The skirt: each border sample extruded outward at its own height, so stepping
|
|
133
|
+
// off the field lands on the border height rather than on nothing. This is the
|
|
134
|
+
// collider spelling of `heightAt`'s edge clamp.
|
|
135
|
+
const firstCol = 0
|
|
136
|
+
const lastCol = field.cols - 1
|
|
137
|
+
const firstRow = 0
|
|
138
|
+
const lastRow = field.rows - 1
|
|
139
|
+
const westX = xAt(firstCol) - SKIRT_REACH
|
|
140
|
+
const eastX = xAt(lastCol) + SKIRT_REACH
|
|
141
|
+
const northZ = zAt(firstRow) - SKIRT_REACH
|
|
142
|
+
const southZ = zAt(lastRow) + SKIRT_REACH
|
|
143
|
+
|
|
144
|
+
for (let ci = 0; ci < cols.length - 1; ci++) {
|
|
145
|
+
const c0 = cols[ci] as number
|
|
146
|
+
const c1 = cols[ci + 1] as number
|
|
147
|
+
const north0 = at(c0, firstRow)
|
|
148
|
+
const north1 = at(c1, firstRow)
|
|
149
|
+
sink.quad(
|
|
150
|
+
{ x: north0.x, y: north0.y, z: northZ },
|
|
151
|
+
{ x: north1.x, y: north1.y, z: northZ },
|
|
152
|
+
north0,
|
|
153
|
+
north1,
|
|
154
|
+
)
|
|
155
|
+
const south0 = at(c0, lastRow)
|
|
156
|
+
const south1 = at(c1, lastRow)
|
|
157
|
+
sink.quad(
|
|
158
|
+
south0,
|
|
159
|
+
south1,
|
|
160
|
+
{ x: south0.x, y: south0.y, z: southZ },
|
|
161
|
+
{
|
|
162
|
+
x: south1.x,
|
|
163
|
+
y: south1.y,
|
|
164
|
+
z: southZ,
|
|
165
|
+
},
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
for (let ri = 0; ri < rows.length - 1; ri++) {
|
|
170
|
+
const r0 = rows[ri] as number
|
|
171
|
+
const r1 = rows[ri + 1] as number
|
|
172
|
+
const west0 = at(firstCol, r0)
|
|
173
|
+
const west1 = at(firstCol, r1)
|
|
174
|
+
sink.quad(
|
|
175
|
+
{ x: westX, y: west0.y, z: west0.z },
|
|
176
|
+
west0,
|
|
177
|
+
{ x: westX, y: west1.y, z: west1.z },
|
|
178
|
+
west1,
|
|
179
|
+
)
|
|
180
|
+
const east0 = at(lastCol, r0)
|
|
181
|
+
const east1 = at(lastCol, r1)
|
|
182
|
+
sink.quad(east0, { x: eastX, y: east0.y, z: east0.z }, east1, {
|
|
183
|
+
x: eastX,
|
|
184
|
+
y: east1.y,
|
|
185
|
+
z: east1.z,
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Corner quads, flat at the corner sample's height — the diagonal region no edge
|
|
190
|
+
// strip covers, and where `heightAt` also reports the corner value.
|
|
191
|
+
const corners: Array<[Vertex, number, number]> = [
|
|
192
|
+
[at(firstCol, firstRow), westX, northZ],
|
|
193
|
+
[at(lastCol, firstRow), eastX, northZ],
|
|
194
|
+
[at(firstCol, lastRow), westX, southZ],
|
|
195
|
+
[at(lastCol, lastRow), eastX, southZ],
|
|
196
|
+
]
|
|
197
|
+
for (const [corner, farX, farZ] of corners) {
|
|
198
|
+
const minX = Math.min(corner.x, farX)
|
|
199
|
+
const maxX = Math.max(corner.x, farX)
|
|
200
|
+
const minZ = Math.min(corner.z, farZ)
|
|
201
|
+
const maxZ = Math.max(corner.z, farZ)
|
|
202
|
+
const y = corner.y
|
|
203
|
+
sink.quad(
|
|
204
|
+
{ x: minX, y, z: minZ },
|
|
205
|
+
{ x: maxX, y, z: minZ },
|
|
206
|
+
{ x: minX, y, z: maxZ },
|
|
207
|
+
{ x: maxX, y, z: maxZ },
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return sink.build()
|
|
212
|
+
}
|