mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
feat(navara): add finite visible-surface obstruction rays
Reuse retained tile triangles for CCTV's upcoming activation probe, with finite range, exclusions and honest misses. Correct nearest-hit ordering when the ray starts inside a mesh bound. Fifteen focused surface tests and all full keyless unit/allocation, boundary, format and build gates pass. CCTV renderer integration and existing acceptance gaps remain documented.
This commit is contained in:
1 parent
93c71ffc86
commit
798d6636ab
4 files changed
+200
-1
No files matched your search
@@ -1112,6 +1112,7 @@
|
||||
"src/globe/navara/sharpen.js",
|
||||
"src/globe/navara/surface.js",
|
||||
"src/globe/navara/surface.test.mjs",
|
||||
"src/globe/navara/surfaceRay.test.mjs",
|
||||
"src/globe/navara/viewOptions.js",
|
||||
"src/globe/navara/viewOptions.test.mjs",
|
||||
"src/globe/testing/contractSuite.mjs",
|
||||
|
||||
@@ -203,6 +203,11 @@
|
||||
* The position moved onto the rendered scene along the geodetic normal.
|
||||
* @property {boolean} clampToHeightSupported
|
||||
* @property {() => boolean} tilesLoaded
|
||||
* @property {boolean} [raycastSupported] Optional visible-tile obstruction probe.
|
||||
* @property {(ray: { origin: Cartesian3, direction: Cartesian3, far: number, exclude?: any[] }) => ({ position: Cartesian3, distance: number, object: any } | undefined)} [raycast]
|
||||
* Nearest visible tile triangle within finite positive `far` metres. Direction
|
||||
* is normalized by the adapter. Excluded roots/meshes and hidden objects do
|
||||
* not obstruct; a miss or unsupported geometry returns undefined, not terrain.
|
||||
*
|
||||
* @typedef {object} SurfaceHeight
|
||||
* @property {number} height Metres above the ellipsoid.
|
||||
|
||||
@@ -132,7 +132,14 @@ function nearestHit(meshes, roots, ray) {
|
||||
if (!geometry.boundingSphere) geometry.computeBoundingSphere();
|
||||
sphere.copy(geometry.boundingSphere).applyMatrix4(mesh.matrixWorld);
|
||||
if (!worldRay.intersectSphere(sphere, entry)) continue;
|
||||
candidates.push([worldRay.origin.distanceTo(entry), i]);
|
||||
// intersectSphere returns the exit when the origin is inside. Such a mesh
|
||||
// can contain the nearest triangle, so it must enter the candidate list at 0.
|
||||
candidates.push([
|
||||
sphere.containsPoint(worldRay.origin)
|
||||
? 0
|
||||
: worldRay.origin.distanceTo(entry),
|
||||
i,
|
||||
]);
|
||||
}
|
||||
candidates.sort((x, y) => x[0] - y[0]);
|
||||
let nearest = Infinity;
|
||||
@@ -283,8 +290,59 @@ export function createNavaraSurface(view, hooks) {
|
||||
};
|
||||
const sampleHeight = (place, exclude) =>
|
||||
sampleHeightDetailed(place, exclude ? { exclude } : undefined)?.height;
|
||||
// Activation-time obstruction probe: only the visible tile triangles, never
|
||||
// a fabricated terrain intersection or a second retained-geometry cache.
|
||||
const raycast = ({ origin, direction, far, exclude = [] }) => {
|
||||
if (
|
||||
!supported ||
|
||||
![
|
||||
origin?.x,
|
||||
origin?.y,
|
||||
origin?.z,
|
||||
direction?.x,
|
||||
direction?.y,
|
||||
direction?.z,
|
||||
far,
|
||||
].every(Number.isFinite) ||
|
||||
far <= 0
|
||||
)
|
||||
return undefined;
|
||||
const length = Math.hypot(direction.x, direction.y, direction.z);
|
||||
if (!length || !Number.isFinite(length)) return undefined;
|
||||
const ray = {
|
||||
origin,
|
||||
direction: {
|
||||
x: direction.x / length,
|
||||
y: direction.y / length,
|
||||
z: direction.z / length,
|
||||
},
|
||||
far,
|
||||
};
|
||||
const excluded = exclude.length ? new Set(exclude) : NO_EXCLUDE;
|
||||
shownMeshes.length = shownRoots.length = 0;
|
||||
let hit;
|
||||
try {
|
||||
for (const root of hooks.tileRoots())
|
||||
collectMeshes(root, excluded, true, shownMeshes, shownRoots, root);
|
||||
hit = nearestHit(shownMeshes, shownRoots, ray);
|
||||
} finally {
|
||||
shownMeshes.length = shownRoots.length = 0;
|
||||
}
|
||||
if (!Number.isFinite(hit.distance)) return undefined;
|
||||
return {
|
||||
distance: hit.distance,
|
||||
object: hit.root,
|
||||
position: {
|
||||
x: origin.x + ray.direction.x * hit.distance,
|
||||
y: origin.y + ray.direction.y * hit.distance,
|
||||
z: origin.z + ray.direction.z * hit.distance,
|
||||
},
|
||||
};
|
||||
};
|
||||
return {
|
||||
surface: {
|
||||
raycast,
|
||||
raycastSupported: supported,
|
||||
sampleHeight,
|
||||
sampleHeightDetailed,
|
||||
supported,
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
BackSide,
|
||||
DoubleSide,
|
||||
FrontSide,
|
||||
Group,
|
||||
Mesh,
|
||||
MeshBasicMaterial,
|
||||
PlaneGeometry,
|
||||
} from 'three';
|
||||
import { createNavaraSurface } from './surface.js';
|
||||
|
||||
function fixture(t, { distances = [5], sizes = [20], supported = true } = {}) {
|
||||
const tiles = distances.map((distance, i) => {
|
||||
const root = new Group();
|
||||
const geometry = new PlaneGeometry(sizes[i] ?? 20, sizes[i] ?? 20);
|
||||
const material = new MeshBasicMaterial({ side: DoubleSide });
|
||||
const mesh = new Mesh(geometry, material);
|
||||
root.position.set(6378137, 0, distance);
|
||||
root.add(mesh);
|
||||
root.updateMatrixWorld(true);
|
||||
t.after(() => {
|
||||
geometry.dispose();
|
||||
material.dispose();
|
||||
});
|
||||
return root;
|
||||
});
|
||||
let terrainQueries = 0;
|
||||
const { surface } = createNavaraSurface(
|
||||
{
|
||||
sampleTerrainHeight: () => {
|
||||
terrainQueries++;
|
||||
return 123;
|
||||
},
|
||||
},
|
||||
{
|
||||
tileRoots: () => tiles,
|
||||
isIdle: () => true,
|
||||
retainTileGeometry() {
|
||||
if (!supported) throw new Error('CPU geometry unavailable');
|
||||
},
|
||||
},
|
||||
);
|
||||
const ray = {
|
||||
origin: { x: 6378137, y: 0, z: 0 },
|
||||
direction: { x: 0, y: 0, z: 1 },
|
||||
far: 10,
|
||||
};
|
||||
return { surface, tiles, ray, terrainQueries: () => terrainQueries };
|
||||
}
|
||||
|
||||
test('surface ray returns the nearest real triangle in world metres', (t) => {
|
||||
const h = fixture(t, { distances: [8, 5] });
|
||||
const hit = h.surface.raycast({
|
||||
...h.ray,
|
||||
direction: { x: 0, y: 0, z: 7 },
|
||||
});
|
||||
assert.equal(hit.distance, 5);
|
||||
assert.deepEqual(hit.position, { x: 6378137, y: 0, z: 5 });
|
||||
assert.equal(hit.object, h.tiles[1]);
|
||||
assert.equal(h.terrainQueries(), 0);
|
||||
});
|
||||
|
||||
test('a ray starting inside a broad bound still finds its nearer triangle', (t) => {
|
||||
const h = fixture(t, { distances: [5, 2], sizes: [1, 200] });
|
||||
assert.equal(h.surface.raycast(h.ray).distance, 2);
|
||||
});
|
||||
|
||||
test('surface rays respect finite range, tile and child exclusions and visibility', (t) => {
|
||||
const h = fixture(t, { distances: [5, 8] });
|
||||
assert.equal(h.surface.raycast({ ...h.ray, far: 4 }), undefined);
|
||||
assert.equal(h.surface.raycast({ ...h.ray, far: 5 }).distance, 5);
|
||||
for (const excluded of [h.tiles[0], h.tiles[0].children[0]])
|
||||
assert.equal(
|
||||
h.surface.raycast({ ...h.ray, exclude: [excluded] }).distance,
|
||||
8,
|
||||
);
|
||||
h.tiles[0].visible = false;
|
||||
assert.equal(h.surface.raycast(h.ray).distance, 8);
|
||||
h.tiles[1].children[0].visible = false;
|
||||
assert.equal(h.surface.raycast(h.ray), undefined);
|
||||
assert.equal(h.terrainQueries(), 0);
|
||||
});
|
||||
|
||||
test('invalid and unsupported surface rays never invent terrain intersections', (t) => {
|
||||
const h = fixture(t);
|
||||
for (const far of [0, -1, NaN, Infinity])
|
||||
assert.equal(h.surface.raycast({ ...h.ray, far }), undefined);
|
||||
for (const direction of [
|
||||
{ x: 0, y: 0, z: 0 },
|
||||
{ x: NaN, y: 0, z: 1 },
|
||||
])
|
||||
assert.equal(h.surface.raycast({ ...h.ray, direction }), undefined);
|
||||
assert.equal(
|
||||
h.surface.raycast({ ...h.ray, origin: { x: Infinity, y: 0, z: 0 } }),
|
||||
undefined,
|
||||
);
|
||||
const missing = fixture(t, { supported: false });
|
||||
assert.equal(missing.surface.raycastSupported, false);
|
||||
assert.equal(missing.surface.raycast(missing.ray), undefined);
|
||||
assert.equal(missing.terrainQueries(), 0);
|
||||
});
|
||||
|
||||
test('surface rays work after UV/normal release and honor sides and draw ranges', (t) => {
|
||||
const h = fixture(t);
|
||||
const mesh = h.tiles[0].children[0];
|
||||
mesh.geometry.attributes.normal.array = null;
|
||||
mesh.geometry.attributes.uv.array = null;
|
||||
mesh.material.side = FrontSide;
|
||||
assert.equal(h.surface.raycast(h.ray), undefined);
|
||||
mesh.material.side = BackSide;
|
||||
assert.equal(h.surface.raycast(h.ray).distance, 5);
|
||||
mesh.geometry.setDrawRange(0, 0);
|
||||
assert.equal(h.surface.raycast(h.ray), undefined);
|
||||
mesh.geometry.setDrawRange(0, 6);
|
||||
const hit = h.surface.raycast({
|
||||
...h.ray,
|
||||
direction: { x: 1, y: 0, z: 1 },
|
||||
});
|
||||
assert.ok(Math.abs(hit.distance - Math.sqrt(50)) < 1e-9);
|
||||
assert.ok(Math.abs(hit.position.x - 6378142) < 1e-9);
|
||||
});
|
||||
|
||||
test('surface ray distances stay in world metres under mesh transforms', (t) => {
|
||||
const h = fixture(t);
|
||||
h.tiles[0].scale.set(2, 3, 2);
|
||||
h.tiles[0].children[0].position.z = 1;
|
||||
h.tiles[0].updateMatrixWorld(true);
|
||||
assert.equal(h.surface.raycast({ ...h.ray, far: 6 }), undefined);
|
||||
const first = h.surface.raycast(h.ray);
|
||||
assert.equal(first.distance, 7);
|
||||
h.surface.raycast({ ...h.ray, direction: { x: 1, y: 0, z: 1 } });
|
||||
assert.deepEqual(first.position, { x: 6378137, y: 0, z: 7 });
|
||||
});
|
||||
Reference in new issue
Block a user