Keep Navara's 3D Tiles where the tileset puts them

Navara's 3D Tiles source defaults to model.height 1, which moves a
transformed tileset by a metre along Earth-fixed X: on the synthetic set
flat roofs read 0.12 m high and a sloping roof 0.68 m off, and the drawn
meshes move with them. The adapter sets the height to 0 and records the
package behaviour in WORKAROUNDS.md, with a test that loads the real
package. The downward surface ray now takes its direction from the
place's own normal, so it no longer drifts sideways.

qa-tiles-fixture gains --report (heights and drape pixels per engine,
without asserting), starts on Cesium, and checks each drape hidden, shown
and hidden again; qa-surface-heights uses the plan's 0.5 m tolerance under
tiles and names the point that misses.
This commit is contained in:
German Khokhlov committed 2026-11-10 17:16:23 +04:00
1 parent 3c304502a0
commit 00153acbeb
10 files changed
+430 -97

No files matched your search

+10 -32
View File
@@ -23,6 +23,8 @@ import {
fixtureRoutes,
observeSurfaceMovers,
settledPositions,
assertTileHeight,
waitForSurfaceHeights,
} from './qa/surfaceHeights.mjs';
export function compareHeights(before, after, { tiles = false } = {}) {
@@ -82,7 +84,7 @@ export function compareHeights(before, after, { tiles = false } = {}) {
!missing.length &&
!extra.length &&
!invalid.length &&
maxAbsM <= 0.1 + 1e-9,
maxAbsM <= (tiles ? 0.5 : 0.1) + 1e-9,
});
}
}
@@ -237,32 +239,7 @@ async function main(argv) {
},
);
await whenSettled(page);
await page.waitForFunction(
({ points, tiles }) => {
const { engine } = window.__godsEyeView;
engine.render.request('qa-surface-heights');
return (
engine.surface.tilesLoaded() &&
engine.maps.photorealActive() === tiles &&
points.every((p) => {
const place = {
longitude: (p.lon * Math.PI) / 180,
latitude: (p.lat * Math.PI) / 180,
height: 0,
};
const height = tiles
? engine.surface.sampleHeight(place)
: engine.surface.terrainHeight(place);
return (
Number.isFinite(height) &&
(tiles ? Math.abs(height - 50) < 0.1 : height > 1)
);
})
);
},
{ timeout: 90000 },
{ points, tiles },
);
await waitForSurfaceHeights(page, kind, tiles);
await page.evaluate(observeSurfaceMovers);
snapshot.engines[kind] = {};
for (const layer of layers) {
@@ -285,9 +262,10 @@ async function main(argv) {
snapshot.engines[kind][layer] = values;
if (tiles)
for (const [id, p] of Object.entries(values))
assert.ok(
Math.abs(p.heightM - (50 + offsets[layer])) <= 0.1,
`${kind}/${layer}/${id}: ${p.heightM} m, expected ${50 + offsets[layer]} m`,
assertTileHeight(
p.heightM,
50 + offsets[layer],
`${kind}/${layer}/${id}`,
);
await strict.passed(
page,
@@ -296,7 +274,7 @@ async function main(argv) {
values,
);
console.log(
`PASS ${kind}/${layer}: ${rows.length} points${tiles ? ` at ${50 + offsets[layer]} m (±0.1 m)` : ''}`,
`PASS ${kind}/${layer}: ${rows.length} points${tiles ? ` at ${50 + offsets[layer]} m (±0.5 m)` : ''}`,
);
await unloadDisplay(page, layer);
}
@@ -316,7 +294,7 @@ async function main(argv) {
);
assert.ok(
comparison.ok,
'Surface heights differ by more than 0.1 m or the point sets changed',
`Surface heights differ by more than ${tiles ? 0.5 : 0.1} m or the point sets changed`,
);
}
console.log(
+73
View File
@@ -6,6 +6,9 @@ import {
observeSurfaceMovers,
fixtureRoutes,
center,
points,
assertTileHeight,
waitForSurfaceHeights,
} from './qa/surfaceHeights.mjs';
import { createFakeEngine } from '../src/globe/testing/fakeEngine.js';
import { decodeAlprTile } from '../src/layers/alpr/tileRecords.js';
@@ -94,6 +97,76 @@ test('tiles require explicit opt-in and cannot be compared with terrain', () =>
assert.equal(compareHeights(tiles, tiles, { tiles: true }).ok, true);
});
test('tile heights and tile comparisons use 0.5 m while terrain keeps 0.1 m', () => {
const before = { ...snapshot(), surface: 'tiles' };
for (const delta of [0.118, 0.5, -0.5]) {
assertTileHeight(50 + delta, 50, 'navara/surface-0');
const after = { ...snapshot(50 + delta), surface: 'tiles' };
assert.equal(compareHeights(before, after, { tiles: true }).ok, true);
assert.equal(compareHeights(snapshot(), snapshot(50 + delta)).ok, false);
}
for (const height of [50.5001, 49.4999, null, NaN, Infinity])
assert.throws(
() => assertTileHeight(height, 50, 'navara/surface-1'),
/navara\/surface-1: .* m, expected 50 m \(±0.5 m\)/,
);
const after = { ...snapshot(50.5001), surface: 'tiles' };
assert.equal(compareHeights(before, after, { tiles: true }).ok, false);
});
test('loaded incorrect tile heights fail immediately with the point and measured height', async (t) => {
const previous = globalThis.window;
t.after(() => {
if (previous === undefined) delete globalThis.window;
else globalThis.window = previous;
});
const engine = createFakeEngine();
t.after(() => engine.destroy());
globalThis.window = { __godsEyeView: { engine } };
engine.maps.photorealActive = () => true;
let height = 50.118;
engine.surface.sampleHeight = () => height;
const dispose = t.mock.fn();
const page = {
async waitForFunction(fn, options, args) {
const state = fn(args);
assert.ok(
state,
'finite loaded values return without waiting for tolerance',
);
return { jsonValue: async () => state, dispose };
},
};
await waitForSurfaceHeights(page, 'navara', true);
height = 51.25;
await assert.rejects(
waitForSurfaceHeights(page, 'navara', true),
/navara\/surface-0 sampleHeight: 51.25 m, expected 50 m/,
);
assert.equal(dispose.mock.callCount(), 2);
});
test('unavailable surface timeout reports all points and readiness state', async () => {
const cause = new Error('timeout');
const page = {
waitForFunction: async () => {
throw cause;
},
evaluate: async () => ({
loaded: false,
photoreal: true,
points: points.map((p) => ({ id: p.id, height: null })),
}),
};
await assert.rejects(waitForSurfaceHeights(page, 'navara', true), (error) => {
assert.equal(error.cause, cause);
for (const p of points)
assert.ok(error.message.includes(`${p.id}: null m`));
assert.match(error.message, /loaded=false, photoreal=true/);
return true;
});
});
test('the probe reads committed contract positions, snapshots mutable inputs and drops destroyed sets', () => {
const engine = createFakeEngine();
const original = globalThis.window;
+120 -58
View File
@@ -13,10 +13,11 @@ import {
} from './qa-strict-harness.mjs';
const base = localPreview(process.argv[2]);
const report = process.argv.includes('--report');
const { extras: fixture } = JSON.parse(
readFileSync(new URL('./fixtures/tiles3d/tileset.json', import.meta.url)),
);
base.search = `?engine=navara&welcome=0&qaTileset=${encodeURIComponent(TILES_FIXTURE_PATH)}`;
base.search = `?engine=cesium&welcome=0&qaTileset=${encodeURIComponent(TILES_FIXTURE_PATH)}`;
base.hash = `v=2&lat=${fixture.origin.lat}&lon=${fixture.origin.lon}&alt=1100&heading=0&pitch=-90&roll=0&map=photoreal&l=`;
let releaseTiles;
let pendingTiles;
@@ -51,8 +52,8 @@ await strict.run({}, async (browser) => {
await page.setCacheEnabled(false);
let readsBefore = { ...strict.ledger.routes };
await page.goto(base.href, { waitUntil: 'domcontentloaded' });
for (const kind of ['navara', 'cesium']) {
if (kind === 'cesium') {
for (const kind of ['cesium', 'navara']) {
if (kind === 'navara') {
readsBefore = { ...strict.ledger.routes };
holdTiles();
await strict.pick(page, kind, { settle: false });
@@ -95,60 +96,93 @@ await strict.run({}, async (browser) => {
);
await loaded(page);
await waitForCameraAtRest(page);
for (const route of routes.slice(0, 5))
assert.ok(
strict.ledger.routes[route.pattern] > (readsBefore[route.pattern] ?? 0),
`${kind} intercepted ${new URL(route.pattern).pathname}`,
);
if (!report)
for (const route of routes.slice(0, 5))
assert.ok(
strict.ledger.routes[route.pattern] >
(readsBefore[route.pattern] ?? 0),
`${kind} intercepted ${new URL(route.pattern).pathname}`,
);
await strict.passed(
page,
'osm-places',
'tilesLoaded becomes true; all five files served by same-origin routes',
report
? 'fixture tiles ready for measurement'
: 'tilesLoaded becomes true; all five files served by same-origin routes',
);
const heights = await page.evaluate(
({ probes, clampStart }) => {
const readings = await page.evaluate(
({ probes, clampStarts }) => {
const { surface } = window.__godsEyeView.engine;
return {
sampled: Object.fromEntries(
Object.entries(probes).map(([id, p]) => [
id,
surface.sampleHeight(p.cartographic),
]),
),
clamped: surface.clampToHeight(clampStart),
};
return Object.fromEntries(
Object.entries(probes).map(([id, p]) => [
id,
{
sampled: surface.sampleHeight(p.cartographic),
clamped: surface.clampToHeight(clampStarts[id]),
},
]),
);
},
{
probes: fixture.probes,
clampStart: geo.cartesian3.fromDegrees(
fixture.probes.roof.lon,
fixture.probes.roof.lat,
fixture.probes.roof.height + 300,
clampStarts: Object.fromEntries(
Object.entries(fixture.probes).map(([id, p]) => [
id,
geo.cartesian3.fromDegrees(p.lon, p.lat, p.height + 300),
]),
),
},
);
for (const [id, p] of Object.entries(fixture.probes))
assert.ok(
Math.abs(heights.sampled[id] - p.height) <= 0.5,
`${kind} ${id}: ${JSON.stringify(heights)}`,
const heights = Object.fromEntries(
Object.entries(readings).map(([id, reading]) => {
const p = fixture.probes[id];
return [
id,
{
expectedHeightM: p.height,
sampledHeightM: reading.sampled ?? null,
clampedHeightM: reading.clamped
? geo.cartographic.fromCartesian(reading.clamped).height
: null,
clampDistanceM: reading.clamped
? geo.cartesian3.distance(reading.clamped, p.position)
: null,
},
];
}),
);
console.log(
`${report ? 'REPORT' : 'HEIGHTS'} ${kind} ${JSON.stringify(heights)}`,
);
if (kind === 'navara')
console.log(
'NOTE navara: the default 3D Tiles height offset is a known package limitation; the adapter sets model.height=0 (height tolerance ±0.5 m).',
);
assert.ok(
Math.abs(heights.sampled.block50 - heights.sampled.plate - 50) <= 0.5,
);
assert.ok(
heights.clamped &&
geo.cartesian3.distance(
heights.clamped,
fixture.probes.roof.position,
if (!report) {
for (const [id, p] of Object.entries(heights)) {
assert.ok(
Number.isFinite(p.sampledHeightM) &&
Math.abs(p.sampledHeightM - p.expectedHeightM) <= 0.5,
`${kind} ${id}: ${JSON.stringify(p)}`,
);
assert.ok(
Number.isFinite(p.clampDistanceM) && p.clampDistanceM <= 0.5,
`${kind} ${id} clamp: ${JSON.stringify(p)}`,
);
}
assert.ok(
Math.abs(
heights.block50.sampledHeightM - heights.plate.sampledHeightM - 50,
) <= 0.5,
);
await strict.passed(
page,
'osm-places',
'known plate, 50 m, 200 m and sloping roof heights; clamp lands on roof',
heights,
);
);
await strict.passed(
page,
'osm-places',
'known plate, 50 m, 200 m and sloping roof heights; clamps land on each surface',
heights,
);
}
const drapes = [];
for (const surface of ['tiles', 'terrain']) {
@@ -159,6 +193,7 @@ await strict.run({}, async (browser) => {
await engine.maps.setTerrain({ kind: 'ellipsoid' });
});
await loaded(page);
await waitForCameraAtRest(page);
}
const p = fixture.probes.block50;
const position = geo.cartesian3.fromDegrees(
@@ -166,7 +201,6 @@ await strict.run({}, async (browser) => {
p.lat,
surface === 'tiles' ? p.height : 0,
);
const baseline = await pixels(page, position);
for (const drape of ['tiles', 'terrain']) {
await page.evaluate(
({ lon, lat, drape }) => {
@@ -199,19 +233,41 @@ await strict.run({}, async (browser) => {
},
{ timeout: 30000 },
);
// Build first, then compare the same ready primitive hidden/shown.
// Map imagery and classification shaders may still be arriving before ready.
await page.evaluate(() => window.qaDrape.show(false));
await loaded(page);
const baseline = await pixels(page, position);
await page.evaluate(() => window.qaDrape.show(true));
const rgb = await pixels(page, position);
await page.evaluate(() => window.qaDrape.show(false));
const restored = await pixels(page, position);
const drawn = rgb[0] > 180 && rgb[1] < 80 && rgb[2] > 180;
assert.equal(
drawn,
surface === drape,
`${kind} ${drape} on ${surface}: ${rgb}`,
const sample = { surface, drape, baseline, rgb, restored, drawn };
drapes.push(sample);
console.log(
`${report ? 'REPORT' : 'DRAPE'} ${kind} ${JSON.stringify(sample)}`,
);
if (surface !== drape)
if (!report) {
assert.ok(
rgb.every((v, i) => Math.abs(v - baseline[i]) < 15),
'other surface stays unchanged',
!(baseline[0] > 180 && baseline[1] < 80 && baseline[2] > 180),
`${kind} hidden drape is already magenta: ${JSON.stringify(sample)}`,
);
drapes.push({ surface, drape, baseline, rgb });
assert.ok(
baseline.every((v, i) => Math.abs(v - restored[i]) < 15),
`${kind} unstable drape baseline: ${JSON.stringify(sample)}`,
);
assert.equal(
drawn,
surface === drape,
`${kind} ${drape} on ${surface}: ${JSON.stringify(sample)}`,
);
if (surface !== drape)
assert.ok(
rgb.every((v, i) => Math.abs(v - baseline[i]) < 15),
`${kind} ${drape} on ${surface} changes the other surface: ${JSON.stringify(sample)}`,
);
}
} finally {
await page.evaluate(() => {
window.qaDrape.destroy();
@@ -223,21 +279,26 @@ await strict.run({}, async (browser) => {
await strict.passed(
page,
'osm-places',
'drape tiles and terrain draw only on their respective surface (pixels)',
drapes,
report
? 'diagnostic height and drape measurements collected; values not asserted'
: 'drape tiles and terrain draw only on their respective surface (pixels)',
{ heights, drapes },
);
await page.evaluate(() =>
window.__godsEyeView.mapStackController.setStack('photoreal'),
);
await loaded(page);
await waitForCameraAtRest(page);
await checkPoints(page, kind);
if (!report) await checkPoints(page, kind);
await assertKeylessPreview(page);
console.log(
`PASS ${kind} fixture heights, clamp, drapes, tilesLoaded and surface-points budget`,
report
? `REPORT ${kind} complete (measurements only)`
: `PASS ${kind} fixture heights, clamp, drapes, tilesLoaded and surface-points budget`,
);
}
assert.deepEqual(errors, []);
if (report) console.log(`REPORT browser errors ${JSON.stringify(errors)}`);
else assert.deepEqual(errors, []);
assert.deepEqual(
Object.keys(strict.ledger.external).filter((host) =>
/google|gstatic|cesium\.com/i.test(host),
@@ -270,6 +331,7 @@ async function pixels(page, position) {
off();
const point = engine.projector.worldToWindow(position);
const scale = engine.host.canvas.width / engine.host.size().width;
context.clearRect(0, 0, 5, 5);
context.drawImage(
engine.host.canvas,
Math.round(point.x * scale) - 2,
@@ -384,7 +446,7 @@ async function checkPoints(page, kind) {
engine.movers.createSet = createSet;
target[method] = render;
};
layer.attachRenderer(viewer, { engine });
layer.attachRenderer({ engine });
layer.setResults(
points.map((p, i) => ({ id: String(i), lon: p.lon, lat: p.lat })),
{ kind: 'fixture', label: 'Roof points', area: 'Austin' },
+151 -1
View File
@@ -2,14 +2,17 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { Cesium3DTileset } from 'cesium';
import { Core, initSync } from '@navaramap/engine';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { Raycaster, Vector3 } from 'three';
import { Group, Raycaster, Vector3 } from 'three';
import * as geo from '../src/geo/index.js';
import { buildTilesFixture } from './build-tiles3d-fixture.mjs';
import { localTilesetPath, qaTilesetUrl } from '../src/maps/qaTileset.js';
import { createDefaultMapSources } from '../src/maps/defaultSources.js';
import { MapSourceController } from '../src/maps/controller.js';
import { createFakeEngine } from '../src/globe/testing/fakeEngine.js';
import { createNavaraMaps } from '../src/globe/navara/maps.js';
import { createNavaraSurface } from '../src/globe/navara/surface.js';
import {
assertKeylessPreview,
TILES_FIXTURE_PATH,
@@ -78,6 +81,153 @@ test('independent glTF loader and triangle rays recover the authored heights', a
}
});
// Exercise the installed WASM tile loader, then the same glTF transforms
// consumed by Three's renderModel/setTransform path, without WebGL or fetch.
async function loadedNavaraTiles(t, adapter) {
initSync({
module: readFileSync(
new URL(
'../node_modules/@navaramap/engine/navara_wasm_bg.wasm',
import.meta.url,
),
),
});
const files = buildTilesFixture();
const fixture = JSON.parse(files.get('tileset.json'));
const core = new Core('tiles-fixture');
t.after(() => core.free());
core.resize(1280, 800, 1);
core.setFrustum(60, 1, 1e8);
core.start();
core.changeCamera(
new Float64Array([
fixture.extras.origin.lon,
fixture.extras.origin.lat,
1100,
]),
-90,
0,
0,
);
const view = {
addSource: (desc) => {
const id = core.addSource(desc);
return { id, delete: () => core.deleteSource(id) };
},
addLayer: (desc) => {
const id = core.addLayer({ ...desc, source: desc.source.id });
return { id, delete: () => core.deleteLayer(id) };
},
forceUpdate() {},
};
if (adapter) {
globals(t, { location: new URL('http://localhost:4303') });
const owner = createNavaraMaps(view, {
setInterval: () => 1,
clearInterval() {},
});
await owner.maps.setPhotorealTiles({ tilesetUrl: TILES_FIXTURE_PATH });
} else {
const source = view.addSource({
type: '3d-tiles',
url: 'http://localhost:4303/tileset.json',
});
view.addLayer({ type: '3d-tiles', source, model: { maxSse: 16 } });
}
const roots = [];
for (let frame = 0; frame < 60 && roots.length < 4; frame++) {
core.update(frame * 16);
const events = core.readEvents();
if (!events) continue;
for (const request of events.take_data_requested()) {
const bytes = files.get(new URL(request.url).pathname.split('/').at(-1));
assert.ok(bytes, `unexpected fixture request: ${request.url}`);
core.setBufferU8(request.handle, request.bits, bytes.length, (buffer) =>
buffer.set(bytes),
);
}
for (const event of events.take_renderable_feature_added()) {
const model = event.feature.model;
if (!model) continue;
const bytes = core.getBufferU8(model.bin).slice();
const gltf = await new GLTFLoader().parseAsync(bytes.buffer, '');
const root = new Group();
const m = model.transform;
root.position.set(m.tx, m.ty, m.tz);
root.quaternion.set(m.qx, m.qy, m.qz, m.qw);
root.scale.set(m.sx, m.sy, m.sz);
root.add(gltf.scene);
root.updateMatrixWorld(true);
roots.push(root);
core.markFeatureIsRendered('model', event.bits);
}
}
assert.equal(roots.length, 4, 'all four real tile contents loaded');
const { surface } = createNavaraSurface(
{ sampleTerrainHeight: () => undefined },
{ tileRoots: () => roots, isIdle: () => true, retainTileGeometry() {} },
);
t.after(() => {
for (const root of roots)
root.traverse((object) => {
object.geometry?.dispose();
object.material?.dispose();
});
});
return { fixture, roots, surface };
}
test('Navara default tile height shifts the rendered transform by one ECEF metre', async (t) => {
const { fixture, roots, surface } = await loadedNavaraTiles(t, false);
for (const root of roots) {
assert.equal(root.position.x, fixture.root.transform[12] - 1);
assert.equal(root.position.y, fixture.root.transform[13]);
assert.equal(root.position.z, fixture.root.transform[14]);
}
for (const [id, expected] of Object.entries({
plate: 0.11864,
block50: 50.11837,
block200: 200.11864,
roof: 83.32621,
}))
assert.ok(
Math.abs(
surface.sampleHeight(fixture.extras.probes[id].cartographic) - expected,
) < 0.001,
id,
);
});
test('Navara map source preserves authored rendered vertices, sampled heights and clamps', async (t) => {
const { fixture, roots, surface } = await loadedNavaraTiles(t, true);
for (const root of roots)
assert.deepEqual(
root.position.toArray(),
fixture.root.transform.slice(12, 15),
);
for (const p of [
...Object.values(fixture.extras.probes),
...fixture.extras.points,
]) {
const normal = geo.ellipsoid.WGS84.geodeticSurfaceNormalCartographic(
p.cartographic,
{},
);
const origin = geo.cartesian3.fromDegrees(p.lon, p.lat, p.height + 300);
const ray = new Raycaster(
new Vector3(origin.x, origin.y, origin.z),
new Vector3(-normal.x, -normal.y, -normal.z),
);
const hit = ray.intersectObjects(roots, true)[0];
assert.ok(hit, 'rendered mesh is under the authored probe');
assert.ok(geo.cartesian3.distance(hit.point, p.position) < 1e-6);
assert.ok(Math.abs(surface.sampleHeight(p.cartographic) - p.height) < 1e-6);
assert.ok(
geo.cartesian3.distance(surface.clampToHeight(origin), p.position) < 1e-6,
);
}
});
test('QA URL switch is inert by default and rejects nonlocal or malformed paths', (t) => {
const location = new URL('http://localhost:4303/');
globals(t, { location });
+59
View File
@@ -1,3 +1,4 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { cameraTile } from '../fixtures/layers/alpr.mjs';
import { tilesFixtureRoutes } from '../qa-strict-harness.mjs';
@@ -36,6 +37,64 @@ export const counts = {
story: 1,
};
export function assertTileHeight(height, expected, label) {
assert.ok(
Number.isFinite(height) && Math.abs(height - expected) <= 0.5 + 1e-9,
`${label}: ${height} m, expected ${expected} m (±0.5 m)`,
);
}
export async function waitForSurfaceHeights(page, kind, tiles) {
const handle = await page
.waitForFunction(
({ points, tiles }) => {
const { engine } = window.__godsEyeView;
engine.render.request('qa-surface-heights');
const state = {
loaded: engine.surface.tilesLoaded(),
photoreal: engine.maps.photorealActive(),
points: points.map((p) => {
const place = {
longitude: (p.lon * Math.PI) / 180,
latitude: (p.lat * Math.PI) / 180,
height: 0,
};
const height = tiles
? engine.surface.sampleHeight(place)
: engine.surface.terrainHeight(place);
return { id: p.id, height: height ?? null };
}),
};
window.qaSurfaceHeights = state;
// Readiness must not wait for an incorrect height to become correct.
return state.loaded &&
state.photoreal === tiles &&
state.points.every(
(p) => Number.isFinite(p.height) && (tiles || p.height > 1),
)
? state
: false;
},
{ timeout: 90000 },
{ points, tiles },
)
.catch(async (cause) => {
const state = await page.evaluate(() => window.qaSurfaceHeights);
throw new Error(
`${kind} surface readiness failed: ${state?.points.map((p) => `${p.id}: ${p.height} m`).join(', ') ?? 'no samples'}; loaded=${state?.loaded}, photoreal=${state?.photoreal}`,
{ cause },
);
});
try {
const state = await handle.jsonValue();
if (tiles)
for (const p of state.points)
assertTileHeight(p.height, 50, `${kind}/${p.id} sampleHeight`);
} finally {
await handle.dispose();
}
}
export const features = points.map(({ id, lon, lat }) => ({
type: 'Feature',
id,
+1
View File
@@ -46,6 +46,7 @@ is fixed. Neither excluded check is a signal to remove a workaround.
| `src/globe/navara/movers/batchIds.js:11` | Unregistering pickable wrappers does not recycle their global batch IDs. | 0.2.1 | `batch-ids-retained` (browser, confirmed) checks disjoint allocations after unregistering; this measures ID reuse, not WASM byte reclamation. | Remove `createBatchIdPool` and pool wiring in `movers/index.js`. |
| `src/globe/navara/ground/index.js:145` | The original native-vector path lost the first `show: false` while a new layer was being built. Current ground rendering uses its own pass, so the hide retry is already absent. | 0.2.1 (original path) | No canary: `initial-vector-hide-dropped` timed out before a verdict; the shared readiness error does not identify the failed wait. Requires browser diagnosis. | No current hide retry to delete. Do not remove the ground pass: it also implements draping and materials. |
| `src/globe/navara/maps.js:76` | Idle can be reported before a fetched raster tile arrives after a map switch; the map may keep a stale frame. | 0.2.1 | `idle-before-raster-arrives` (browser, confirmed) holds real raster requests across the idle event, then releases local PNG responses. | Remove the `SETTLE_MS` / `SETTLE_EVERY_MS` redraw timer from `changed`. |
| `src/globe/navara/maps.js` (`setPhotorealTiles`) | The default `model.height=1` displaces transformed 3D Tiles by −1 m in ECEF X on the synthetic ENU fixture; both rendered meshes and CPU queries inherit it. Set `model.height: 0` to preserve the tileset transform. | 0.2.1 | `scripts/qa-tiles-fixture.test.mjs` loads the real WASM package: default transform reproduces the measured flat-top +0.118 m / roof −0.676 m errors; the adapter restores all probes and 40 roof points within 1 µm against independent triangle rays. `qa-tiles-fixture --report` prints browser samples without asserting them. | Remove the explicit zero only when the package's default preserves authored tile positions; retain the authored-geometry regression test. |
| `src/globe/navara/maps.js:145` | Removing the final raster layer interrupts terrain loading at the current view. | 0.2.1 | No canary: needs terrain-loading continuity across final-layer removal; the raster idle fixture does not test terrain. | Remove the add-before-delete constraint and hidden base-raster retention under 3D tiles. |
| `src/globe/navara/maps.js:37`, `src/globe/navara/maps.js:209` | Quantized-mesh root URLs without the tile template can panic. | 0.2.1 | No canary: deliberately panicking WASM would invalidate the remaining view checks; no isolated terrain fixture here. | Use the upstream root-URL API instead of manually appending `{z}/{x}/{y}.terrain`. |
| `src/globe/navara/maps.js:65` | No ion imagery path. | 0.2.1 | No canary: explicit unsupported source; requires a real ion imagery endpoint. | Replace the ion-imagery rejection with native source support. |
+2 -1
View File
@@ -299,7 +299,8 @@ export function createNavaraMaps(
'photoreal',
url,
{ type: '3d-tiles', url },
{ type: '3d-tiles', model: { maxSse: PHOTOREAL_MAX_SSE } },
// Navara's default height shifts transformed tiles along ECEF X.
{ type: '3d-tiles', model: { maxSse: PHOTOREAL_MAX_SSE, height: 0 } },
tilesetUrl
? []
: [
+1 -1
View File
@@ -193,7 +193,7 @@ test('setPhotorealTiles shows Google 3D tiles with a browser key and the Google
tiles.desc.source.desc.url,
'https://tile.googleapis.com/v1/3dtiles/root.json?key=browser%20key',
);
assert.deepEqual(tiles.desc.model, { maxSse: 16 });
assert.deepEqual(tiles.desc.model, { maxSse: 16, height: 0 });
// Hidden, not deleted: Navara 0.2.1 stops loading terrain at the current
// view when the last raster layer is deleted.
const [base] = layersOf(view, 'raster');
+7 -4
View File
@@ -2,9 +2,9 @@
// straight down the ellipsoid normal against the 3D-tile meshes Navara shows.
// Where the shown tiles do not reach (outside the frustum), the loaded
// tiles Navara hides answer, the finest first. Tile positions are float32
// relative to the tile centre, which sits in the
// float64 matrixWorld with no rotation, so a ray taken into a mesh's local
// frame gives Earth-fixed metres. Navara frees the CPU arrays after upload;
// in the mesh's local frame; its float64 matrixWorld maps hits back into
// Earth-fixed metres, including any tileset rotation or translation.
// Navara frees the CPU arrays after upload;
// retainTileGeometry (internals.js) keeps positions and indices. Without a
// tile hit, the answer is Navara's terrain height, as Cesium's sampleHeight
// also samples the globe. Navara internals come in through `hooks`, so this
@@ -37,7 +37,10 @@ export function surfaceRay(place) {
RAY_START_HEIGHT,
WGS84,
);
const normal = WGS84.geodeticSurfaceNormal(origin, cartesian3.create());
const normal = WGS84.geodeticSurfaceNormalCartographic(
place,
cartesian3.create(),
);
return {
origin,
direction: cartesian3.negate(normal, cartesian3.create()),
+6
View File
@@ -84,6 +84,12 @@ test('surfaceRay runs down the ellipsoid normal from 12 km', () => {
cartesian3.create(),
);
near(cartographic.fromCartesian(below, WGS84).height, 0, 1e-6, 'ground');
near(
cartesian3.distance(below, cartesian3.fromDegrees(20, 10, 0)),
0,
1e-6,
'the ray keeps longitude and latitude at the surface',
);
assert.equal(heightAlongRay(11950), 50);
assert.equal(heightAlongRay(null), undefined);
});