mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +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/sharpen.js",
|
||||||
"src/globe/navara/surface.js",
|
"src/globe/navara/surface.js",
|
||||||
"src/globe/navara/surface.test.mjs",
|
"src/globe/navara/surface.test.mjs",
|
||||||
|
"src/globe/navara/surfaceRay.test.mjs",
|
||||||
"src/globe/navara/viewOptions.js",
|
"src/globe/navara/viewOptions.js",
|
||||||
"src/globe/navara/viewOptions.test.mjs",
|
"src/globe/navara/viewOptions.test.mjs",
|
||||||
"src/globe/testing/contractSuite.mjs",
|
"src/globe/testing/contractSuite.mjs",
|
||||||
|
|||||||
@@ -203,6 +203,11 @@
|
|||||||
* The position moved onto the rendered scene along the geodetic normal.
|
* The position moved onto the rendered scene along the geodetic normal.
|
||||||
* @property {boolean} clampToHeightSupported
|
* @property {boolean} clampToHeightSupported
|
||||||
* @property {() => boolean} tilesLoaded
|
* @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
|
* @typedef {object} SurfaceHeight
|
||||||
* @property {number} height Metres above the ellipsoid.
|
* @property {number} height Metres above the ellipsoid.
|
||||||
|
|||||||
@@ -132,7 +132,14 @@ function nearestHit(meshes, roots, ray) {
|
|||||||
if (!geometry.boundingSphere) geometry.computeBoundingSphere();
|
if (!geometry.boundingSphere) geometry.computeBoundingSphere();
|
||||||
sphere.copy(geometry.boundingSphere).applyMatrix4(mesh.matrixWorld);
|
sphere.copy(geometry.boundingSphere).applyMatrix4(mesh.matrixWorld);
|
||||||
if (!worldRay.intersectSphere(sphere, entry)) continue;
|
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]);
|
candidates.sort((x, y) => x[0] - y[0]);
|
||||||
let nearest = Infinity;
|
let nearest = Infinity;
|
||||||
@@ -283,8 +290,59 @@ export function createNavaraSurface(view, hooks) {
|
|||||||
};
|
};
|
||||||
const sampleHeight = (place, exclude) =>
|
const sampleHeight = (place, exclude) =>
|
||||||
sampleHeightDetailed(place, exclude ? { exclude } : undefined)?.height;
|
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 {
|
return {
|
||||||
surface: {
|
surface: {
|
||||||
|
raycast,
|
||||||
|
raycastSupported: supported,
|
||||||
sampleHeight,
|
sampleHeight,
|
||||||
sampleHeightDetailed,
|
sampleHeightDetailed,
|
||||||
supported,
|
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