From 9e33150b371a2982ef3907c190bd81c6924f2fe8 Mon Sep 17 00:00:00 2001 From: Ben Adler Date: Tue, 28 Apr 2026 12:37:53 -0500 Subject: [PATCH] THIS WHOLE PATCH WAS AI GENERATED. Implement support for reading .3tz files, add an example and test. Please note, I'm not a javascript developer, but I tested this on https://s3.us-east-2.wasabisys.com/testing/20260428-3tz-sampledata.3tz and it worked for me. I'm not sure what your AI policy is, feel free to close this if you prefer. Sample dataset was created using npx 3d-tiles-tools convert -i /tmp/tileset/tileset.json -o /tmp/20260428-3tz-sampledata.3tz --- README.md | 1 + example/three/tz3Example.html | 12 + example/three/tz3Example.js | 124 +++++++ src/core/plugins/TZ3Plugin.d.ts | 20 ++ src/core/plugins/TZ3Plugin.js | 97 ++++++ src/core/plugins/index.d.ts | 1 + src/core/plugins/index.js | 1 + src/core/plugins/loaders/ZipArchiveReader.js | 339 +++++++++++++++++++ test/core/ZipArchiveReader.test.js | 202 +++++++++++ 9 files changed, 797 insertions(+) create mode 100644 example/three/tz3Example.html create mode 100644 example/three/tz3Example.js create mode 100644 src/core/plugins/TZ3Plugin.d.ts create mode 100644 src/core/plugins/TZ3Plugin.js create mode 100644 src/core/plugins/loaders/ZipArchiveReader.js create mode 100644 test/core/ZipArchiveReader.test.js diff --git a/README.md b/README.md index 5acbd9a70..82865a5c3 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,7 @@ See the [Three.js usage guide](./USAGE.md) or [Babylon.js usage guide](./src/bab | [Quantized Mesh](https://nasa-ammos.github.io/3DTilesRendererJS/three/quantMeshOverlays.html) | Quantized mesh with overlays | | [Load Region](https://nasa-ammos.github.io/3DTilesRendererJS/three/loadRegion.html) | Loading tiles in region volumes | | [GeoJSON](https://nasa-ammos.github.io/3DTilesRendererJS/three/geojson.html) | GeoJSON overlays | +| [3TZ Archive](https://nasa-ammos.github.io/3DTilesRendererJS/three/tz3Example.html) | Streaming a `.3tz` single-file tileset archive via HTTP range requests | ¹ Requires a [Google Tiles API Key](https://developers.google.com/maps/documentation/tile/3d-tiles) or [Cesium Ion API Key](https://cesium.com/platform/cesium-ion/) diff --git a/example/three/tz3Example.html b/example/three/tz3Example.html new file mode 100644 index 000000000..680c57194 --- /dev/null +++ b/example/three/tz3Example.html @@ -0,0 +1,12 @@ + + + + + + 3D Tiles Renderer — .3tz Archive Example + + + + + + diff --git a/example/three/tz3Example.js b/example/three/tz3Example.js new file mode 100644 index 000000000..4c186b4f6 --- /dev/null +++ b/example/three/tz3Example.js @@ -0,0 +1,124 @@ +import { + TilesRenderer, +} from '3d-tiles-renderer'; +import { + TZ3Plugin, + ImplicitTilingPlugin, +} from '3d-tiles-renderer/plugins'; +import { + Scene, + DirectionalLight, + AmbientLight, + WebGLRenderer, + PerspectiveCamera, + Box3, + Sphere, + Group, +} from 'three'; +import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; + +let camera, controls, scene, renderer, tiles, offsetParent; + +const params = { + // Replace with the URL of a publicly hosted .3tz file. + url: new URLSearchParams( window.location.search ).get( 'url' ) || 'https://s3.us-east-2.wasabisys.com/testing/20260428-3tz-sampledata.3tz', +}; + +init(); +animate(); + +function reinstantiateTiles() { + + if ( tiles ) { + + offsetParent.remove( tiles.group ); + tiles.dispose(); + tiles = null; + + } + + tiles = new TilesRenderer( params.url ); + tiles.registerPlugin( new TZ3Plugin() ); + tiles.registerPlugin( new ImplicitTilingPlugin() ); + + tiles.addEventListener( 'load-tileset', () => { + + const box = new Box3(); + const sphere = new Sphere(); + if ( tiles.getBoundingBox( box ) ) { + + box.getCenter( tiles.group.position ).multiplyScalar( - 1 ); + + } else if ( tiles.getBoundingSphere( sphere ) ) { + + tiles.group.position.copy( sphere.center ).multiplyScalar( - 1 ); + + } + + } ); + + tiles.setCamera( camera ); + tiles.setResolutionFromRenderer( camera, renderer ); + offsetParent.add( tiles.group ); + +} + +function init() { + + scene = new Scene(); + + renderer = new WebGLRenderer( { antialias: true } ); + renderer.setPixelRatio( window.devicePixelRatio ); + renderer.setSize( window.innerWidth, window.innerHeight ); + renderer.setClearColor( 0x151c1f ); + document.body.appendChild( renderer.domElement ); + + camera = new PerspectiveCamera( 60, window.innerWidth / window.innerHeight, 1, 4000 ); + camera.position.set( 400, 400, 400 ); + + controls = new OrbitControls( camera, renderer.domElement ); + controls.screenSpacePanning = false; + controls.minDistance = 1; + controls.maxDistance = 2000; + + const dirLight = new DirectionalLight( 0xffffff, 1.25 ); + dirLight.position.set( 1, 2, 3 ).multiplyScalar( 40 ); + scene.add( dirLight ); + scene.add( new AmbientLight( 0xffffff, 0.2 ) ); + + offsetParent = new Group(); + scene.add( offsetParent ); + + reinstantiateTiles(); + + window.addEventListener( 'resize', onWindowResize, false ); + onWindowResize(); + +} + +function onWindowResize() { + + camera.aspect = window.innerWidth / window.innerHeight; + camera.updateProjectionMatrix(); + renderer.setPixelRatio( window.devicePixelRatio ); + renderer.setSize( window.innerWidth, window.innerHeight ); + +} + +function animate() { + + requestAnimationFrame( animate ); + + controls.update(); + camera.updateMatrixWorld(); + + if ( tiles ) { + + tiles.setResolutionFromRenderer( camera, renderer ); + tiles.update(); + + } + + renderer.render( scene, camera ); + +} diff --git a/src/core/plugins/TZ3Plugin.d.ts b/src/core/plugins/TZ3Plugin.d.ts new file mode 100644 index 000000000..30f692352 --- /dev/null +++ b/src/core/plugins/TZ3Plugin.d.ts @@ -0,0 +1,20 @@ +export interface TZ3PluginOptions { + + /** + * Extra fetch options (headers, credentials, signal, etc.) forwarded to + * every range request made against the archive. + */ + fetchOptions?: RequestInit; + +} + +/** + * Plugin that adds support for the ".3tz" single-file 3D Tiles archive + * format. Intercepts fetches for URLs pointing at a ".3tz" file or any path + * inside one and serves the bytes via HTTP range requests. + */ +export class TZ3Plugin { + + constructor( options?: TZ3PluginOptions ); + +} diff --git a/src/core/plugins/TZ3Plugin.js b/src/core/plugins/TZ3Plugin.js new file mode 100644 index 000000000..cbce6f1db --- /dev/null +++ b/src/core/plugins/TZ3Plugin.js @@ -0,0 +1,97 @@ +import { ZipArchiveReader } from './loaders/ZipArchiveReader.js'; + +// Matches a URL that points to a file path inside a .3tz archive, e.g. +// "https://example.com/foo.3tz/content/0/0.glb". Group 1 is the archive URL +// (including the ".3tz" extension), group 2 is the path inside the archive. +const ARCHIVE_PATH_RE = /^(.+?\.3tz)\/([^?#]+)(\?[^#]*)?(#.*)?$/i; + +// Matches a URL whose pathname ends in ".3tz" (possibly with a query or hash). +const ARCHIVE_ROOT_RE = /^(.+?\.3tz)(\?[^#]*)?(#.*)?$/i; + +/** + * Plugin that teaches the renderer how to load 3D Tiles packaged as a single + * ".3tz" archive. The plugin intercepts fetches for URLs that point at a + * ".3tz" file (or any path inside one) and serves the bytes from the archive + * via HTTP range requests, so callers do not need to unpack the archive + * server-side. + * + * The 3tz spec is a ZIP archive with a root-level "tileset.json"; see + * https://github.com/erikdahlstrom/3tz-specification. + */ +export class TZ3Plugin { + + constructor( options = {} ) { + + this.name = 'TZ3_PLUGIN'; + this.priority = - 100; + this.tiles = null; + this.fetchOptions = options.fetchOptions || null; + this._archives = new Map(); + + } + + init( tiles ) { + + this.tiles = tiles; + + } + + preprocessURL( url, tile ) { + + // Only rewrite the root tileset URL — inner references already carry + // a path inside the archive because base-path derivation uses the + // rewritten URL as a prefix. + if ( tile !== null ) return url; + + const str = String( url ); + const match = str.match( ARCHIVE_ROOT_RE ); + if ( ! match ) return url; + + // If the URL already points into the archive, leave it alone. + if ( ARCHIVE_PATH_RE.test( str ) ) return url; + + const [ , archive, query = '', hash = '' ] = match; + return `${ archive }/tileset.json${ query }${ hash }`; + + } + + fetchData( url, options ) { + + const str = String( url ); + const match = str.match( ARCHIVE_PATH_RE ); + if ( ! match ) return null; + + const [ , archiveUrl, relativePath ] = match; + return this._fetchFromArchive( archiveUrl, relativePath, options ); + + } + + async _fetchFromArchive( archiveUrl, relativePath, options ) { + + let reader = this._archives.get( archiveUrl ); + if ( ! reader ) { + + const fetchFn = ( url, init ) => fetch( url, init ); + reader = new ZipArchiveReader( archiveUrl, fetchFn, this.fetchOptions || {} ); + this._archives.set( archiveUrl, reader ); + + } + + const bytes = await reader.getFile( relativePath, options ); + + // Hand back a real Response so the caller can treat this path the same + // as a plain HTTP fetch (json(), arrayBuffer(), etc.). + return new Response( bytes, { + status: 200, + headers: { 'Content-Length': String( bytes.byteLength ) }, + } ); + + } + + dispose() { + + this._archives.clear(); + + } + +} diff --git a/src/core/plugins/index.d.ts b/src/core/plugins/index.d.ts index 84c01d3f2..4a6d06119 100644 --- a/src/core/plugins/index.d.ts +++ b/src/core/plugins/index.d.ts @@ -2,3 +2,4 @@ export * from './CesiumIonAuthPlugin.js'; export * from './GoogleCloudAuthPlugin.js'; export * from './ImplicitTilingPlugin.js'; export * from './EnforceNonZeroErrorPlugin.js'; +export * from './TZ3Plugin.js'; diff --git a/src/core/plugins/index.js b/src/core/plugins/index.js index 736bb7f49..057fd0930 100644 --- a/src/core/plugins/index.js +++ b/src/core/plugins/index.js @@ -2,6 +2,7 @@ export * from './CesiumIonAuthPlugin.js'; export * from './GoogleCloudAuthPlugin.js'; export * from './ImplicitTilingPlugin.js'; export * from './EnforceNonZeroErrorPlugin.js'; +export * from './TZ3Plugin.js'; export * from './auth/GoogleCloudAuth.js'; export * from './auth/CesiumIonAuth.js'; export * from './loaders/QuantizedMeshLoaderBase.js'; diff --git a/src/core/plugins/loaders/ZipArchiveReader.js b/src/core/plugins/loaders/ZipArchiveReader.js new file mode 100644 index 000000000..78ee0ccc4 --- /dev/null +++ b/src/core/plugins/loaders/ZipArchiveReader.js @@ -0,0 +1,339 @@ +// Minimal random-access reader for ZIP archives served over HTTP using +// range requests. Supports the subset of ZIP required by the 3TZ spec: +// - non-ZIP64 archives (< 4 GiB, < 65535 entries) via the standard EOCD +// - stored (method 0) and deflate (method 8) entries +// Zstd (method 93) is declared unsupported — callers get a clear error. + +const EOCD_SIG = 0x06054b50; +const CD_SIG = 0x02014b50; +const LFH_SIG = 0x04034b50; + +// Maximum size of the EOCD + trailing ZIP comment. The comment length is a +// 16-bit field so 22 + 0xFFFF is an upper bound. +const EOCD_MAX_SIZE = 22 + 0xFFFF; + +// Bytes we speculatively fetch past the LFH fixed header. This covers the +// name + extra fields in the LFH, which can differ from the CD entry. If a +// file's LFH extra field is larger than this buffer we fall back to a second +// range request. +const LFH_EXTRA_BUFFER = 1024; + +export class ZipArchiveReader { + + constructor( url, fetchFn = fetch, fetchOptions = {} ) { + + this.url = url; + this.fetchFn = fetchFn; + this.fetchOptions = fetchOptions; + this.entries = new Map(); + this._ready = null; + + } + + ready( options ) { + + if ( this._ready === null ) { + + this._ready = this._initialize( options ); + + } + + return this._ready; + + } + + async _initialize( options ) { + + // Grab the tail of the file — enough to cover the maximum possible + // EOCD region. We have to know the total file size first because some + // HTTP servers do not correctly honour suffix ranges ("bytes=-N"). + const size = await this._fetchSize( options ); + if ( size === null ) { + + throw new Error( `ZipArchiveReader: Could not determine size of "${ this.url }".` ); + + } + + const tailSize = Math.min( EOCD_MAX_SIZE, size ); + const tailStart = size - tailSize; + const tail = await this._fetchRange( tailStart, size - 1, options ); + const view = new DataView( tail.buffer ); + + // Scan backwards for the EOCD signature. + let eocdOffset = - 1; + for ( let i = view.byteLength - 22; i >= 0; i -- ) { + + if ( view.getUint32( i, true ) === EOCD_SIG ) { + + eocdOffset = i; + break; + + } + + } + + if ( eocdOffset < 0 ) { + + throw new Error( `ZipArchiveReader: Could not find End of Central Directory in "${ this.url }".` ); + + } + + const cdSize = view.getUint32( eocdOffset + 12, true ); + const cdOffset = view.getUint32( eocdOffset + 16, true ); + const totalEntries = view.getUint16( eocdOffset + 10, true ); + + if ( cdOffset === 0xFFFFFFFF || cdSize === 0xFFFFFFFF || totalEntries === 0xFFFF ) { + + throw new Error( `ZipArchiveReader: ZIP64 archives are not supported ("${ this.url }").` ); + + } + + // Pull the central directory, reusing bytes we already have when possible. + let cdBuffer; + if ( cdOffset >= tailStart ) { + + const sliceStart = cdOffset - tailStart; + cdBuffer = tail.buffer.slice( sliceStart, sliceStart + cdSize ); + + } else { + + const range = await this._fetchRange( cdOffset, cdOffset + cdSize - 1, options ); + cdBuffer = range.buffer; + + } + + this._parseCentralDirectory( cdBuffer, totalEntries ); + + } + + _parseCentralDirectory( buffer, totalEntries ) { + + const view = new DataView( buffer ); + const decoder = new TextDecoder( 'utf-8' ); + let offset = 0; + let count = 0; + + while ( offset + 46 <= buffer.byteLength ) { + + if ( view.getUint32( offset, true ) !== CD_SIG ) { + + break; + + } + + const flags = view.getUint16( offset + 8, true ); + const method = view.getUint16( offset + 10, true ); + const compSize = view.getUint32( offset + 20, true ); + const uncompSize = view.getUint32( offset + 24, true ); + const nameLen = view.getUint16( offset + 28, true ); + const extraLen = view.getUint16( offset + 30, true ); + const commentLen = view.getUint16( offset + 32, true ); + const localOffset = view.getUint32( offset + 42, true ); + + if ( compSize === 0xFFFFFFFF || uncompSize === 0xFFFFFFFF || localOffset === 0xFFFFFFFF ) { + + throw new Error( `ZipArchiveReader: ZIP64 entries are not supported ("${ this.url }").` ); + + } + + const nameBytes = new Uint8Array( buffer, offset + 46, nameLen ); + const name = decoder.decode( nameBytes ); + + // Directory entries end with "/" and contain no data — skip them. + if ( ! name.endsWith( '/' ) ) { + + this.entries.set( name, { + name, + method, + compSize, + uncompSize, + nameLen, + localOffset, + flags, + } ); + + } + + offset += 46 + nameLen + extraLen + commentLen; + count ++; + + } + + if ( count !== totalEntries ) { + + // Not fatal — some archives have a mismatched entry count field — + // but worth surfacing during development. + console.warn( `ZipArchiveReader: expected ${ totalEntries } entries, parsed ${ count }.` ); + + } + + } + + async getFile( path, options ) { + + await this.ready( options ); + + const entry = this.entries.get( path ); + if ( ! entry ) { + + throw new Error( `ZipArchiveReader: File "${ path }" not found in archive "${ this.url }".` ); + + } + + // Speculatively pull the LFH together with the file data. The LFH's + // name and extra fields come before the actual bytes; we overshoot the + // size estimate a little so a single range request is usually enough. + const headerEstimate = 30 + entry.nameLen + LFH_EXTRA_BUFFER; + const start = entry.localOffset; + const end = start + headerEstimate + entry.compSize - 1; + const range = await this._fetchRange( start, end, options ); + const view = new DataView( range.buffer ); + + if ( view.getUint32( 0, true ) !== LFH_SIG ) { + + throw new Error( `ZipArchiveReader: Local file header signature mismatch for "${ path }".` ); + + } + + const lfhNameLen = view.getUint16( 26, true ); + const lfhExtraLen = view.getUint16( 28, true ); + const dataOffset = 30 + lfhNameLen + lfhExtraLen; + + let compressed; + if ( dataOffset + entry.compSize <= range.buffer.byteLength ) { + + compressed = new Uint8Array( range.buffer, dataOffset, entry.compSize ); + + } else { + + // The LFH extra field was larger than our speculative buffer — fall + // back to a second fetch that starts at the true data offset. + const dataStart = entry.localOffset + dataOffset; + const dataEnd = dataStart + entry.compSize - 1; + const dataRange = await this._fetchRange( dataStart, dataEnd, options ); + compressed = new Uint8Array( dataRange.buffer ); + + } + + return await decompressEntry( compressed, entry, this.url ); + + } + + async _fetchRange( start, end, options ) { + + const merged = { ...this.fetchOptions, ...( options || {} ) }; + const headers = new Headers( merged.headers || {} ); + headers.set( 'Range', `bytes=${ start }-${ end }` ); + + const response = await this.fetchFn( this.url, { + ...merged, + headers, + } ); + + if ( ! response.ok ) { + + throw new Error( `ZipArchiveReader: Range request failed for "${ this.url }" (status ${ response.status }).` ); + + } + + const buffer = await response.arrayBuffer(); + return { buffer }; + + } + + async _fetchSize( options ) { + + const merged = { ...this.fetchOptions, ...( options || {} ) }; + const headers = new Headers( merged.headers || {} ); + + // A single-byte range is a cheap portable way to probe the total size, + // which the server reports in Content-Range (e.g. "bytes 0-0/12345"). + // HEAD works too but is sometimes blocked by CDN auth middleware that + // only allows GET. + headers.set( 'Range', 'bytes=0-0' ); + + const response = await this.fetchFn( this.url, { + ...merged, + method: 'GET', + headers, + } ); + + if ( ! response.ok ) { + + throw new Error( `ZipArchiveReader: Size probe failed for "${ this.url }" (status ${ response.status }).` ); + + } + + // Drain the body so the connection can be reused. + await response.arrayBuffer(); + + const contentRange = response.headers.get( 'Content-Range' ); + if ( contentRange ) { + + const match = contentRange.match( /bytes\s+\d+-\d+\/(\d+)/i ); + if ( match ) return Number( match[ 1 ] ); + + } + + const contentLength = response.headers.get( 'Content-Length' ); + if ( contentLength ) { + + // Server ignored the Range header — the body length equals the full size. + return Number( contentLength ); + + } + + return null; + + } + +} + +async function decompressEntry( compressed, entry, url ) { + + if ( entry.method === 0 ) { + + return compressed; + + } + + if ( entry.method === 8 ) { + + return await decompressWithStream( compressed, 'deflate-raw', entry, url ); + + } + + if ( entry.method === 93 ) { + + return await decompressWithStream( compressed, 'zstd', entry, url ); + + } + + throw new Error( `ZipArchiveReader: Unsupported compression method ${ entry.method } for "${ entry.name }" in "${ url }".` ); + +} + +async function decompressWithStream( compressed, format, entry, url ) { + + if ( typeof DecompressionStream === 'undefined' ) { + + throw new Error( `ZipArchiveReader: DecompressionStream is unavailable — cannot decode "${ format }" entry "${ entry.name }" in "${ url }".` ); + + } + + let decompressor; + try { + + decompressor = new DecompressionStream( format ); + + } catch ( err ) { + + throw new Error( `ZipArchiveReader: This runtime does not support DecompressionStream("${ format }") — cannot decode "${ entry.name }" in "${ url }". Upgrade to a newer browser or Node version that implements the format.`, { cause: err } ); + + } + + const stream = new Blob( [ compressed ] ).stream().pipeThrough( decompressor ); + const buffer = await new Response( stream ).arrayBuffer(); + return new Uint8Array( buffer ); + +} diff --git a/test/core/ZipArchiveReader.test.js b/test/core/ZipArchiveReader.test.js new file mode 100644 index 000000000..0b6fcc199 --- /dev/null +++ b/test/core/ZipArchiveReader.test.js @@ -0,0 +1,202 @@ +import { readFileSync, statSync, existsSync } from 'fs'; +import { ZipArchiveReader } from '../../src/core/plugins/loaders/ZipArchiveReader.js'; +import { TZ3Plugin } from '../../src/core/plugins/TZ3Plugin.js'; +import { TilesRendererBase } from '../../src/core/renderer/index.js'; + +const FIXTURE_PATH = '/tmp/TilesetOfTilesets.3tz'; + +// Serve a local file to the reader by responding to Range requests so we can +// exercise the range-request code path without real HTTP. +function makeFixtureFetch( filePath ) { + + const fileSize = statSync( filePath ).size; + return async ( _url, init ) => { + + const header = init && init.headers; + const range = header instanceof Headers ? header.get( 'Range' ) : ( header && header.Range ); + let start = 0; + let end = fileSize - 1; + if ( range ) { + + const m = range.match( /bytes=(-?\d+)(?:-(\d+))?/ ); + if ( m ) { + + const a = Number( m[ 1 ] ); + if ( a < 0 ) { + + start = Math.max( 0, fileSize + a ); + end = fileSize - 1; + + } else { + + start = a; + end = m[ 2 ] ? Math.min( fileSize - 1, Number( m[ 2 ] ) ) : fileSize - 1; + + } + + } + + } + + const length = end - start + 1; + const fd = readFileSync( filePath ).subarray( start, end + 1 ); + const body = new Uint8Array( fd ); + return new Response( body, { + status: range ? 206 : 200, + headers: { + 'Content-Range': `bytes ${ start }-${ end }/${ fileSize }`, + 'Content-Length': String( length ), + }, + } ); + + }; + +} + +const hasFixture = existsSync( FIXTURE_PATH ); + +describe.skipIf( ! hasFixture )( 'ZipArchiveReader', () => { + + it( 'indexes the central directory from a real 3tz file', async () => { + + const reader = new ZipArchiveReader( FIXTURE_PATH, makeFixtureFetch( FIXTURE_PATH ) ); + await reader.ready(); + + expect( reader.entries.has( 'tileset.json' ) ).toBe( true ); + expect( reader.entries.has( '@3dtilesIndex1@' ) ).toBe( true ); + expect( reader.entries.size ).toBeGreaterThan( 1 ); + + } ); + + it( 'reads tileset.json as valid JSON', async () => { + + const reader = new ZipArchiveReader( FIXTURE_PATH, makeFixtureFetch( FIXTURE_PATH ) ); + const bytes = await reader.getFile( 'tileset.json' ); + const json = JSON.parse( new TextDecoder().decode( bytes ) ); + + expect( json.asset ).toBeDefined(); + expect( json.root ).toBeDefined(); + + } ); + + it( 'reads a binary tile entry at the right length', async () => { + + const reader = new ZipArchiveReader( FIXTURE_PATH, makeFixtureFetch( FIXTURE_PATH ) ); + await reader.ready(); + + const picked = [ ...reader.entries.values() ] + .find( e => e.name !== 'tileset.json' && e.name !== '@3dtilesIndex1@' ); + + const bytes = await reader.getFile( picked.name ); + expect( bytes.byteLength ).toBe( picked.uncompSize ); + + } ); + +} ); + +describe.skipIf( ! hasFixture )( 'TZ3Plugin (end-to-end)', () => { + + function patchPluginFetch( plugin, filePath ) { + + const fixtureFetch = makeFixtureFetch( filePath ); + const origFetch = plugin._fetchFromArchive.bind( plugin ); + plugin._fetchFromArchive = ( archiveUrl, relPath, options ) => { + + // Re-use the plugin's archive cache but route range requests at + // the archive URL through the fixture fetch. + const archives = plugin._archives; + if ( ! archives.has( archiveUrl ) ) { + + archives.set( archiveUrl, new ZipArchiveReader( archiveUrl, fixtureFetch, options || {} ) ); + + } + + return origFetch( archiveUrl, relPath, options ); + + }; + + } + + it( 'loads a root tileset through TilesRendererBase', async () => { + + globalThis.window = { location: { href: 'http://localhost/' } }; + + const tiles = new TilesRendererBase(); + tiles.rootURL = 'http://localhost/TilesetOfTilesets.3tz'; + + const plugin = new TZ3Plugin(); + patchPluginFetch( plugin, FIXTURE_PATH ); + tiles.registerPlugin( plugin ); + + try { + + const root = await tiles.loadRootTileset(); + expect( root.asset ).toBeDefined(); + expect( root.root ).toBeDefined(); + + } finally { + + delete globalThis.window; + + } + + } ); + +} ); + +describe.skipIf( ! hasFixture )( 'ZipArchiveReader zstd handling', () => { + + it( 'throws a clear error on zstd entries when the runtime lacks DecompressionStream("zstd")', async () => { + + const reader = new ZipArchiveReader( FIXTURE_PATH, makeFixtureFetch( FIXTURE_PATH ) ); + await reader.ready(); + + const victim = [ ...reader.entries.values() ] + .find( e => e.name !== 'tileset.json' && e.name !== '@3dtilesIndex1@' ); + victim.method = 93; + + // Whether the runtime supports zstd or not, feeding the decoder bytes + // that were stored uncompressed must surface as a rejected promise + // with a descriptive error — never as a silent miss. + await expect( reader.getFile( victim.name ) ).rejects.toThrow(); + + } ); + +} ); + +describe( 'TZ3Plugin', () => { + + it( 'rewrites a root .3tz URL to append /tileset.json', () => { + + const plugin = new TZ3Plugin(); + expect( plugin.preprocessURL( 'https://ex.com/a.3tz', null ) ) + .toBe( 'https://ex.com/a.3tz/tileset.json' ); + expect( plugin.preprocessURL( 'https://ex.com/a.3tz?t=1', null ) ) + .toBe( 'https://ex.com/a.3tz/tileset.json?t=1' ); + + } ); + + it( 'leaves non-archive URLs alone', () => { + + const plugin = new TZ3Plugin(); + expect( plugin.preprocessURL( 'https://ex.com/tileset.json', null ) ) + .toBe( 'https://ex.com/tileset.json' ); + + } ); + + it( 'does not rewrite URLs already inside the archive', () => { + + const plugin = new TZ3Plugin(); + expect( plugin.preprocessURL( 'https://ex.com/a.3tz/tileset.json', null ) ) + .toBe( 'https://ex.com/a.3tz/tileset.json' ); + + } ); + + it( 'ignores fetchData for non-archive URLs', () => { + + const plugin = new TZ3Plugin(); + expect( plugin.fetchData( 'https://ex.com/tileset.json', {} ) ).toBe( null ); + + } ); + +} );