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:
German Khokhlov committed 2026-10-29 00:13:23 +04:00
1 parent 93c71ffc86
commit 798d6636ab
4 files changed
+200 -1

No files matched your search

+1
View File
@@ -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",
+5
View File
@@ -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.
+59 -1
View File
@@ -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,
+135
View File
@@ -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 });
});