Files
gods-eye-view/scripts/build-tiles3d-fixture.mjs
T
German Khokhlov e9ba5e9859 Check the photoreal paths in the browser on a synthetic 3D Tiles set
A generated tileset (a plate at ellipsoid level, 50 m and 200 m blocks and
a pitched roof, 27 KB) replaces Google's tiles when a QA page passes
?qaTileset=/path; only a root-relative path on the page origin is
accepted, and the Google credit is not shown for it. The strict harness
serves the set from disk, so nothing reaches Google.

qa-tiles-fixture checks on both engines that sampleHeight reads the block
at 50 m, clampToHeight lands on the roof, tiles and terrain drapes draw
where they should, tilesLoaded turns true, surface points land on the
roof within their budget, and pick renders per pass stay within it.
2026-11-10 15:49:52 +04:00

258 lines
6.8 KiB
JavaScript

import assert from 'node:assert/strict';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { pathToFileURL } from 'node:url';
import * as geo from '../src/geo/index.js';
const directory = new URL('./fixtures/tiles3d/', import.meta.url);
const origin = { lon: -97.7431, lat: 30.2672 };
const transform = geo.transforms.eastNorthUpToFixedFrame(
geo.cartesian3.fromDegrees(origin.lon, origin.lat),
);
const box = (west, south, east, north, bottom, top) => [
[
[west, south, top],
[east, south, top],
[east, north, top],
[west, north, top],
],
[
[west, north, bottom],
[east, north, bottom],
[east, south, bottom],
[west, south, bottom],
],
[
[west, south, bottom],
[east, south, bottom],
[east, south, top],
[west, south, top],
],
[
[east, south, bottom],
[east, north, bottom],
[east, north, top],
[east, south, top],
],
[
[east, north, bottom],
[west, north, bottom],
[west, north, top],
[east, north, top],
],
[
[west, north, bottom],
[west, south, bottom],
[west, south, top],
[west, north, top],
],
];
// glTF is Y-up; the tileset transform is east/north/up after the 3D Tiles
// loader's Y-to-Z rotation. Untextured flat faces need no external assets.
function glb(faces) {
const positions = [],
normals = [];
for (const face of faces) {
const vertices = face.map(([x, y, z]) => [x, z, -y]);
const [a, b, c] = vertices;
const u = b.map((v, i) => v - a[i]);
const v = c.map((v, i) => v - a[i]);
const normal = [
u[1] * v[2] - u[2] * v[1],
u[2] * v[0] - u[0] * v[2],
u[0] * v[1] - u[1] * v[0],
];
const length = Math.hypot(...normal);
for (let i = 1; i < vertices.length - 1; i++)
for (const vertex of [a, vertices[i], vertices[i + 1]]) {
positions.push(...vertex);
normals.push(...normal.map((n) => n / length));
}
}
const bytes = Buffer.from(
new Float32Array([...positions, ...normals]).buffer,
);
const bounds = (fn) =>
[0, 1, 2].map((axis) => fn(...positions.filter((_, i) => i % 3 === axis)));
const json = Buffer.from(
JSON.stringify({
asset: { version: '2.0' },
scene: 0,
scenes: [{ nodes: [0] }],
nodes: [{ mesh: 0 }],
meshes: [
{
primitives: [{ attributes: { POSITION: 0, NORMAL: 1 }, material: 0 }],
},
],
materials: [
{
pbrMetallicRoughness: {
baseColorFactor: [0.5, 0.5, 0.5, 1],
metallicFactor: 0,
roughnessFactor: 1,
},
},
],
buffers: [{ byteLength: bytes.length }],
bufferViews: [0, 1].map((i) => ({
buffer: 0,
byteOffset: i * positions.length * 4,
byteLength: positions.length * 4,
target: 34962,
})),
accessors: [
{
bufferView: 0,
componentType: 5126,
count: positions.length / 3,
type: 'VEC3',
min: bounds(Math.min),
max: bounds(Math.max),
},
{
bufferView: 1,
componentType: 5126,
count: positions.length / 3,
type: 'VEC3',
},
],
}),
);
const padded = Buffer.alloc(Math.ceil(json.length / 4) * 4, 0x20);
json.copy(padded);
const result = Buffer.alloc(12 + 8 + padded.length + 8 + bytes.length);
result.writeUInt32LE(0x46546c67, 0);
result.writeUInt32LE(2, 4);
result.writeUInt32LE(result.length, 8);
result.writeUInt32LE(padded.length, 12);
result.writeUInt32LE(0x4e4f534a, 16);
padded.copy(result, 20);
result.writeUInt32LE(bytes.length, 20 + padded.length);
result.writeUInt32LE(0x004e4942, 24 + padded.length);
bytes.copy(result, 28 + padded.length);
return result;
}
function probe(x, y, z) {
const position = geo.matrix4.multiplyByPoint(transform, { x, y, z }, {});
const cartographic = geo.cartographic.fromCartesian(position);
return {
position,
cartographic,
lon: geo.math.toDegrees(cartographic.longitude),
lat: geo.math.toDegrees(cartographic.latitude),
height: cartographic.height,
};
}
export function buildTilesFixture() {
const roof = box(110, -60, 210, 60, 0, 60);
roof.shift();
roof.push(
[
[110, -60, 60],
[160, -60, 100],
[160, 60, 100],
[110, 60, 60],
],
[
[160, -60, 100],
[210, -60, 60],
[210, 60, 60],
[160, 60, 100],
],
[
[110, -60, 60],
[210, -60, 60],
[160, -60, 100],
],
[
[210, 60, 60],
[110, 60, 60],
[160, 60, 100],
],
);
const tiles = [
[
'plate',
box(-300, -300, 300, 300, -2, 0),
[0, 0, -1, 300, 0, 0, 0, 300, 0, 0, 0, 1],
],
[
'block50',
box(-210, -60, -110, 60, 0, 50),
[-160, 0, 25, 50, 0, 0, 0, 60, 0, 0, 0, 25],
],
[
'block200',
box(-50, 120, 50, 220, 0, 200),
[0, 170, 100, 50, 0, 0, 0, 50, 0, 0, 0, 100],
],
['roof', roof, [160, 0, 50, 50, 0, 0, 0, 60, 0, 0, 0, 50]],
];
const tileset = {
asset: { version: '1.1' },
geometricError: 1000,
root: {
transform: Array.from(transform),
boundingVolume: { box: [0, 0, 99, 300, 0, 0, 0, 300, 0, 0, 0, 101] },
geometricError: 1000,
refine: 'ADD',
children: tiles.map(([name, , box]) => ({
boundingVolume: { box },
geometricError: 0,
content: { uri: `${name}.glb` },
})),
},
extras: {
origin,
datum: 'WGS84 ellipsoid; local ENU heights in metres',
probes: {
plate: probe(0, -170, 0),
block50: probe(-160, 0, 50),
block200: probe(0, 170, 200),
roof: probe(180, 0, 84),
},
// More than one pass of surfacePoints, all away from the roof edges.
points: Array.from({ length: 40 }, (_, i) => {
const x = 140 + (i % 8) * 5;
return probe(
x,
-40 + Math.floor(i / 8) * 20,
100 - 0.8 * Math.abs(x - 160),
);
}),
},
};
const files = new Map(
tiles.map(([name, faces]) => [`${name}.glb`, glb(faces)]),
);
files.set(
'tileset.json',
Buffer.from(JSON.stringify(tileset, null, 2) + '\n'),
);
assert.ok(
[...files.values()].reduce((sum, b) => sum + b.length, 0) < 200_000,
);
return files;
}
if (
process.argv[1] &&
import.meta.url === pathToFileURL(process.argv[1]).href
) {
const check = process.argv.includes('--check');
const files = buildTilesFixture();
if (!check) mkdirSync(directory, { recursive: true });
for (const [name, bytes] of files) {
const file = new URL(name, directory);
if (check)
assert.deepEqual(readFileSync(file), bytes, `${name} is reproducible`);
else writeFileSync(file, bytes);
}
console.log(
`${check ? 'Checked' : 'Generated'} ${files.size} fixture files (${[...files.values()].reduce((sum, b) => sum + b.length, 0)} bytes)`,
);
}