diff --git a/scripts/build-tiles3d-fixture.mjs b/scripts/build-tiles3d-fixture.mjs new file mode 100644 index 000000000..621661fe2 --- /dev/null +++ b/scripts/build-tiles3d-fixture.mjs @@ -0,0 +1,257 @@ +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)`, + ); +} diff --git a/scripts/fixtures/tiles3d/block200.glb b/scripts/fixtures/tiles3d/block200.glb new file mode 100644 index 000000000..9a097e2fc Binary files /dev/null and b/scripts/fixtures/tiles3d/block200.glb differ diff --git a/scripts/fixtures/tiles3d/block50.glb b/scripts/fixtures/tiles3d/block50.glb new file mode 100644 index 000000000..8e12157bc Binary files /dev/null and b/scripts/fixtures/tiles3d/block50.glb differ diff --git a/scripts/fixtures/tiles3d/plate.glb b/scripts/fixtures/tiles3d/plate.glb new file mode 100644 index 000000000..1efd54da9 Binary files /dev/null and b/scripts/fixtures/tiles3d/plate.glb differ diff --git a/scripts/fixtures/tiles3d/roof.glb b/scripts/fixtures/tiles3d/roof.glb new file mode 100644 index 000000000..7942d6810 Binary files /dev/null and b/scripts/fixtures/tiles3d/roof.glb differ diff --git a/scripts/fixtures/tiles3d/tileset.json b/scripts/fixtures/tiles3d/tileset.json new file mode 100644 index 000000000..8231f8542 --- /dev/null +++ b/scripts/fixtures/tiles3d/tileset.json @@ -0,0 +1,805 @@ +{ + "asset": { + "version": "1.1" + }, + "geometricError": 1000, + "root": { + "transform": [ + 0.9908821299915158, + -0.13473160157690048, + 0, + 0, + 0.06790921036869463, + 0.4994375649707324, + 0.8636842350322285, + 0, + -0.11636556024261228, + -0.8558092744488276, + 0.5040332748507723, + 0, + -742827.4189515795, + -5463116.347553584, + 3195991.10594888, + 1 + ], + "boundingVolume": { + "box": [ + 0, + 0, + 99, + 300, + 0, + 0, + 0, + 300, + 0, + 0, + 0, + 101 + ] + }, + "geometricError": 1000, + "refine": "ADD", + "children": [ + { + "boundingVolume": { + "box": [ + 0, + 0, + -1, + 300, + 0, + 0, + 0, + 300, + 0, + 0, + 0, + 1 + ] + }, + "geometricError": 0, + "content": { + "uri": "plate.glb" + } + }, + { + "boundingVolume": { + "box": [ + -160, + 0, + 25, + 50, + 0, + 0, + 0, + 60, + 0, + 0, + 0, + 25 + ] + }, + "geometricError": 0, + "content": { + "uri": "block50.glb" + } + }, + { + "boundingVolume": { + "box": [ + 0, + 170, + 100, + 50, + 0, + 0, + 0, + 50, + 0, + 0, + 0, + 100 + ] + }, + "geometricError": 0, + "content": { + "uri": "block200.glb" + } + }, + { + "boundingVolume": { + "box": [ + 160, + 0, + 50, + 50, + 0, + 0, + 0, + 60, + 0, + 0, + 0, + 50 + ] + }, + "geometricError": 0, + "content": { + "uri": "roof.glb" + } + } + ] + }, + "extras": { + "origin": { + "lon": -97.7431, + "lat": 30.2672 + }, + "datum": "WGS84 ellipsoid; local ENU heights in metres", + "probes": { + "plate": { + "position": { + "x": -742838.9635173421, + "y": -5463201.251939629, + "z": 3195844.2796289246 + }, + "cartographic": { + "longitude": -1.7059389161060694, + "latitude": 0.5282355305977933, + "height": 0.002275014548483431 + }, + "lon": -97.7431, + "lat": 30.265666492107215, + "height": 0.002275014548483431 + }, + "block50": { + "position": { + "x": -742991.7783703903, + "y": -5463137.580961054, + "z": 3196016.307612622 + }, + "cartographic": { + "longitude": -1.7059679361541311, + "latitude": 0.5282622951753985, + "height": 50.00200513183283 + }, + "lon": -97.7447627262752, + "lat": 30.267199989444443, + "height": 50.00200513183283 + }, + "block200": { + "position": { + "x": -742839.1474978654, + "y": -5463202.605022429, + "z": 3196238.7389238053 + }, + "cartographic": { + "longitude": -1.7059389161060696, + "latitude": 0.5282890592724478, + "height": 200.0022749319483 + }, + "lon": -97.74310000000001, + "lat": 30.26873345924785, + "height": 200.0022749319483 + }, + "roof": { + "position": { + "x": -742658.8348752414, + "y": -5463212.487220922, + "z": 3196033.444743967 + }, + "cartographic": { + "longitude": -1.7059062687258868, + "latitude": 0.528262295126465, + "height": 84.00253773142123 + }, + "lon": -97.74122944290338, + "lat": 30.267199986640765, + "height": 84.00253773142123 + } + }, + "points": [ + { + "position": { + "x": -742701.1865288558, + "y": -5463227.075459457, + "z": 3195998.897374566 + }, + "cartographic": { + "longitude": -1.7059135237921106, + "latitude": 0.5282559977114081, + "height": 84.00166112171651 + }, + "lon": -97.74164512757808, + "lat": 30.26683917133616, + "height": 84.00166112171651 + }, + { + "position": { + "x": -742696.6975804468, + "y": -5463231.172354563, + "z": 3196000.9135076655 + }, + "cartographic": { + "longitude": -1.7059126169402343, + "latitude": 0.5282559977051193, + "height": 88.00177273310982 + }, + "lon": -97.74159316879293, + "lat": 30.26683917097584, + "height": 88.00177273310982 + }, + { + "position": { + "x": -742692.2086320379, + "y": -5463235.269249668, + "z": 3196002.929640765 + }, + "cartographic": { + "longitude": -1.7059117100894945, + "latitude": 0.528255997698471, + "height": 92.00188826158782 + }, + "lon": -97.7415412100729, + "lat": 30.266839170594917, + "height": 92.00188826158782 + }, + { + "position": { + "x": -742687.7196836289, + "y": -5463239.3661447745, + "z": 3196004.945773864 + }, + "cartographic": { + "longitude": -1.705910803239891, + "latitude": 0.5282559976914627, + "height": 96.00200770555026 + }, + "lon": -97.74148925141796, + "lat": 30.26683917019337, + "height": 96.00200770555026 + }, + { + "position": { + "x": -742683.2307352199, + "y": -5463243.46303988, + "z": 3196006.9619069635 + }, + "cartographic": { + "longitude": -1.7059098963914245, + "latitude": 0.5282559976840947, + "height": 100.00213106686036 + }, + "lon": -97.74143729282817, + "lat": 30.266839169771217, + "height": 100.00213106686036 + }, + { + "position": { + "x": -742677.810862329, + "y": -5463240.71346079, + "z": 3196004.945773864 + }, + "cartographic": { + "longitude": -1.7059089895065904, + "latitude": 0.5282559976684348, + "height": 96.00225834565195 + }, + "lon": -97.74138533215466, + "lat": 30.266839168873968, + "height": 96.00225834565195 + }, + { + "position": { + "x": -742672.390989438, + "y": -5463237.9638817, + "z": 3196002.929640765 + }, + "cartographic": { + "longitude": -1.7059080826206199, + "latitude": 0.5282559976524149, + "height": 92.002389541416 + }, + "lon": -97.74133337141606, + "lat": 30.2668391679561, + "height": 92.002389541416 + }, + { + "position": { + "x": -742666.9711165471, + "y": -5463235.21430261, + "z": 3196000.9135076655 + }, + "cartographic": { + "longitude": -1.705907175733513, + "latitude": 0.5282559976360354, + "height": 88.0025246520816 + }, + "lon": -97.74128141061233, + "lat": 30.26683916701762, + "height": 88.0025246520816 + }, + { + "position": { + "x": -742699.8283446485, + "y": -5463217.086708157, + "z": 3196016.1710592667 + }, + "cartographic": { + "longitude": -1.705913523745684, + "latitude": 0.5282591464650368, + "height": 84.0015666583481 + }, + "lon": -97.74164512491804, + "lat": 30.26701958162981, + "height": 84.0015666583481 + }, + { + "position": { + "x": -742695.3393962395, + "y": -5463221.183603263, + "z": 3196018.187192366 + }, + "cartographic": { + "longitude": -1.7059126168921497, + "latitude": 0.5282591464567652, + "height": 88.00167827023611 + }, + "lon": -97.74159316603789, + "lat": 30.267019581155882, + "height": 88.00167827023611 + }, + { + "position": { + "x": -742690.8504478304, + "y": -5463225.280498369, + "z": 3196020.2033254653 + }, + "cartographic": { + "longitude": -1.7059117100397518, + "latitude": 0.5282591464481338, + "height": 92.0017937984389 + }, + "lon": -97.74154120722285, + "lat": 30.267019580661337, + "height": 92.0017937984389 + }, + { + "position": { + "x": -742686.3614994214, + "y": -5463229.377393475, + "z": 3196022.2194585647 + }, + "cartographic": { + "longitude": -1.7059108031884906, + "latitude": 0.5282591464391424, + "height": 96.00191324267489 + }, + "lon": -97.74148924847293, + "lat": 30.26701958014617, + "height": 96.00191324267489 + }, + { + "position": { + "x": -742681.8725510124, + "y": -5463233.47428858, + "z": 3196024.2355916644 + }, + "cartographic": { + "longitude": -1.7059098963383656, + "latitude": 0.5282591464297914, + "height": 100.00203660347505 + }, + "lon": -97.74143728978812, + "lat": 30.2670195796104, + "height": 100.00203660347505 + }, + { + "position": { + "x": -742676.4526781215, + "y": -5463230.72470949, + "z": 3196022.2194585647 + }, + "cartographic": { + "longitude": -1.7059089894518735, + "latitude": 0.5282591464161143, + "height": 96.00216388302483 + }, + "lon": -97.74138532901962, + "lat": 30.267019578826755, + "height": 96.00216388302483 + }, + { + "position": { + "x": -742671.0328052306, + "y": -5463227.975130401, + "z": 3196020.2033254653 + }, + "cartographic": { + "longitude": -1.705908082564245, + "latitude": 0.5282591464020774, + "height": 92.00229507980886 + }, + "lon": -97.74133336818602, + "lat": 30.267019578022502, + "height": 92.00229507980886 + }, + { + "position": { + "x": -742665.6129323397, + "y": -5463225.225551311, + "z": 3196018.187192366 + }, + "cartographic": { + "longitude": -1.7059071756754798, + "latitude": 0.5282591463876805, + "height": 88.00243019067032 + }, + "lon": -97.74128140728727, + "lat": 30.26701957719762, + "height": 88.00243019067032 + }, + { + "position": { + "x": -742698.470160441, + "y": -5463207.097956859, + "z": 3196033.444743967 + }, + "cartographic": { + "longitude": -1.705913523699257, + "latitude": 0.5282622952185787, + "height": 84.00153517112447 + }, + "lon": -97.74164512225796, + "lat": 30.26719999191849, + "height": 84.00153517112447 + }, + { + "position": { + "x": -742693.981212032, + "y": -5463211.194851964, + "z": 3196035.460877067 + }, + "cartographic": { + "longitude": -1.7059126168440648, + "latitude": 0.528262295208324, + "height": 88.00164678309102 + }, + "lon": -97.74159316328283, + "lat": 30.267199991330937, + "height": 88.00164678309102 + }, + { + "position": { + "x": -742689.492263623, + "y": -5463215.29174707, + "z": 3196037.4770101663 + }, + "cartographic": { + "longitude": -1.705911709990009, + "latitude": 0.5282622951977096, + "height": 92.0017623126523 + }, + "lon": -97.7415412043728, + "lat": 30.267199990722776, + "height": 92.0017623126523 + }, + { + "position": { + "x": -742685.0033152141, + "y": -5463219.388642175, + "z": 3196039.4931432656 + }, + "cartographic": { + "longitude": -1.7059108031370898, + "latitude": 0.5282622951867355, + "height": 96.00188175721505 + }, + "lon": -97.74148924552787, + "lat": 30.267199990094007, + "height": 96.00188175721505 + }, + { + "position": { + "x": -742680.5143668051, + "y": -5463223.485537281, + "z": 3196041.509276365 + }, + "cartographic": { + "longitude": -1.705909896285307, + "latitude": 0.5282622951754012, + "height": 100.00200511623785 + }, + "lon": -97.74143728674808, + "lat": 30.267199989444602, + "height": 100.00200511623785 + }, + { + "position": { + "x": -742675.0944939142, + "y": -5463220.735958191, + "z": 3196039.4931432656 + }, + "cartographic": { + "longitude": -1.7059089893971566, + "latitude": 0.5282622951637073, + "height": 96.0021323949121 + }, + "lon": -97.74138532588458, + "lat": 30.267199988774586, + "height": 96.0021323949121 + }, + { + "position": { + "x": -742669.6746210232, + "y": -5463217.986379101, + "z": 3196037.4770101663 + }, + "cartographic": { + "longitude": -1.7059080825078698, + "latitude": 0.5282622951516531, + "height": 92.0022635916176 + }, + "lon": -97.74133336495596, + "lat": 30.267199988083934, + "height": 92.0022635916176 + }, + { + "position": { + "x": -742664.2547481323, + "y": -5463215.236800011, + "z": 3196035.460877067 + }, + "cartographic": { + "longitude": -1.7059071756174464, + "latitude": 0.5282622951392388, + "height": 88.00239870366701 + }, + "lon": -97.74128140396222, + "lat": 30.26719998737265, + "height": 88.00239870366701 + }, + { + "position": { + "x": -742697.1119762337, + "y": -5463197.109205559, + "z": 3196050.718428668 + }, + "cartographic": { + "longitude": -1.7059135236528302, + "latitude": 0.5282654439720338, + "height": 84.00156665806156 + }, + "lon": -97.7416451195979, + "lat": 30.267380402202193, + "height": 84.00156665806156 + }, + { + "position": { + "x": -742692.6230278247, + "y": -5463201.206100664, + "z": 3196052.7345617674 + }, + "cartographic": { + "longitude": -1.70591261679598, + "latitude": 0.5282654439597962, + "height": 88.00167827087749 + }, + "lon": -97.74159316052776, + "lat": 30.267380401501033, + "height": 88.00167827087749 + }, + { + "position": { + "x": -742688.1340794157, + "y": -5463205.30299577, + "z": 3196054.7506948668 + }, + "cartographic": { + "longitude": -1.705911709940266, + "latitude": 0.5282654439471988, + "height": 92.00179379845281 + }, + "lon": -97.74154120152274, + "lat": 30.26738040077925, + "height": 92.00179379845281 + }, + { + "position": { + "x": -742683.6451310067, + "y": -5463209.399890875, + "z": 3196056.766827966 + }, + "cartographic": { + "longitude": -1.7059108030856887, + "latitude": 0.5282654439342416, + "height": 96.00191324280611 + }, + "lon": -97.74148924258282, + "lat": 30.267380400036856, + "height": 96.00191324280611 + }, + { + "position": { + "x": -742679.1561825977, + "y": -5463213.496785982, + "z": 3196058.7829610654 + }, + "cartographic": { + "longitude": -1.7059098962322479, + "latitude": 0.5282654439209244, + "height": 100.00203660378942 + }, + "lon": -97.74143728370802, + "lat": 30.267380399273843, + "height": 100.00203660378942 + }, + { + "position": { + "x": -742673.7363097068, + "y": -5463210.747206892, + "z": 3196056.766827966 + }, + "cartographic": { + "longitude": -1.7059089893424393, + "latitude": 0.5282654439112129, + "height": 96.00216388304962 + }, + "lon": -97.74138532274951, + "lat": 30.26738039871741, + "height": 96.00216388304962 + }, + { + "position": { + "x": -742668.3164368159, + "y": -5463207.997627802, + "z": 3196054.7506948668 + }, + "cartographic": { + "longitude": -1.7059080824514945, + "latitude": 0.5282654439011418, + "height": 92.00229507962341 + }, + "lon": -97.74133336172589, + "lat": 30.267380398140382, + "height": 92.00229507962341 + }, + { + "position": { + "x": -742662.896563925, + "y": -5463205.248048712, + "z": 3196052.7345617674 + }, + "cartographic": { + "longitude": -1.7059071755594128, + "latitude": 0.5282654438907107, + "height": 88.00243019027465 + }, + "lon": -97.74128140063713, + "lat": 30.267380397542723, + "height": 88.00243019027465 + }, + { + "position": { + "x": -742695.7537920263, + "y": -5463187.120454259, + "z": 3196067.9921133686 + }, + "cartographic": { + "longitude": -1.7059135236064031, + "latitude": 0.528268592725402, + "height": 84.00166112141882 + }, + "lon": -97.74164511693783, + "lat": 30.26756081248092, + "height": 84.00166112141882 + }, + { + "position": { + "x": -742691.2648436173, + "y": -5463191.217349365, + "z": 3196070.008246468 + }, + "cartographic": { + "longitude": -1.7059126167478948, + "latitude": 0.5282685927111814, + "height": 88.00177273414431 + }, + "lon": -97.74159315777268, + "lat": 30.26756081166614, + "height": 88.00177273414431 + }, + { + "position": { + "x": -742686.7758952084, + "y": -5463195.314244471, + "z": 3196072.0243795672 + }, + "cartographic": { + "longitude": -1.705911709890523, + "latitude": 0.5282685926966009, + "height": 92.0018882615363 + }, + "lon": -97.74154119867266, + "lat": 30.267560810830734, + "height": 92.0018882615363 + }, + { + "position": { + "x": -742682.2869467994, + "y": -5463199.411139577, + "z": 3196074.040512667 + }, + "cartographic": { + "longitude": -1.7059108030342875, + "latitude": 0.5282685926816608, + "height": 96.0020077065962 + }, + "lon": -97.74148923963774, + "lat": 30.267560809974732, + "height": 96.0020077065962 + }, + { + "position": { + "x": -742677.7979983904, + "y": -5463203.508034682, + "z": 3196076.0566457664 + }, + "cartographic": { + "longitude": -1.7059098961791888, + "latitude": 0.5282685926663608, + "height": 100.00213106635087 + }, + "lon": -97.74143728066795, + "lat": 30.267560809098107, + "height": 100.00213106635087 + }, + { + "position": { + "x": -742672.3781254995, + "y": -5463200.758455592, + "z": 3196074.040512667 + }, + "cartographic": { + "longitude": -1.705908989287722, + "latitude": 0.5282685926586324, + "height": 96.00225834543971 + }, + "lon": -97.74138531961444, + "lat": 30.267560808655297, + "height": 96.00225834543971 + }, + { + "position": { + "x": -742666.9582526085, + "y": -5463198.0088765025, + "z": 3196072.0243795672 + }, + "cartographic": { + "longitude": -1.705908082395119, + "latitude": 0.5282685926505436, + "height": 92.00238954127981 + }, + "lon": -97.74133335849581, + "lat": 30.267560808191845, + "height": 92.00238954127981 + }, + { + "position": { + "x": -742661.5383797176, + "y": -5463195.259297413, + "z": 3196070.008246468 + }, + "cartographic": { + "longitude": -1.7059071755013793, + "latitude": 0.5282685926420952, + "height": 88.0025246543197 + }, + "lon": -97.74128139731205, + "lat": 30.267560807707792, + "height": 88.0025246543197 + } + ] + } +} diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 33b529d69..c3b6069d6 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2286,7 +2286,9 @@ "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", - "src/globe/contract.js" + "src/globe/contract.js", + "src/globe/cesium/qaTileset.js", + "src/maps/qaTileset.js" ], "external": ["cesium"] }, @@ -3593,7 +3595,9 @@ "src/globe/cesium/orbital.js", "src/globe/cesium/worldTime.js", "src/data/geoImportLimits.js", - "src/data/geoImportPlan.js" + "src/data/geoImportPlan.js", + "src/globe/cesium/qaTileset.js", + "src/maps/qaTileset.js" ], "external": [ "hls.js", diff --git a/scripts/qa-all-layer-switches.mjs b/scripts/qa-all-layer-switches.mjs index 9b886387c..5713000ad 100644 --- a/scripts/qa-all-layer-switches.mjs +++ b/scripts/qa-all-layer-switches.mjs @@ -352,6 +352,7 @@ const runs = [ ], ['radio-engine-switch', 'radio-engine-switch', ['radio']], ['imagery-contract', 'imagery-contract', ['recent-imagery']], + ['tiles-fixture', 'tiles-fixture', ['osm-places']], ['fire-camera-switch', 'fire-camera-switch', ['alpr-cameras']], [ 'fire-camera-switch--fires', diff --git a/scripts/qa-harness-baseline.json b/scripts/qa-harness-baseline.json index 89e06bed9..7f83ca154 100644 --- a/scripts/qa-harness-baseline.json +++ b/scripts/qa-harness-baseline.json @@ -3,8 +3,8 @@ "ownLaunch": 99, "fixedSleeps": 118, "loadingScreenReadiness": 9, - "cesiumOnlyReads": 75, + "cesiumOnlyReads": 76, "ownWaitHelpers": 19 }, - "measuredAt": "Navara canaries run a bare Navara view outside the app, so they launch Chrome themselves" + "measuredAt": "Tiles fixture measures Cesium offscreen pick renders and Navara renderer calls during surface sampling" } diff --git a/scripts/qa-strict-harness.mjs b/scripts/qa-strict-harness.mjs index 4b459d95b..4b96b393e 100644 --- a/scripts/qa-strict-harness.mjs +++ b/scripts/qa-strict-harness.mjs @@ -93,10 +93,45 @@ export async function assertPreviewPortAvailable(href, request = fetch) { /** Check the running page before any scenario touches its layers. */ export async function assertKeylessPreview(page) { - await page.evaluate(() => { - if (window.__godsEyeView.tileset || window.__GOOGLE_MAPS_API_KEY__) - throw new Error('Keyless preview required'); - }); + await page.evaluate( + (fixturePath) => { + const app = window.__godsEyeView; + const source = app.engine?.maps?.current()?.photoreal; + const fixture = + new URLSearchParams(location.search).get('qaTileset') === fixturePath && + source?.tilesetUrl === fixturePath && + (!source.tileset || + new URL(source.tileset.resource?.url, location.href).href === + location.origin + fixturePath); + if ( + window.__GOOGLE_MAPS_API_KEY__ || + (app.tileset && (!fixture || app.tileset !== source.tileset)) || + ((source || app.engine?.maps?.photorealActive()) && !fixture) + ) + throw new Error('Keyless preview required'); + }, + TILES_FIXTURE_PATH, + ); +} + +export const TILES_FIXTURE_PATH = '/__qa/tiles3d/tileset.json'; + +// CDP serves these exact same-origin URLs, including requests from workers. +// Keeping them in scripts/ avoids shipping fixture assets in normal builds. +export function tilesFixtureRoutes(origin) { + return ['tileset.json', 'plate.glb', 'block50.glb', 'block200.glb', 'roof.glb'].map( + (name) => ({ + pattern: new URL(`/__qa/tiles3d/${name}`, origin).href, + respond: () => ({ + contentType: name.endsWith('.json') + ? 'application/json' + : 'model/gltf-binary', + body: fs.readFileSync( + new URL(`./fixtures/tiles3d/${name}`, import.meta.url), + ), + }), + }), + ); } /** The URL with no remote IP: fulfilled by a fixture or a cache, or a data URL. */ diff --git a/scripts/qa-tiles-fixture.mjs b/scripts/qa-tiles-fixture.mjs new file mode 100644 index 000000000..435128f10 --- /dev/null +++ b/scripts/qa-tiles-fixture.mjs @@ -0,0 +1,458 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import * as geo from '../src/geo/index.js'; +import { + assertKeylessPreview, + createStrict, + localPreview, + openPage, + TILES_FIXTURE_PATH, + tilesFixtureRoutes, + waitForCameraAtRest, + waitForEngine, +} from './qa-strict-harness.mjs'; + +const base = localPreview(process.argv[2]); +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.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; +const holdTiles = () => { + pendingTiles = new Promise((resolve) => { + releaseTiles = resolve; + }); +}; +holdTiles(); +const routes = tilesFixtureRoutes(base.origin).map((route) => ({ + ...route, + async respond(request) { + if (route.pattern.endsWith('.glb')) await pendingTiles; + return route.respond(request); + }, +})); +routes.push({ + pattern: `${base.origin}/api/overpass/status`, + respond: () => ({ + contentType: 'application/json', + body: '{"configured":true}', + }), +}); +const strict = createStrict({ + origin: base.origin, + layers: ['osm-places'], + routes, +}); + +await strict.run({}, async (browser) => { + const { page, errors } = await openPage(browser, { shaderErrors: true }); + 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') { + readsBefore = { ...strict.ledger.routes }; + holdTiles(); + await strict.pick(page, kind, { settle: false }); + } + try { + await page.waitForFunction( + (kind) => { + const engine = window.__godsEyeView?.engine; + return ( + engine?.kind === kind && + engine.maps.photorealActive() && + !engine.surface.tilesLoaded() + ); + }, + { timeout: 90000 }, + kind, + ); + await strict.passed( + page, + 'osm-places', + 'tilesLoaded is false while GLBs are held', + ); + } finally { + releaseTiles(); + } + await waitForEngine(page, kind); + await page.evaluate( + async (destination) => { + const { engine, styleManager } = window.__godsEyeView; + styleManager.orbitController.stop(); + engine.camera.cancelFlight(); + await engine.maps.setTerrain({ kind: 'ellipsoid' }); + engine.camera.setView({ + destination, + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + engine.render.request('qa-tiles-fixture'); + }, + geo.cartesian3.fromDegrees(fixture.origin.lon, fixture.origin.lat, 1100), + ); + 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}`, + ); + await strict.passed( + page, + 'osm-places', + 'tilesLoaded becomes true; all five files served by same-origin routes', + ); + + const heights = await page.evaluate( + ({ probes, clampStart }) => { + const { surface } = window.__godsEyeView.engine; + return { + sampled: Object.fromEntries( + Object.entries(probes).map(([id, p]) => [ + id, + surface.sampleHeight(p.cartographic), + ]), + ), + clamped: surface.clampToHeight(clampStart), + }; + }, + { + probes: fixture.probes, + clampStart: geo.cartesian3.fromDegrees( + fixture.probes.roof.lon, + fixture.probes.roof.lat, + fixture.probes.roof.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)}`, + ); + 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, + ) <= 0.5, + ); + await strict.passed( + page, + 'osm-places', + 'known plate, 50 m, 200 m and sloping roof heights; clamp lands on roof', + heights, + ); + + const drapes = []; + for (const surface of ['tiles', 'terrain']) { + if (surface === 'terrain') { + await page.evaluate(async () => { + const { engine, mapStackController } = window.__godsEyeView; + await mapStackController.setStack('osm'); + await engine.maps.setTerrain({ kind: 'ellipsoid' }); + }); + await loaded(page); + } + const p = fixture.probes.block50; + const position = geo.cartesian3.fromDegrees( + p.lon, + 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 }) => { + const { engine } = window.__godsEyeView; + const set = engine.ground.createSet({ + id: 'qa-drape', + kind: 'cell', + drape, + pickable: false, + }); + set.replace([ + { + west: lon - 0.0003, + east: lon + 0.0003, + south: lat - 0.0003, + north: lat + 0.0003, + fill: [1, 0, 1, 1], + }, + ]); + set.commit(); + window.qaDrape = set; + }, + { ...p, drape }, + ); + try { + await page.waitForFunction( + () => { + window.__godsEyeView.engine.render.request('qa-drape'); + return window.qaDrape.ready(); + }, + { timeout: 30000 }, + ); + const rgb = 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}`, + ); + if (surface !== drape) + assert.ok( + rgb.every((v, i) => Math.abs(v - baseline[i]) < 15), + 'other surface stays unchanged', + ); + drapes.push({ surface, drape, baseline, rgb }); + } finally { + await page.evaluate(() => { + window.qaDrape.destroy(); + delete window.qaDrape; + }); + } + } + } + await strict.passed( + page, + 'osm-places', + 'drape tiles and terrain draw only on their respective surface (pixels)', + drapes, + ); + await page.evaluate(() => + window.__godsEyeView.mapStackController.setStack('photoreal'), + ); + await loaded(page); + await waitForCameraAtRest(page); + await checkPoints(page, kind); + await assertKeylessPreview(page); + console.log( + `PASS ${kind} fixture heights, clamp, drapes, tilesLoaded and surface-points budget`, + ); + } + assert.deepEqual(errors, []); + assert.deepEqual( + Object.keys(strict.ledger.external).filter((host) => + /google|gstatic|cesium\.com/i.test(host), + ), + [], + ); +}); + +async function loaded(page) { + await page.waitForFunction( + () => { + const { engine } = window.__godsEyeView; + engine.render.request('qa-tiles-fixture'); + return engine.surface.tilesLoaded(); + }, + { polling: 100, timeout: 90000 }, + ); +} + +// Read actual canvas pixels during postRender; Cesium discards them afterwards. +async function pixels(page, position) { + return page.evaluate(async (position) => { + const { engine } = window.__godsEyeView; + const canvas = document.createElement('canvas'); + canvas.width = canvas.height = 5; + const context = canvas.getContext('2d', { willReadFrequently: true }); + for (let frame = 0; frame < 2; frame++) + await new Promise((resolve) => { + const off = engine.frames.postRender.addEventListener(() => { + off(); + const point = engine.projector.worldToWindow(position); + const scale = engine.host.canvas.width / engine.host.size().width; + context.drawImage( + engine.host.canvas, + Math.round(point.x * scale) - 2, + Math.round(point.y * scale) - 2, + 5, + 5, + 0, + 0, + 5, + 5, + ); + resolve(); + }); + engine.render.request('qa-drape-pixels'); + }); + const data = context.getImageData(0, 0, 5, 5).data; + return [0, 1, 2].map( + (channel) => + data.reduce( + (sum, value, i) => sum + (i % 4 === channel ? value : 0), + 0, + ) / 25, + ); + }, position); +} + +async function checkPoints(page, kind) { + await page.evaluate(async (points) => { + const { engine, viewer, dataManager } = window.__godsEyeView; + const layer = dataManager.layers.get('osm-places').module; + layer.setResults([], null); + await dataManager.setEnabled('osm-places', true); + // Reattach this real layer once so its mover commits can be observed. + layer.detachRenderer(); + const createSet = engine.movers.createSet; + const sampleHeight = engine.surface.sampleHeight; + const target = + engine.kind === 'cesium' + ? viewer.scene._picking._pickOffscreenView.pickFramebuffer + : engine.extensions.navara.view._renderer; + const method = engine.kind === 'cesium' ? 'begin' : 'render'; + const render = target[method]; + if (typeof render !== 'function') + throw new Error('Missing pick-render counter hook'); + const stats = (window.qaPoints = { + passes: [], + positions: [], + samples: 0, + renders: 0, + }); + let pass = null, + passStart = 0, + sampling = false; + target[method] = function (...args) { + if (sampling) { + pass.renders++; + stats.renders++; + } + return render.apply(this, args); + }; + engine.surface.sampleHeight = function (...args) { + if (!pass) { + passStart = performance.now(); + pass = { + samples: 0, + renders: 0, + ms: 0, + startsMs: [], + cameraEstimate: !points.some( + (p) => + Math.abs(p.cartographic.longitude - args[0].longitude) < 1e-10 && + Math.abs(p.cartographic.latitude - args[0].latitude) < 1e-10, + ), + }; + stats.passes.push(pass); + // A placement callback samples synchronously, without yielding. + queueMicrotask(() => { + pass = null; + }); + } + pass.samples++; + stats.samples++; + const start = performance.now(); + pass.startsMs.push(start - passStart); + sampling = true; + try { + return sampleHeight.apply(this, args); + } finally { + sampling = false; + pass.ms += performance.now() - start; + } + }; + engine.movers.createSet = function (options) { + const set = createSet.call(this, options); + if (options.id !== 'osm-places') return set; + const write = set.set, + commit = set.commit; + const positions = []; + set.set = function (index, fields) { + if (fields.position) positions[index] = { ...fields.position }; + return write.call(this, index, fields); + }; + set.commit = function () { + const result = commit.call(this); + stats.positions = positions.map((p) => ({ ...p })); + return result; + }; + return set; + }; + window.qaRestorePoints = () => { + engine.surface.sampleHeight = sampleHeight; + engine.movers.createSet = createSet; + target[method] = render; + }; + layer.attachRenderer(viewer, { engine }); + layer.setResults( + points.map((p, i) => ({ id: String(i), lon: p.lon, lat: p.lat })), + { kind: 'fixture', label: 'Roof points', area: 'Austin' }, + ); + }, fixture.points); + try { + const expected = fixture.points.map((p) => + geo.cartesian3.fromDegrees(p.lon, p.lat, p.height + 2), + ); + await page.waitForFunction( + (expected) => { + const positions = window.qaPoints.positions; + return ( + positions.length === expected.length && + positions.every( + (p, i) => + Math.hypot( + p.x - expected[i].x, + p.y - expected[i].y, + p.z - expected[i].z, + ) <= 0.5, + ) + ); + }, + { polling: 100, timeout: 30000 }, + expected, + ); + const result = await page.evaluate(() => ({ + ...window.qaPoints, + drawn: window.__godsEyeView.engine.movers + .list() + .find((s) => s.id === 'osm-places'), + })); + assert.equal(result.drawn.count, fixture.points.length); + assert.equal(result.drawn.shown, true); + assert.ok( + result.samples >= fixture.points.length && result.passes.length > 1, + ); + assert.ok( + result.passes.every((p) => p.samples <= 16 && p.renders <= 16), + JSON.stringify(result.passes), + ); + // The 4 ms limit is checked between points: one expensive sample (and + // its initial camera estimate) may finish after the deadline. + assert.ok( + result.passes.every((p) => + p.startsMs.slice(p.cameraEstimate ? 2 : 1).every((ms) => ms < 4), + ), + JSON.stringify(result.passes), + ); + if (kind === 'cesium') + assert.ok( + result.renders >= fixture.points.length, + 'count actual offscreen pick renders', + ); + else assert.equal(result.renders, 0, 'tile sampling uses CPU rays'); + await strict.passed( + page, + 'osm-places', + '40 roof points committed at surface + 2 m; 16 samples/pick renders and 4 ms soft budget per pass', + result, + ); + } finally { + await page.evaluate(async () => { + window.qaRestorePoints(); + const { dataManager } = window.__godsEyeView; + await dataManager.setEnabled('osm-places', false); + dataManager.layers.get('osm-places').module.setResults([], null); + }); + } +} diff --git a/scripts/qa-tiles-fixture.test.mjs b/scripts/qa-tiles-fixture.test.mjs new file mode 100644 index 000000000..e3ea8ef2c --- /dev/null +++ b/scripts/qa-tiles-fixture.test.mjs @@ -0,0 +1,226 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { Cesium3DTileset } from 'cesium'; +import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; +import { Raycaster, Vector3 } from 'three'; +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 { + assertKeylessPreview, + TILES_FIXTURE_PATH, + tilesFixtureRoutes, +} from './qa-strict-harness.mjs'; + +function globals(t, values) { + for (const [name, value] of Object.entries(values)) { + const old = Object.getOwnPropertyDescriptor(globalThis, name); + Object.defineProperty(globalThis, name, { + configurable: true, + writable: true, + value, + }); + t.after(() => { + if (old) Object.defineProperty(globalThis, name, old); + else delete globalThis[name]; + }); + } +} + +test('fixture is reproducible, self-contained and under 200 KB', () => { + const files = buildTilesFixture(); + assert.equal(files.size, 5); + assert.ok( + [...files.values()].reduce((sum, bytes) => sum + bytes.length, 0) < 200_000, + ); + for (const [name, bytes] of files) + assert.deepEqual( + readFileSync(new URL(`./fixtures/tiles3d/${name}`, import.meta.url)), + bytes, + ); + const tileset = JSON.parse(files.get('tileset.json')); + for (const child of tileset.root.children) + assert.ok(files.has(child.content.uri)); + assert.equal(tileset.extras.points.length, 40); +}); + +test('independent glTF loader and triangle rays recover the authored heights', async () => { + const files = buildTilesFixture(); + const probes = [ + ['plate', 0, -170, 0], + ['block50', -160, 0, 50], + ['block200', 0, 170, 200], + ['roof', 180, 0, 84], + ['roof', 140, 30, 84], + ['roof', 160, 0, 100], + ]; + for (const [name, east, north, height] of probes) { + const bytes = files.get(`${name}.glb`); + const gltf = await new GLTFLoader().parseAsync( + bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), + '', + ); + gltf.scene.updateMatrixWorld(true); + const ray = new Raycaster( + new Vector3(east, 1000, -north), + new Vector3(0, -1, 0), + ); + const hits = ray.intersectObject(gltf.scene, true); + assert.ok(hits.length, name); + assert.ok( + Math.abs(hits[0].point.y - height) < 1e-5, + `${name}: ${hits[0].point.y}`, + ); + } +}); + +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 }); + assert.equal(qaTilesetUrl(), null); + assert.equal(createDefaultMapSources().defaultId, 'esri-imagery'); + location.searchParams.set('qaTileset', TILES_FIXTURE_PATH); + assert.equal(qaTilesetUrl(), TILES_FIXTURE_PATH); + for (const value of [ + 'https://example.com/tileset.json', + 'http://localhost:4303/tileset.json', + '//example.com/x', + '/\\example.com/x', + 'relative.json', + '/x\n', + '/x?key=test', + '/x#fragment', + ]) { + assert.throws(() => localTilesetPath(value), /page origin/); + for (const engineKind of ['cesium', 'navara']) + assert.throws( + () => createDefaultMapSources({ engineKind, tilesetUrl: value }), + /page origin/, + ); + } +}); + +test('controller passes local URL to Navara and owns the Cesium factory result', async (t) => { + globals(t, { location: new URL('http://localhost:4303/') }); + const tileset = { url: TILES_FIXTURE_PATH, show: true, destroy: t.mock.fn() }; + const factory = t.mock.method( + Cesium3DTileset, + 'fromUrl', + async () => tileset, + ); + for (const engineKind of ['navara', 'cesium']) { + const registry = createDefaultMapSources({ + engineKind, + tilesetUrl: TILES_FIXTURE_PATH, + }); + const engine = createFakeEngine({ kind: engineKind }); + const controller = new MapSourceController({ registry, engine }); + await controller.setStack('photoreal'); + const source = engine.maps.current().photoreal; + assert.equal(source.tilesetUrl, TILES_FIXTURE_PATH); + assert.equal(source.tileset, engineKind === 'cesium' ? tileset : undefined); + assert.equal(factory.mock.callCount(), engineKind === 'cesium' ? 1 : 0); + controller.destroy(); + engine.destroy(); + } + assert.equal(factory.mock.calls[0].arguments[0], TILES_FIXTURE_PATH); + assert.equal(tileset.destroy.mock.callCount(), 1); +}); + +test('Navara accepts the local 3D source without a key or Google credit, and rejects remote paths', async (t) => { + globals(t, { location: new URL('http://localhost:4303/') }); + const sources = [], + credits = []; + const view = { + addSource: (desc) => { + sources.push(desc); + return { delete() {} }; + }, + addLayer: () => ({ id: 'fixture', delete() {} }), + forceUpdate() {}, + attribution: { + add: (items) => credits.push(...items), + remove() {}, + show: t.mock.fn(), + }, + }; + const owner = createNavaraMaps(view, { + setInterval: () => 1, + clearInterval() {}, + }); + t.after(owner.destroy); + await owner.maps.setPhotorealTiles({ tilesetUrl: TILES_FIXTURE_PATH }); + assert.deepEqual(sources, [ + { type: '3d-tiles', url: `http://localhost:4303${TILES_FIXTURE_PATH}` }, + ]); + assert.equal(owner.maps.photorealActive(), true); + assert.deepEqual(credits, []); + assert.equal(view.attribution.show.mock.callCount(), 0); + await assert.rejects( + owner.maps.setPhotorealTiles({ + tilesetUrl: '//example.com/tileset.json', + googleApiKey: 'unused', + }), + /page origin/, + ); + assert.equal(sources.length, 1); + await owner.maps.setPhotorealTiles(null); + assert.equal(owner.maps.photorealActive(), false); +}); + +test('strict keyless assertion permits only the opted-in fixture and its real tileset URL', async (t) => { + const location = new URL( + `http://localhost:4303/?qaTileset=${encodeURIComponent(TILES_FIXTURE_PATH)}`, + ); + const source = { + tilesetUrl: TILES_FIXTURE_PATH, + tileset: { resource: { url: TILES_FIXTURE_PATH } }, + }; + const app = { + engine: { + maps: { + current: () => ({ photoreal: source }), + photorealActive: () => true, + }, + }, + }; + const window = { __godsEyeView: app }; + globals(t, { window, location }); + const page = { evaluate: async (fn, ...args) => fn(...args) }; + await assertKeylessPreview(page); + source.tileset.resource.url = 'https://example.com/tileset.json'; + await assert.rejects(assertKeylessPreview(page), /Keyless/); + source.tileset.resource.url = TILES_FIXTURE_PATH; + location.search = ''; + await assert.rejects(assertKeylessPreview(page), /Keyless/); + location.searchParams.set('qaTileset', TILES_FIXTURE_PATH); + source.tilesetUrl = '/another/tileset.json'; + await assert.rejects(assertKeylessPreview(page), /Keyless/); + source.tilesetUrl = TILES_FIXTURE_PATH; + window.__GOOGLE_MAPS_API_KEY__ = 'key'; + await assert.rejects(assertKeylessPreview(page), /Keyless/); + delete window.__GOOGLE_MAPS_API_KEY__; + app.tileset = { url: '/another/tileset.json' }; + await assert.rejects(assertKeylessPreview(page), /Keyless/); +}); + +test('harness exposes exactly the five binary-safe fixture routes on the preview origin', () => { + const routes = tilesFixtureRoutes('http://localhost:4303'); + assert.equal(routes.length, 5); + const files = buildTilesFixture(); + for (const route of routes) { + const url = new URL(route.pattern); + assert.equal(url.origin, 'http://localhost:4303'); + assert.ok(!route.pattern.includes('*')); + const answer = route.respond(); + assert.deepEqual(answer.body, files.get(url.pathname.split('/').at(-1))); + assert.equal( + answer.contentType, + url.pathname.endsWith('.glb') ? 'model/gltf-binary' : 'application/json', + ); + } +}); diff --git a/src/globe/cesium/qaTileset.js b/src/globe/cesium/qaTileset.js new file mode 100644 index 000000000..22473816e --- /dev/null +++ b/src/globe/cesium/qaTileset.js @@ -0,0 +1,8 @@ +import { Cesium3DTileset } from 'cesium'; +import { localTilesetPath } from '../../maps/qaTileset.js'; + +/** The QA source uses the controller's normal tileset ownership path. */ +export function createQaTileset(url, { signal } = {}) { + signal?.throwIfAborted(); + return Cesium3DTileset.fromUrl(localTilesetPath(url)); +} diff --git a/src/globe/navara/maps.js b/src/globe/navara/maps.js index 8c7a19867..574e3f480 100644 --- a/src/globe/navara/maps.js +++ b/src/globe/navara/maps.js @@ -22,6 +22,7 @@ import { SENTINEL2_MAX_ZOOM, SENTINEL2_URL, } from '../../maps/sentinel2.js'; +import { localTilesetPath } from '../../maps/qaTileset.js'; const OSM_URL = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; const OSM_CREDIT = { @@ -280,12 +281,15 @@ export function createNavaraMaps( return; } const key = String(source.googleApiKey || '').trim(); - if (!key) + const tilesetUrl = localTilesetPath(source.tilesetUrl); + if (!tilesetUrl && !key) throw new Error( 'Google 3D tiles on Navara need a browser key (googleApiKey)', ); if (destroyed || generation !== generations.display) return; - const url = `${GOOGLE_TILES_ROOT}?key=${encodeURIComponent(key)}`; + const url = tilesetUrl + ? new URL(tilesetUrl, globalThis.location.origin).href + : `${GOOGLE_TILES_ROOT}?key=${encodeURIComponent(key)}`; setImageryShown(false); currentBase = null; tilesSource = source; @@ -296,17 +300,19 @@ export function createNavaraMaps( url, { type: '3d-tiles', url }, { type: '3d-tiles', model: { maxSse: PHOTOREAL_MAX_SSE } }, - [ - (layer) => ({ - attribution: 'Google', - logo: GOOGLE_LOGO, - creditLayerId: layer.id, - }), - ], + tilesetUrl + ? [] + : [ + (layer) => ({ + attribution: 'Google', + logo: GOOGLE_LOGO, + creditLayerId: layer.id, + }), + ], ); // Google's terms want its copyright lines on screen, as Cesium shows // them; Navara lists them in its attribution card, collapsed by default. - view.attribution?.show(); + if (!tilesetUrl) view.attribution?.show(); }, releasePhotorealTiles(source) { if (!knownTiles.delete(source)) return; diff --git a/src/maps/controller.js b/src/maps/controller.js index 796e9cfc4..b1ca1e8ec 100644 --- a/src/maps/controller.js +++ b/src/maps/controller.js @@ -408,6 +408,7 @@ export class MapSourceController { descriptor: source.descriptor, kind: source.descriptor.kind, credit: source.credit ?? null, + ...(source.tilesetUrl && { tilesetUrl: source.tilesetUrl }), ...(source.googleApiKey && { googleApiKey: source.googleApiKey }), ...(source.cesiumToken && { cesiumToken: source.cesiumToken }), ...(tileset && { tileset }), diff --git a/src/maps/defaultSources.js b/src/maps/defaultSources.js index cfe82a415..6da600f8d 100644 --- a/src/maps/defaultSources.js +++ b/src/maps/defaultSources.js @@ -7,6 +7,7 @@ import { } from '../globe/cesium/mapImagery.js'; import { terrainSourceFor } from '../globe/cesium/terrain.js'; import { t } from '../i18n/index.js'; +import { localTilesetPath, qaTilesetUrl } from './qaTileset.js'; const navaraIonImageryReason = () => t('Bing imagery is not available on the experimental Navara engine'); @@ -24,10 +25,12 @@ export function createDefaultMapSources({ cesiumToken = '', googleApiKey = '', engineKind = 'cesium', + tilesetUrl = qaTilesetUrl(), } = {}) { if (engineKind !== 'cesium' && engineKind !== 'navara') throw new TypeError(`Unknown globe engine: ${engineKind}`); const navara = engineKind === 'navara'; + tilesetUrl = localTilesetPath(tilesetUrl); const ionToken = String(cesiumToken || '').trim(); const hasIon = Boolean(ionToken); // Engine-neutral descriptions ride beside the Cesium factories: a terrain @@ -40,7 +43,8 @@ export function createDefaultMapSources({ }; const googleKey = String(googleApiKey || '').trim(); const hasGoogle = Boolean(googleKey); - const photorealAvailable = navara ? hasGoogle : Boolean(googleTileset); + const photorealAvailable = + Boolean(tilesetUrl) || (navara ? hasGoogle : Boolean(googleTileset)); return { defaultId: photorealAvailable ? 'photoreal' : 'esri-imagery', unknownId: 'photoreal', @@ -69,7 +73,15 @@ export function createDefaultMapSources({ photorealUnavailableReason( navara ? hasGoogle : hasIon || hasGoogle, ), - tileset: googleTileset, + tileset: tilesetUrl ? null : googleTileset, + ...(tilesetUrl && { + tilesetUrl, + createTileset: async (options) => { + const { createQaTileset } = + await import('../globe/cesium/qaTileset.js'); + return createQaTileset(tilesetUrl, options); + }, + }), ...(googleKey && { googleApiKey: googleKey }), ...(hasIon && { cesiumToken: ionToken }), }; diff --git a/src/maps/qaTileset.js b/src/maps/qaTileset.js new file mode 100644 index 000000000..90704060a --- /dev/null +++ b/src/maps/qaTileset.js @@ -0,0 +1,22 @@ +/** Only a root-relative path on this page may replace the photoreal source. */ +export function localTilesetPath(value, location = globalThis.location) { + if (!value) return null; + if ( + typeof value !== 'string' || + !/^\/(?!\/)/.test(value) || + /[\\\s\u0000-\u001f\u007f]/.test(value) || + !location?.origin + ) + throw new TypeError('tilesetUrl must be a path on the page origin'); + const url = new URL(value, location.origin); + if (url.origin !== location.origin || url.search || url.hash) + throw new TypeError('tilesetUrl must be a path on the page origin'); + return url.pathname; +} + +// Like parkedMs, this explicit QA parameter is inert on an ordinary page. +export function qaTilesetUrl() { + return localTilesetPath( + new URLSearchParams(globalThis.location?.search).get('qaTileset'), + ); +}