diff --git a/scripts/format-scope.json b/scripts/format-scope.json index cad7d1a80..eb802e4b4 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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", diff --git a/src/globe/contract.js b/src/globe/contract.js index fc89aa2a9..3376f2579 100644 --- a/src/globe/contract.js +++ b/src/globe/contract.js @@ -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. diff --git a/src/globe/navara/surface.js b/src/globe/navara/surface.js index fa2fa54d7..68253bc98 100644 --- a/src/globe/navara/surface.js +++ b/src/globe/navara/surface.js @@ -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, diff --git a/src/globe/navara/surfaceRay.test.mjs b/src/globe/navara/surfaceRay.test.mjs new file mode 100644 index 000000000..0ed1629c2 --- /dev/null +++ b/src/globe/navara/surfaceRay.test.mjs @@ -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 }); +});