diff --git a/docs/README.mdx b/docs/README.mdx index cb0ea717ba..85cd74d710 100644 --- a/docs/README.mdx +++ b/docs/README.mdx @@ -35,7 +35,7 @@ loaders.gl provides a wide selection of loaders organized into categories: | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | [Table Loaders](/docs/specifications/category-table) | Streaming tabular loaders for [CSV](/docs/modules/csv/api-reference/csv-loader), [JSON](/docs/modules/json/api-reference/json-loader), [Arrow](/docs/modules/arrow/api-reference/arrow-loader) etc | | [Geospatial Loaders](/docs/specifications/category-gis) | Loaders for geospatial formats such as [GeoJSON](/docs/modules/json/api-reference/geojson-loader) [KML](/docs/modules/kml/api-reference/kml-loader), [WKT/WKB](/docs/modules/wkt/api-reference/wkt-loader), [Mapbox Vector Tiles](/docs/modules/mvt/api-reference/mvt-loader) etc. | -| [Image Loaders](/docs/specifications/category-image) | Loaders for [images](/docs/modules/images/api-reference/image-loader), [compressed textures](/docs/modules/textures/api-reference/compressed-texture-loader), [supercompressed textures (Basis)](/docs/modules/textures/api-reference/basis-loader). Utilities for [mipmapped arrays](/docs/modules/textures/api-reference/load-image-array), [cubemaps](/docs/modules/textures/api-reference/load-image-cube), [binary images](/docs/modules/images/api-reference/binary-image-api) and more. | +| [Image Loaders](/docs/specifications/category-image) | Loaders for [images](/docs/modules/images/api-reference/image-loader), [compressed textures](/docs/modules/textures/api-reference/compressed-texture-loader), [supercompressed textures (Basis)](/docs/modules/textures/api-reference/basis-loader), [composite image textures](/docs/modules/textures/api-reference/image-texture-cube-loader), [binary images](/docs/modules/images/api-reference/binary-image-api) and more. | | [Pointcloud and Mesh Loaders](/docs/specifications/category-mesh) | Loaders for point cloud and simple mesh formats such as [Draco](/docs/modules/draco/api-reference/draco-loader), [LAS](/docs/modules/las/api-reference/las-loader), [PCD](/docs/modules/pcd/api-reference/pcd-loader), [PLY](/docs/modules/ply/api-reference/ply-loader), [OBJ](/docs/modules/obj/api-reference/obj-loader), and [Terrain](/docs/modules/terrain/api-reference/terrain-loader). | | [Scenegraph Loaders](/docs/specifications/category-scenegraph) | [glTF](/docs/modules/gltf/api-reference/gltf-loader) loader | | [Tiled Data Loaders](/docs/specifications/category-3d-tiles) | Loaders for 3D tile formats such as [3D Tiles](/docs/modules/3d-tiles/api-reference/tiles-3d-loader), [I3S](/docs/modules/i3s/api-reference/i3s-loader) and potree | diff --git a/docs/developer-guide/composite-loaders.md b/docs/developer-guide/composite-loaders.md index b119fc5da9..1a8bd3e48d 100644 --- a/docs/developer-guide/composite-loaders.md +++ b/docs/developer-guide/composite-loaders.md @@ -44,3 +44,25 @@ The `parse*WithContext()` functions exported by `@loaders.gl/loader-utils` are t ## LoaderContext When a loader is being called (i.e. one of its `parse*()` functions is being called), a `LoaderContext` object is supplied. + +## Base URL Handling + +Composite loaders should resolve relative sub-resources from `context.baseUrl`. + +- `load(url, loader)` derives the effective base URL from the top-level resource URL and stores it on `context.baseUrl` +- subloader and associated-resource resolution should prefer `context.baseUrl` +- `options.core.baseUrl` is only a fallback for entrypoints that do not have a source URL, such as `parse(text, loader, options)` +- once a loader has a context, it should not keep forwarding `core.baseUrl` into subloader options; child loads should resolve from their own context instead + +This keeps base URL state in one place and avoids passing ad hoc base values between composite loaders. + +## Forwarding Loader Lists + +When a composite loader parses sub-resources, it should preserve the top-level loader list so callers can provide additional member loaders. + +- prefer calling subloaders with an array such as `[ImageLoader]`, not a single forced loader +- preserve `context.loaders` so caller-supplied loaders can participate in selection +- when possible, parse the fetched `Response` rather than a detached `ArrayBuffer`, so subloader selection can still use the member URL and MIME type +- if a child resource has its own URL, update the child context so downstream loaders see the correct `context.url` and `context.baseUrl` + +This pattern lets a composite loader provide a default member loader while still allowing applications to extend subresource handling. diff --git a/docs/docs-sidebar.json b/docs/docs-sidebar.json index efef2021c5..5469ae923c 100644 --- a/docs/docs-sidebar.json +++ b/docs/docs-sidebar.json @@ -429,9 +429,10 @@ "items": [ "modules/textures/README", "modules/textures/api-reference/radiance-hdr-loader", - "modules/textures/api-reference/load-image", - "modules/textures/api-reference/load-image-array", - "modules/textures/api-reference/load-image-cube" + "modules/textures/api-reference/texture-loader", + "modules/textures/api-reference/texture-array-loader", + "modules/textures/api-reference/texture-cube-loader", + "modules/textures/api-reference/texture-cube-array-loader" ] }, { diff --git a/docs/modules/gltf/api-reference/post-process-gltf.md b/docs/modules/gltf/api-reference/post-process-gltf.md index d1ec94c284..6666d468e6 100644 --- a/docs/modules/gltf/api-reference/post-process-gltf.md +++ b/docs/modules/gltf/api-reference/post-process-gltf.md @@ -99,7 +99,7 @@ Remarks: ## Images - `image.image` - Populated from the supplied `gltf.images` array. This array is populated by the `GLTFLoader` via `options.loadImages: true`): -- `image.uri` - If loaded image in the `images` array is not available, uses `gltf.baseUri` or `options.baseUri` is available, to resolve a relative URI and replaces this value. +- `image.uri` - If the loaded image in the `images` array is not available, uses `gltf.baseUri` to resolve a relative URI and replaces this value. ### Materials diff --git a/docs/modules/textures/README.md b/docs/modules/textures/README.md index 5df624033e..e5769a2036 100644 --- a/docs/modules/textures/README.md +++ b/docs/modules/textures/README.md @@ -41,6 +41,10 @@ The `@loaders.gl/textures` module handles the following formats: | [`CompressedTextureLoader`](/docs/modules/textures/api-reference/compressed-texture-loader) | KTX, DDS and PVR mip chains as `TextureLevel[]` | | [`RadianceHDRLoader`](/docs/modules/textures/api-reference/radiance-hdr-loader) | Radiance `.hdr` textures as `Texture` | | [`CrunchWorkerLoader`](/docs/modules/textures/api-reference/crunch-loader) | Crunch mip chains as `TextureLevel[]` | +| [`TextureLoader`](/docs/modules/textures/api-reference/texture-loader) | Manifest-driven single image or mip chain | +| [`TextureArrayLoader`](/docs/modules/textures/api-reference/texture-array-loader) | Manifest-driven texture arrays | +| [`TextureCubeLoader`](/docs/modules/textures/api-reference/texture-cube-loader) | Manifest-driven cubemaps | +| [`TextureCubeArrayLoader`](/docs/modules/textures/api-reference/texture-cube-array-loader) | Manifest-driven cube arrays | ## Return Types @@ -72,19 +76,18 @@ A `TextureLevel` describes one mip level of one texture image. See [`BasisLoader`](/docs/modules/textures/api-reference/basis-loader) and [`CompressedTextureLoader`](/docs/modules/textures/api-reference/compressed-texture-loader) for loader-specific options and return shapes. -## Texture APIs +## Composite Image Loaders -The textures API offers functions to load "composite" images for WebGL textures, cube textures and image mip levels. +The textures module also includes manifest-driven loaders for composite image textures: -These functions take a `getUrl` parameter that enables the app to supply the url for each "sub-image", and return a single promise enabling applications to for instance load all the faces of a cube texture, with one image for each mip level for each face in a single async operation. +- [`TextureLoader`](/docs/modules/textures/api-reference/texture-loader) for a single image or mip chain +- [`TextureArrayLoader`](/docs/modules/textures/api-reference/texture-array-loader) for texture arrays, including mipmapped layers +- [`TextureCubeLoader`](/docs/modules/textures/api-reference/texture-cube-loader) for cubemaps, including mipmapped faces +- [`TextureCubeArrayLoader`](/docs/modules/textures/api-reference/texture-cube-array-loader) for cube arrays -| Function | Description | -| ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | -| [`loadImage`](/docs/modules/textures/api-reference/load-image) | Load a single image | -| [`loadImageArray`](/docs/modules/textures/api-reference/load-image-array) | Load an array of images, e.g. for a `Texture2DArray` or `Texture3D` | -| [`loadImageCube`](/docs/modules/textures/api-reference/load-image-cube) | Load a map of 6 images for the faces of a cube map, or a map of 6 arrays of images for the mip levels of the 6 faces. | - -As with all loaders.gl functions, while these functions are intended for use in WebGL applications, they do not call any WebGL functions, and do not actually create any WebGL textures.. +These loaders resolve relative member URLs against the manifest URL, or against `options.core.baseUrl` when parsing an in-memory manifest. +Member assets are parsed with `ImageLoader` by default, and additional loaders passed to top-level `load()` are also available for manifest members. +They return schema `Texture` objects rather than raw image trees. ## Attributions diff --git a/docs/modules/textures/api-reference/radiance-hdr-loader.md b/docs/modules/textures/api-reference/radiance-hdr-loader.md index d6ac860641..e186e5d7be 100644 --- a/docs/modules/textures/api-reference/radiance-hdr-loader.md +++ b/docs/modules/textures/api-reference/radiance-hdr-loader.md @@ -8,13 +8,13 @@ Loader for Radiance RGBE `.hdr` textures. See also: [`Radiance HDR`](/docs/modules/textures/formats/hdr) -| Loader | Characteristic | -| -------------- | ---------------------------- | -| File Format | Radiance HDR / RGBE | -| File Extension | `.hdr` | -| File Type | Binary | -| Data Format | `Texture` | -| Supported APIs | `load`, `parse`, `parseSync` | +| Loader | Characteristic | +| -------------- | ----------------------------------------------------------- | +| File Format | Radiance HDR / RGBE | +| File Extension | `.hdr` | +| File Type | Binary | +| Data Format | [`Texture`](/docs/modules/textures/README#texture-category) | +| Supported APIs | `load`, `parse`, `parseSync` | ## Usage diff --git a/docs/modules/textures/api-reference/texture-array-loader.md b/docs/modules/textures/api-reference/texture-array-loader.md new file mode 100644 index 0000000000..ed4089b52d --- /dev/null +++ b/docs/modules/textures/api-reference/texture-array-loader.md @@ -0,0 +1,84 @@ +# TextureArrayLoader + +

+ From-v4.4 +

+ +A loader for texture arrays described by a JSON manifest. + +| Loader | Characteristic | +| -------------- | ----------------------------------------------------------- | +| File Format | JSON manifest | +| File Extension | `.json` | +| File Type | Text | +| Data Format | [`Texture`](/docs/modules/textures/README#texture-category) | +| Supported APIs | `load`, `parse` | + +## Usage + +```typescript +import {load} from '@loaders.gl/core'; +import {TextureArrayLoader} from '@loaders.gl/textures'; + +const images = await load('texture-array.image-texture-array.json', TextureArrayLoader); +``` + +Member images are parsed with `ImageLoader` by default. If you pass a loader array to `load()`, those additional loaders are also available for array layers and mip levels. + +## Manifest + +Texture array: + +```json +{ + "shape": "image-texture-array", + "layers": ["layer-0.png", "layer-1.png"] +} +``` + +Texture array with mipmaps: + +```json +{ + "shape": "image-texture-array", + "layers": [ + ["layer-0-0.png", "layer-0-1.png"], + ["layer-1-0.png", "layer-1-1.png"] + ] +} +``` + +Each entry in `layers` can be either: + +- a single image path +- an array of image paths representing mip levels +- a template source object + +Template source example: + +```json +{ + "shape": "image-texture-array", + "layers": [ + {"mipLevels": "auto", "template": "layer-{index}-{lod}.png"}, + {"mipLevels": "auto", "template": "layer-{index}-{lod}.png"} + ] +} +``` + +Supported template placeholders are `{lod}` and `{index}`. +Use `\\{` and `\\}` to include literal braces in filenames. + +## Options + +| Option | Type | Default | Description | +| -------------- | -------- | ------- | ---------------------------------------------------------------------------------- | +| `core.baseUrl` | `string` | - | Base URL used to resolve relative member paths when parsing an in-memory manifest. | + +## Output + +Returns a `Texture` with: + +- `shape: 'texture'` +- `type: '2d-array'` +- `data`: one mip chain per array layer diff --git a/docs/modules/textures/api-reference/texture-cube-array-loader.md b/docs/modules/textures/api-reference/texture-cube-array-loader.md new file mode 100644 index 0000000000..52a7ff5a65 --- /dev/null +++ b/docs/modules/textures/api-reference/texture-cube-array-loader.md @@ -0,0 +1,102 @@ +# TextureCubeArrayLoader + +

+ From-v4.4 +

+ +A loader for texture cube arrays described by a JSON manifest. + +| Loader | Characteristic | +| -------------- | ----------------------------------------------------------- | +| File Format | JSON manifest | +| File Extension | `.json` | +| File Type | Text | +| Data Format | [`Texture`](/docs/modules/textures/README#texture-category) | +| Supported APIs | `load`, `parse` | + +## Usage + +```typescript +import {load} from '@loaders.gl/core'; +import {TextureCubeArrayLoader} from '@loaders.gl/textures'; + +const imageCubeArray = await load( + 'environment.image-texture-cube-array.json', + TextureCubeArrayLoader +); +``` + +Member faces are parsed with `ImageLoader` by default. If you pass a loader array to `load()`, those additional loaders are also available for cube-array faces and mip levels. + +## Manifest + +```json +{ + "shape": "image-texture-cube-array", + "layers": [ + { + "faces": { + "+X": "sky-right.png", + "-X": "sky-left.png", + "+Y": "sky-top.png", + "-Y": "sky-bottom.png", + "+Z": "sky-front.png", + "-Z": "sky-back.png" + } + }, + { + "faces": { + "+X": "irr-right.png", + "-X": "irr-left.png", + "+Y": "irr-top.png", + "-Y": "irr-bottom.png", + "+Z": "irr-front.png", + "-Z": "irr-back.png" + } + } + ] +} +``` + +Each layer is a cubemap manifest fragment. Each face entry can be either: + +- a single image path +- an array of image paths representing mip levels +- a template source object + +Template source example: + +```json +{ + "shape": "image-texture-cube-array", + "layers": [ + { + "faces": { + "+X": {"mipLevels": "auto", "template": "cube-{index}-{face}-{lod}.png"}, + "-X": {"mipLevels": "auto", "template": "cube-{index}-{face}-{lod}.png"}, + "+Y": {"mipLevels": "auto", "template": "cube-{index}-{face}-{lod}.png"}, + "-Y": {"mipLevels": "auto", "template": "cube-{index}-{face}-{lod}.png"}, + "+Z": {"mipLevels": "auto", "template": "cube-{index}-{face}-{lod}.png"}, + "-Z": {"mipLevels": "auto", "template": "cube-{index}-{face}-{lod}.png"} + } + } + ] +} +``` + +Supported template placeholders are `{lod}`, `{index}`, `{face}`, `{direction}`, `{axis}`, and `{sign}`. +Use `\\{` and `\\}` to include literal braces in filenames. + +## Options + +| Option | Type | Default | Description | +| -------------- | -------- | ------- | ---------------------------------------------------------------------------------- | +| `core.baseUrl` | `string` | - | Base URL used to resolve relative member paths when parsing an in-memory manifest. | + +## Output + +Returns a `Texture` with: + +- `shape: 'texture'` +- `type: 'cube-array'` +- `data`: one cubemap per layer, with one mip chain per face diff --git a/docs/modules/textures/api-reference/texture-cube-loader.md b/docs/modules/textures/api-reference/texture-cube-loader.md new file mode 100644 index 0000000000..8559a761e4 --- /dev/null +++ b/docs/modules/textures/api-reference/texture-cube-loader.md @@ -0,0 +1,101 @@ +# TextureCubeLoader + +

+ From-v4.4 +

+ +A loader for cubemaps described by a JSON manifest. + +| Loader | Characteristic | +| -------------- | ----------------------------------------------------------- | +| File Format | JSON manifest | +| File Extension | `.json` | +| File Type | Text | +| Data Format | [`Texture`](/docs/modules/textures/README#texture-category) | +| Supported APIs | `load`, `parse` | + +## Usage + +```typescript +import {load} from '@loaders.gl/core'; +import {TextureCubeLoader} from '@loaders.gl/textures'; + +const imageCube = await load('environment.image-texture-cube.json', TextureCubeLoader); +``` + +Member faces are parsed with `ImageLoader` by default. If you pass a loader array to `load()`, those additional loaders are also available for cubemap faces and mip levels. + +## Manifest + +Cubemap: + +```json +{ + "shape": "image-texture-cube", + "faces": { + "+X": "right.png", + "-X": "left.png", + "+Y": "top.png", + "-Y": "bottom.png", + "+Z": "front.png", + "-Z": "back.png" + } +} +``` + +Cubemap with mipmaps: + +```json +{ + "shape": "image-texture-cube", + "faces": { + "+X": ["right-0.png", "right-1.png"], + "-X": ["left-0.png", "left-1.png"], + "+Y": ["top-0.png", "top-1.png"], + "-Y": ["bottom-0.png", "bottom-1.png"], + "+Z": ["front-0.png", "front-1.png"], + "-Z": ["back-0.png", "back-1.png"] + } +} +``` + +Face names follow luma.gl conventions: `'+X'`, `'-X'`, `'+Y'`, `'-Y'`, `'+Z'`, `'-Z'`. + +Each face entry can be either: + +- a single image path +- an array of image paths representing mip levels +- a template source object + +Template source example: + +```json +{ + "shape": "image-texture-cube", + "faces": { + "+X": {"mipLevels": "auto", "template": "cube-{face}-{lod}.png"}, + "-X": {"mipLevels": "auto", "template": "cube-{face}-{lod}.png"}, + "+Y": {"mipLevels": "auto", "template": "cube-{face}-{lod}.png"}, + "-Y": {"mipLevels": "auto", "template": "cube-{face}-{lod}.png"}, + "+Z": {"mipLevels": "auto", "template": "cube-{face}-{lod}.png"}, + "-Z": {"mipLevels": "auto", "template": "cube-{face}-{lod}.png"} + } +} +``` + +Supported template placeholders are `{lod}`, `{face}`, `{direction}`, `{axis}`, and `{sign}`. +Use `\\{` and `\\}` to include literal braces in filenames. + +## Options + +| Option | Type | Default | Description | +| -------------- | -------- | ------- | ---------------------------------------------------------------------------------- | +| `core.baseUrl` | `string` | - | Base URL used to resolve relative member paths when parsing an in-memory manifest. | + +## Output + +Returns a `Texture` with: + +- `shape: 'texture'` +- `type: 'cube'` +- `data`: one mip chain per cube face, in luma.gl face order diff --git a/docs/modules/textures/api-reference/texture-loader.md b/docs/modules/textures/api-reference/texture-loader.md new file mode 100644 index 0000000000..32796cec78 --- /dev/null +++ b/docs/modules/textures/api-reference/texture-loader.md @@ -0,0 +1,88 @@ +# TextureLoader + +

+ From-v4.4 +

+ +A loader for image-based composite textures described by a JSON manifest. + +| Loader | Characteristic | +| -------------- | ----------------------------------------------------------- | +| File Format | JSON manifest | +| File Extension | `.json` | +| File Type | Text | +| Data Format | [`Texture`](/docs/modules/textures/README#texture-category) | +| Supported APIs | `load`, `parse` | + +## Usage + +```typescript +import {load} from '@loaders.gl/core'; +import {TextureLoader} from '@loaders.gl/textures'; + +const image = await load('texture.image-texture.json', TextureLoader); +``` + +Member images are parsed with `ImageLoader` by default. If you call `load()` with a loader array, those additional loaders are also available for manifest members: + +```typescript +import {load} from '@loaders.gl/core'; +import {TextureLoader, CompressedTextureLoader, BasisLoader} from '@loaders.gl/textures'; + +const texture = await load('texture.image-texture.json', [ + TextureLoader, + CompressedTextureLoader, + BasisLoader +]); +``` + +This allows manifest members to use image formats handled by `ImageLoader` as well as compressed member formats handled by the additional loaders. + +## Manifest + +Single image: + +```json +{ + "shape": "image-texture", + "image": "texture.png" +} +``` + +Mipmapped image: + +```json +{ + "shape": "image-texture", + "mipmaps": ["texture-0.png", "texture-1.png", "texture-2.png"] +} +``` + +Template-driven mipmapped image: + +```json +{ + "shape": "image-texture", + "mipLevels": "auto", + "template": "texture-{lod}.png" +} +``` + +Template placeholders are validated strictly. Supported placeholders for `TextureLoader` are `{lod}` only. +Use `\\{` and `\\}` to include literal braces in filenames. + +## Options + +| Option | Type | Default | Description | +| -------------- | -------- | ------- | ---------------------------------------------------------------------------------- | +| `core.baseUrl` | `string` | - | Base URL used to resolve relative member paths when parsing an in-memory manifest. | + +## Output + +Returns a `Texture` with: + +- `shape: 'texture'` +- `type: '2d'` +- `data`: one `TextureLevel` per mip level + +For image-backed levels, `TextureLevel.imageBitmap` is populated when available and `TextureLevel.data` is an empty `Uint8Array`. diff --git a/docs/upgrade-guide.md b/docs/upgrade-guide.md index c358b5c957..9edc74b439 100644 --- a/docs/upgrade-guide.md +++ b/docs/upgrade-guide.md @@ -9,6 +9,12 @@ - `@loaders.gl/textures` no longer exports `selectSupportedBasisFormat` or `getSupportedGPUTextureFormats`. Pass `basis.supportedTextureFormats` to `BasisLoader` instead of using exported auto-detection helpers. - `BasisLoader`, `CrunchLoader`, and `CompressedTextureLoader` no longer support `libraryPath`. Supply runtime libraries through `options.modules` instead. +| Deprecated helper | Replacement | +| ------------------------- | ------------------------------- | +| `loadImageTexture()` | `load(url, TextureLoader)` | +| `loadImageTextureArray()` | `load(url, TextureArrayLoader)` | +| `loadImageTextureCube()` | `load(url, TextureCubeLoader)` | + **@loaders.gl/draco** - `DracoLoader` no longer supports `draco.libraryPath`. Supply `modules: {draco3d}` instead of configuring decoder library paths manually. diff --git a/modules/core/src/lib/api/load.ts b/modules/core/src/lib/api/load.ts index 24ea6a6f9f..eed397c4a8 100644 --- a/modules/core/src/lib/api/load.ts +++ b/modules/core/src/lib/api/load.ts @@ -15,6 +15,7 @@ import type { import {isBlob} from '@loaders.gl/loader-utils'; import {isLoaderObject} from '../loader-utils/normalize-loader'; import {getFetchFunction} from '../loader-utils/get-fetch-function'; +import {normalizeLoaderOptions} from '../loader-utils/option-utils'; import {parse} from './parse'; @@ -97,6 +98,19 @@ export async function load( data = await fetch(url); } + if (typeof url === 'string') { + const normalizedOptions = normalizeLoaderOptions(resolvedOptions || {}); + if (!normalizedOptions.core?.baseUrl) { + resolvedOptions = { + ...resolvedOptions, + core: { + ...resolvedOptions?.core, + baseUrl: url + } + }; + } + } + // Data is loaded (at least we have a `Response` object) so time to hand over to `parse` // return await parse(data, loaders as Loader[], options); return Array.isArray(resolvedLoaders) diff --git a/modules/core/src/lib/api/select-loader.ts b/modules/core/src/lib/api/select-loader.ts index f96d9ede3b..d7f7a8516d 100644 --- a/modules/core/src/lib/api/select-loader.ts +++ b/modules/core/src/lib/api/select-loader.ts @@ -47,6 +47,19 @@ export async function selectLoader( const normalizedOptions = normalizeLoaderOptions(options || {}); normalizedOptions.core ||= {}; + if (data instanceof Response && mayContainText(data)) { + const text = await data.clone().text(); + const textLoader = selectLoaderSync( + text, + loaders, + {...normalizedOptions, core: {...normalizedOptions.core, nothrow: true}}, + context + ); + if (textLoader) { + return textLoader; + } + } + // First make a sync attempt, disabling exceptions let loader = selectLoaderSync( data, @@ -65,6 +78,11 @@ export async function selectLoader( loader = selectLoaderSync(data, loaders, normalizedOptions, context); } + if (!loader && data instanceof Response && mayContainText(data)) { + const text = await data.clone().text(); + loader = selectLoaderSync(text, loaders, normalizedOptions, context); + } + // no loader available if (!loader && !normalizedOptions.core.nothrow) { throw new Error(getNoValidLoaderMessage(data)); @@ -73,6 +91,14 @@ export async function selectLoader( return loader; } +function mayContainText(response: Response): boolean { + const mimeType = getResourceMIMEType(response); + return Boolean( + mimeType && + (mimeType.startsWith('text/') || mimeType === 'application/json' || mimeType.endsWith('+json')) + ); +} + /** * Find a loader that matches file extension and/or initial file content * Search the loaders array argument for a loader that matches url extension or initial data diff --git a/modules/core/src/lib/loader-utils/option-defaults.ts b/modules/core/src/lib/loader-utils/option-defaults.ts index 69c772c443..343f5e808e 100644 --- a/modules/core/src/lib/loader-utils/option-defaults.ts +++ b/modules/core/src/lib/loader-utils/option-defaults.ts @@ -8,8 +8,8 @@ import {ConsoleLog} from './loggers'; export const DEFAULT_LOADER_OPTIONS: LoaderOptions = { core: { - baseUri: undefined, - // baseUri + baseUrl: undefined, + // baseUrl fetch: null, mimeType: undefined, fallbackMimeType: undefined, @@ -36,8 +36,8 @@ export const DEFAULT_LOADER_OPTIONS: LoaderOptions = { }; export const REMOVED_LOADER_OPTIONS = { - // baseUri - baseUri: 'core.baseUri', + // deprecated top-level alias + baseUri: 'core.baseUrl', fetch: 'core.fetch', mimeType: 'core.mimeType', fallbackMimeType: 'core.fallbackMimeType', @@ -65,7 +65,7 @@ export const REMOVED_LOADER_OPTIONS = { // Older deprecations throws: 'nothrow', dataType: '(no longer used)', - uri: 'baseUri', + uri: 'core.baseUrl', // Warn if fetch options are used on toplevel method: 'core.fetch.method', diff --git a/modules/core/src/lib/loader-utils/option-utils.ts b/modules/core/src/lib/loader-utils/option-utils.ts index b3e6400319..147da5e89d 100644 --- a/modules/core/src/lib/loader-utils/option-utils.ts +++ b/modules/core/src/lib/loader-utils/option-utils.ts @@ -8,13 +8,15 @@ import { registerJSModules, isPureObject, isObject, - StrictLoaderOptions + StrictLoaderOptions, + path } from '@loaders.gl/loader-utils'; import {probeLog, NullLog} from './loggers'; import {DEFAULT_LOADER_OPTIONS, REMOVED_LOADER_OPTIONS} from './option-defaults'; +import {stripQueryString} from '../utils/url-utils'; const CORE_LOADER_OPTION_KEYS = [ - 'baseUri', + 'baseUrl', 'fetch', 'mimeType', 'fallbackMimeType', @@ -91,7 +93,7 @@ export function setGlobalOptions(options: LoaderOptions): void { } /** - * Merges options with global opts and loader defaults, also injects baseUri + * Merges options with global opts and loader defaults, also injects baseUrl * @param options * @param loader * @param loaders @@ -262,8 +264,7 @@ function mergeNestedFields(mergedOptions: LoaderOptions, options: LoaderOptions) /** * Harvest information from the url - * @deprecated This is mainly there to support a hack in the GLTFLoader - * TODO - baseUri should be a directory, i.e. remove file component from baseUri + * @deprecated This is mainly there to support loaders that still resolve from options * TODO - extract extension? * TODO - extract query parameters? * TODO - should these be injected on context instead of options? @@ -272,11 +273,10 @@ function addUrlOptions(options: LoaderOptions, url?: string): void { if (!url) { return; } - const hasTopLevelBaseUri = options.baseUri !== undefined; - const hasCoreBaseUri = options.core?.baseUri !== undefined; - if (!hasTopLevelBaseUri && !hasCoreBaseUri) { + const hasCoreBaseUrl = options.core?.baseUrl !== undefined; + if (!hasCoreBaseUrl) { options.core ||= {}; - options.core.baseUri = url; + options.core.baseUrl = path.dirname(stripQueryString(url)); } } @@ -289,6 +289,13 @@ function cloneLoaderOptions(options: LoaderOptions): LoaderOptions { } function moveDeprecatedTopLevelOptionsToCore(options: LoaderOptions): void { + if (options.baseUri !== undefined) { + options.core ||= {}; + if (options.core.baseUrl === undefined) { + options.core.baseUrl = options.baseUri; + } + } + for (const key of CORE_LOADER_OPTION_KEYS) { if ((options as Record)[key] !== undefined) { const coreOptions = (options.core = options.core || {}); diff --git a/modules/core/test/lib/loader-utils/option-utils.spec.ts b/modules/core/test/lib/loader-utils/option-utils.spec.ts index 32a017061d..72bfae6ccb 100644 --- a/modules/core/test/lib/loader-utils/option-utils.spec.ts +++ b/modules/core/test/lib/loader-utils/option-utils.spec.ts @@ -60,7 +60,7 @@ const TEST_CASES = [ options: {}, url: 'https://example.com/tileset.las', assert: (t, options, url) => { - t.equal(options.core.baseUri, url); + t.equal(options.core.baseUrl, 'https://example.com'); t.equal(options.baseUri, undefined); } } diff --git a/modules/gltf/src/lib/gltf-utils/resolve-url.ts b/modules/gltf/src/lib/gltf-utils/resolve-url.ts index 72f7b0068c..5b1d856b49 100644 --- a/modules/gltf/src/lib/gltf-utils/resolve-url.ts +++ b/modules/gltf/src/lib/gltf-utils/resolve-url.ts @@ -1,14 +1,29 @@ +import type {LoaderContext, StrictLoaderOptions} from '@loaders.gl/loader-utils'; + // Resolves a relative url against a baseUrl // If url is absolute, return it unchanged -export function resolveUrl(url, options) { +export function resolveUrl(url: string, options?: StrictLoaderOptions, context?: LoaderContext) { // TODO: Use better logic to handle all protocols plus not delay on data const absolute = url.startsWith('data:') || url.startsWith('http:') || url.startsWith('https:'); if (absolute) { return url; } - const baseUrl = options?.core?.baseUri || options.baseUri || options.uri; + const baseUrl = context?.baseUrl || getResolveBaseUrl(options?.core?.baseUrl); if (!baseUrl) { - throw new Error(`'baseUri' must be provided to resolve relative url ${url}`); + throw new Error(`'baseUrl' must be provided to resolve relative url ${url}`); } - return baseUrl.substr(0, baseUrl.lastIndexOf('/') + 1) + url; + return baseUrl.endsWith('/') ? `${baseUrl}${url}` : `${baseUrl}/${url}`; +} + +function getResolveBaseUrl(baseUrl?: string): string | undefined { + if (!baseUrl) { + return undefined; + } + + if (baseUrl.endsWith('/')) { + return baseUrl; + } + + const slashIndex = baseUrl.lastIndexOf('/'); + return slashIndex >= 0 ? baseUrl.slice(0, slashIndex + 1) : ''; } diff --git a/modules/gltf/src/lib/parsers/parse-gltf.ts b/modules/gltf/src/lib/parsers/parse-gltf.ts index dede989214..df441eb039 100644 --- a/modules/gltf/src/lib/parsers/parse-gltf.ts +++ b/modules/gltf/src/lib/parsers/parse-gltf.ts @@ -72,8 +72,8 @@ export async function parseGLTF( */ function parseGLTFContainerSync(gltf, data, byteOffset, options: GLTFLoaderOptions) { // Initialize gltf container - if (options.core?.baseUri) { - gltf.baseUri = options.core?.baseUri; + if (options.core?.baseUrl) { + gltf.baseUri = options.core?.baseUrl; } // If data is binary and starting with magic bytes, assume binary JSON text, convert to string @@ -134,7 +134,7 @@ async function loadBuffers(gltf: GLTFWithBuffers, options, context: LoaderContex const {fetch} = context; assert(fetch); - const uri = resolveUrl(buffer.uri, options); + const uri = resolveUrl(buffer.uri, options, context); const response = await context?.fetch?.(uri); const arrayBuffer = await response?.arrayBuffer?.(); @@ -201,7 +201,7 @@ async function loadImage( let arrayBuffer; if (image.uri && !image.hasOwnProperty('bufferView')) { - const uri = resolveUrl(image.uri, options); + const uri = resolveUrl(image.uri, options, context); const {fetch} = context; const response = await fetch(uri); diff --git a/modules/gltf/test/index.ts b/modules/gltf/test/index.ts index db0c9787e8..47a6323170 100644 --- a/modules/gltf/test/index.ts +++ b/modules/gltf/test/index.ts @@ -5,6 +5,7 @@ import './lib/glb/glb-encoder-decoder.spec'; import './lib/glb/glb-custom-payload.spec'; import './lib/gltf-utils/gltf-attribute-utils.spec'; +import './lib/gltf-utils/resolve-url.spec'; import './lib/api/gltf-scenegraph-modifiers.spec'; import './lib/api/gltf-scenegraph-accessors.spec'; diff --git a/modules/gltf/test/lib/gltf-utils/resolve-url.spec.ts b/modules/gltf/test/lib/gltf-utils/resolve-url.spec.ts new file mode 100644 index 0000000000..2f7c60078f --- /dev/null +++ b/modules/gltf/test/lib/gltf-utils/resolve-url.spec.ts @@ -0,0 +1,19 @@ +import test from 'tape-promise/tape'; +// @ts-expect-error +import {resolveUrl} from '@loaders.gl/gltf/lib/gltf-utils/resolve-url'; + +test('resolveUrl#resolves relative urls against document urls', (t) => { + t.equal( + resolveUrl('buffer.bin', {core: {baseUrl: 'https://example.com/models/model.gltf'}}), + 'https://example.com/models/buffer.bin', + 'resolves relative URLs against the source document directory' + ); + + t.equal( + resolveUrl('buffer.bin', {core: {baseUrl: 'https://example.com/models/'}}), + 'https://example.com/models/buffer.bin', + 'preserves directory base URLs' + ); + + t.end(); +}); diff --git a/modules/loader-utils/src/lib/sources/data-source.ts b/modules/loader-utils/src/lib/sources/data-source.ts index 1a49f685f4..928d9b267e 100644 --- a/modules/loader-utils/src/lib/sources/data-source.ts +++ b/modules/loader-utils/src/lib/sources/data-source.ts @@ -58,7 +58,7 @@ export abstract class DataSource { } this.data = data; this.url = typeof data === 'string' ? resolvePath(data) : ''; - this.loadOptions = {...this.options.core?.loadOptions}; + this.loadOptions = normalizeDirectLoaderOptions(this.options.core?.loadOptions); this.fetch = getFetchFunction(this.loadOptions); } @@ -110,3 +110,24 @@ export function getFetchFunction(options?: StrictLoaderOptions) { // else return the global fetch function return (url) => fetch(url); } + +function normalizeDirectLoaderOptions(options?: StrictLoaderOptions): StrictLoaderOptions { + const loadOptions = {...options}; + if (options?.core) { + loadOptions.core = {...options.core}; + } + + const topLevelBaseUri = typeof loadOptions.baseUri === 'string' ? loadOptions.baseUri : undefined; + const topLevelBaseUrl = typeof loadOptions.baseUrl === 'string' ? loadOptions.baseUrl : undefined; + + if (topLevelBaseUri !== undefined || topLevelBaseUrl !== undefined) { + loadOptions.core ||= {}; + if (loadOptions.core.baseUrl === undefined) { + loadOptions.core.baseUrl = topLevelBaseUrl ?? topLevelBaseUri; + } + delete loadOptions.baseUri; + delete loadOptions.baseUrl; + } + + return loadOptions; +} diff --git a/modules/loader-utils/src/loader-types.ts b/modules/loader-utils/src/loader-types.ts index 46baa70fc7..ac7b71f4c2 100644 --- a/modules/loader-utils/src/loader-types.ts +++ b/modules/loader-utils/src/loader-types.ts @@ -13,8 +13,8 @@ import {ReadableFile} from './lib/files/file'; */ export type StrictLoaderOptions = { core?: { - /** Base URI for resolving relative paths */ - baseUri?: string; + /** Base URL for resolving relative paths */ + baseUrl?: string; /** fetch options or a custom fetch function */ fetch?: typeof fetch | FetchLike | RequestInit | null; /** Do not throw on errors */ @@ -84,7 +84,7 @@ export type LoaderOptions = { modules?: StrictLoaderOptions['modules']; // Deprecated top-level aliases for core options - /** @deprecated Use options.core.baseUri */ + /** @deprecated Use options.core.baseUrl */ baseUri?: string; /** @deprecated Use options.core.fetch */ fetch?: typeof fetch | FetchLike | RequestInit | null; diff --git a/modules/loader-utils/test/index.ts b/modules/loader-utils/test/index.ts index 60fd3610f2..7b41500c59 100644 --- a/modules/loader-utils/test/index.ts +++ b/modules/loader-utils/test/index.ts @@ -17,6 +17,7 @@ import './lib/path-utils/path.spec'; import './lib/request-utils/request-scheduler.spec'; import './lib/javascript-utils/is-type.spec'; +import './lib/sources/data-source.spec'; // import './lib/files/node-file-facade.spec'; // import './lib/filesystems/node-filesystem-facade.spec'; diff --git a/modules/loader-utils/test/lib/sources/data-source.spec.ts b/modules/loader-utils/test/lib/sources/data-source.spec.ts new file mode 100644 index 0000000000..c402c78415 --- /dev/null +++ b/modules/loader-utils/test/lib/sources/data-source.spec.ts @@ -0,0 +1,44 @@ +// loaders.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import test from 'tape-promise/tape'; + +import type {DataSourceOptions} from '../../../src'; +import {DataSource} from '../../../src'; + +class TestDataSource extends DataSource {} + +test('DataSource#normalizes legacy loadOptions base URL aliases', (t) => { + const source = new TestDataSource('https://example.com/data', { + core: { + loadOptions: { + baseUri: 'https://example.com/model.gltf' + } + } + }); + + t.equal( + source.loadOptions.core?.baseUrl, + 'https://example.com/model.gltf', + 'top-level baseUri is normalized to core.baseUrl for direct parser calls' + ); + t.equal(source.loadOptions.baseUri, undefined, 'deprecated baseUri alias is removed'); + + const sourceWithBaseUrl = new TestDataSource('https://example.com/data', { + core: { + loadOptions: { + baseUrl: 'https://example.com/textures' + } + } + }); + + t.equal( + sourceWithBaseUrl.loadOptions.core?.baseUrl, + 'https://example.com/textures', + 'top-level baseUrl is normalized to core.baseUrl for direct parser calls' + ); + t.equal(sourceWithBaseUrl.loadOptions.baseUrl, undefined, 'top-level baseUrl alias is removed'); + + t.end(); +}); diff --git a/modules/schema/src/categories/category-texture.ts b/modules/schema/src/categories/category-texture.ts index 7b9b577ad7..fd52be8b09 100644 --- a/modules/schema/src/categories/category-texture.ts +++ b/modules/schema/src/categories/category-texture.ts @@ -231,7 +231,7 @@ export type TextureCubeArray; diff --git a/modules/textures/src/lib/composite-image/parse-composite-image.ts b/modules/textures/src/lib/composite-image/parse-composite-image.ts new file mode 100644 index 0000000000..d1f2e62215 --- /dev/null +++ b/modules/textures/src/lib/composite-image/parse-composite-image.ts @@ -0,0 +1,699 @@ +// loaders.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import type {LoaderContext} from '@loaders.gl/loader-utils'; +import {parseFromContext, path, resolvePath} from '@loaders.gl/loader-utils'; +import type {Texture, TextureFormat, TextureLevel} from '@loaders.gl/schema'; +import {ImageLoader, getImageSize, isImage, type ImageType} from '@loaders.gl/images'; +import {asyncDeepMap} from '../texture-api/async-deep-map'; +import type {TextureLoaderOptions} from '../texture-api/texture-api-types'; +import { + IMAGE_TEXTURE_CUBE_FACES, + type ImageCubeTexture, + type ImageTextureCubeDirectionAlias, + type ImageTextureCubeFace +} from './image-texture-cube'; + +export type ImageTextureTemplateSource = { + mipLevels: number | 'auto'; + template: string; +}; + +export type ImageTextureSource = string | string[] | ImageTextureTemplateSource; + +export type ImageTextureManifest = { + shape: 'image-texture'; + image?: string; + mipLevels?: number | 'auto'; + template?: string; + mipmaps?: string[]; +}; + +export type ImageTextureArrayManifest = { + shape: 'image-texture-array'; + layers: ImageTextureSource[]; +}; + +export type ImageTextureCubeFaces = Partial< + Record +>; + +export type ImageTextureCubeManifest = { + shape: 'image-texture-cube'; + faces: ImageTextureCubeFaces; +}; + +export type ImageTextureCubeArrayLayer = { + faces: ImageTextureCubeFaces; +}; + +export type ImageTextureCubeArrayManifest = { + shape: 'image-texture-cube-array'; + layers: ImageTextureCubeArrayLayer[]; +}; + +export type CompositeImageManifest = + | ImageTextureManifest + | ImageTextureArrayManifest + | ImageTextureCubeManifest + | ImageTextureCubeArrayManifest; + +export type CompositeImageUrlTree = + | ImageTextureSource + | ImageTextureSource[] + | ImageCubeTexture + | ImageCubeTexture[]; + +export async function parseCompositeImageManifest( + text: string, + expectedShape: CompositeImageManifest['shape'], + options: TextureLoaderOptions = {}, + context?: LoaderContext +): Promise { + const manifest = parseCompositeImageManifestJSON(text); + if (manifest.shape !== expectedShape) { + throw new Error(`Expected ${expectedShape} manifest, got ${manifest.shape}`); + } + return await loadCompositeImageManifest(manifest, options, context); +} + +export function testCompositeImageManifestShape( + text: string, + shape: CompositeImageManifest['shape'] +): boolean { + try { + return parseCompositeImageManifestJSON(text).shape === shape; + } catch { + return false; + } +} + +export async function loadCompositeImageManifest( + manifest: CompositeImageManifest, + options: TextureLoaderOptions = {}, + context?: LoaderContext +): Promise { + const normalizedOptions = normalizeCompositeImageManifestOptions(options); + const urlTree = await getCompositeImageUrlTree(manifest, normalizedOptions, context); + const imageData = await loadCompositeImageUrlTree(urlTree, normalizedOptions, context); + return convertCompositeImageToTexture(manifest.shape, imageData); +} + +export async function loadCompositeImageUrlTree( + urlTree: CompositeImageUrlTree, + options: TextureLoaderOptions = {}, + context?: LoaderContext +): Promise { + const normalizedOptions = normalizeCompositeImageOptions(options); + return await asyncDeepMap( + urlTree, + async (url: string) => await loadCompositeImageMember(url, normalizedOptions, context) + ); +} + +export async function loadCompositeImageMember( + url: string, + options: TextureLoaderOptions = {}, + context?: LoaderContext +): Promise { + const resolvedUrl = resolveCompositeImageUrl(url, options, context); + const fetch = getCompositeImageFetch(options, context); + const response = await fetch(resolvedUrl); + const subloaderOptions = getCompositeImageSubloaderOptions(options); + if (context) { + const childContext = getCompositeImageMemberContext(resolvedUrl, response, context); + return await parseFromContext( + response as any, + [ImageLoader], + subloaderOptions as any, + childContext + ); + } + + const arrayBuffer = await response.arrayBuffer(); + return await ImageLoader.parse(arrayBuffer, subloaderOptions as any); +} + +export async function getCompositeImageUrlTree( + manifest: CompositeImageManifest, + options: TextureLoaderOptions = {}, + context?: LoaderContext +): Promise { + switch (manifest.shape) { + case 'image-texture': + return await getImageTextureSource(manifest, options, context); + + case 'image-texture-array': + if (!Array.isArray(manifest.layers) || manifest.layers.length === 0) { + throw new Error('image-texture-array manifest must define one or more layers'); + } + return await Promise.all( + manifest.layers.map( + async (layer, index) => + await getNormalizedImageTextureSource(layer, options, context, {index}) + ) + ); + + case 'image-texture-cube': + return await getImageTextureCubeUrls(manifest, options, context); + + case 'image-texture-cube-array': + if (!Array.isArray(manifest.layers) || manifest.layers.length === 0) { + throw new Error('image-texture-cube-array manifest must define one or more layers'); + } + return await Promise.all( + manifest.layers.map( + async (layer, index) => await getImageTextureCubeUrls(layer, options, context, {index}) + ) + ); + + default: + throw new Error('Unsupported composite image manifest'); + } +} + +export function normalizeCompositeImageOptions( + options: TextureLoaderOptions = {} +): TextureLoaderOptions { + if (options.core?.baseUrl) { + return options; + } + + const fallbackBaseUrl = options.baseUrl; + if (!fallbackBaseUrl) { + return options; + } + + return { + ...options, + core: { + ...options.core, + baseUrl: fallbackBaseUrl + } + }; +} + +export function resolveCompositeImageUrl( + url: string, + options: TextureLoaderOptions = {}, + context?: LoaderContext +): string { + const resolvedUrl = resolvePath(url); + if (isAbsoluteCompositeImageUrl(url)) { + return resolvedUrl; + } + + const baseUrl = getCompositeImageBaseUrl(options, context); + if (!baseUrl) { + if (resolvedUrl !== url || url.startsWith('@')) { + return resolvedUrl; + } + throw new Error(`Unable to resolve relative image URL ${url} without a base URL`); + } + + return resolvePath(joinCompositeImageUrl(baseUrl, url)); +} + +function parseCompositeImageManifestJSON(text: string): CompositeImageManifest { + const manifest = JSON.parse(text) as CompositeImageManifest; + if (!manifest?.shape) { + throw new Error('Composite image manifest must contain a shape field'); + } + return manifest; +} + +async function getImageTextureSource( + manifest: ImageTextureManifest, + options: TextureLoaderOptions, + context?: LoaderContext +): Promise { + if ((manifest.image || manifest.mipmaps) && manifest.template) { + throw new Error('image-texture manifest must define image, mipmaps, or template source'); + } + if (manifest.image && manifest.mipmaps) { + throw new Error('image-texture manifest must define image, mipmaps, or template source'); + } + if (manifest.image) { + return manifest.image; + } + if (manifest.mipmaps?.length) { + return manifest.mipmaps; + } + if (manifest.template) { + return await expandImageTextureSource( + {mipLevels: manifest.mipLevels ?? 'auto', template: manifest.template}, + options, + context, + {} + ); + } + throw new Error('image-texture manifest must define image, mipmaps, or template source'); +} + +async function getImageTextureCubeUrls( + manifest: Pick, + options: TextureLoaderOptions, + context?: LoaderContext, + templateOptions: TemplateOptions = {} +): Promise { + const urls: ImageCubeTexture = {}; + + for (const {face, name, direction, axis, sign} of IMAGE_TEXTURE_CUBE_FACES) { + const source = manifest.faces?.[name] || manifest.faces?.[direction]; + if (!source) { + throw new Error(`image-texture-cube manifest is missing ${name} face`); + } + urls[face] = await getNormalizedImageTextureSource(source, options, context, { + ...templateOptions, + face: name, + direction, + axis, + sign + }); + } + + return urls; +} + +async function getNormalizedImageTextureSource( + source: ImageTextureSource, + options: TextureLoaderOptions, + context: LoaderContext | undefined, + templateOptions: TemplateOptions +): Promise { + if (typeof source === 'string') { + return source; + } + if (Array.isArray(source) && source.length > 0) { + return source; + } + if (isImageTextureTemplateSource(source)) { + return await expandImageTextureSource(source, options, context, templateOptions); + } + throw new Error('Composite image source entries must be strings or non-empty mip arrays'); +} + +async function expandImageTextureSource( + source: ImageTextureTemplateSource, + options: TextureLoaderOptions, + context: LoaderContext | undefined, + templateOptions: TemplateOptions +): Promise { + const mipLevels = + source.mipLevels === 'auto' + ? await getAutoMipLevels(source.template, options, context, templateOptions) + : source.mipLevels; + + if (!Number.isFinite(mipLevels) || mipLevels <= 0) { + throw new Error(`Invalid mipLevels value ${source.mipLevels}`); + } + + const urls: string[] = []; + for (let lod = 0; lod < mipLevels; lod++) { + urls.push(expandTemplate(source.template, {...templateOptions, lod})); + } + return urls; +} + +async function getAutoMipLevels( + template: string, + options: TextureLoaderOptions, + context: LoaderContext | undefined, + templateOptions: TemplateOptions +): Promise { + if (!template.includes('{lod}')) { + throw new Error('Template sources with mipLevels: auto must include a {lod} placeholder'); + } + + const level0Url = expandTemplate(template, {...templateOptions, lod: 0}); + const image = await loadCompositeImageMember( + level0Url, + normalizeCompositeImageOptions(options), + context + ); + const {width, height} = getImageSize(image); + return 1 + Math.floor(Math.log2(Math.max(width, height))); +} + +type TemplateOptions = { + lod?: number; + index?: number; + face?: string; + direction?: string; + axis?: string; + sign?: string; +}; + +function expandTemplate(template: string, templateOptions: TemplateOptions): string { + let expanded = ''; + + for (let index = 0; index < template.length; index++) { + const character = template[index]; + + if (character === '\\') { + const nextCharacter = template[index + 1]; + if (nextCharacter === '{' || nextCharacter === '}' || nextCharacter === '\\') { + expanded += nextCharacter; + index++; + continue; + } + throw new Error(`Invalid escape sequence \\${nextCharacter || ''} in template ${template}`); + } + + if (character === '}') { + throw new Error(`Unexpected } in template ${template}`); + } + + if (character !== '{') { + expanded += character; + continue; + } + + const closingBraceIndex = findClosingBraceIndex(template, index + 1); + if (closingBraceIndex < 0) { + throw new Error(`Unterminated placeholder in template ${template}`); + } + + const placeholder = template.slice(index + 1, closingBraceIndex); + if (!/^[a-z][a-zA-Z0-9]*$/.test(placeholder)) { + throw new Error(`Invalid placeholder {${placeholder}} in template ${template}`); + } + + const value = getTemplateValue(placeholder, templateOptions); + if (value === undefined) { + throw new Error( + `Template ${template} uses unsupported placeholder {${placeholder}} for this source` + ); + } + + expanded += String(value); + index = closingBraceIndex; + } + + return expanded; +} + +function findClosingBraceIndex(template: string, startIndex: number): number { + for (let index = startIndex; index < template.length; index++) { + const character = template[index]; + if (character === '\\') { + index++; + continue; + } + if (character === '{') { + throw new Error(`Nested placeholders are not supported in template ${template}`); + } + if (character === '}') { + return index; + } + } + return -1; +} + +function getTemplateValue( + placeholder: string, + templateOptions: TemplateOptions +): string | number | undefined { + switch (placeholder) { + case 'lod': + return templateOptions.lod; + case 'index': + return templateOptions.index; + case 'face': + return templateOptions.face; + case 'direction': + return templateOptions.direction; + case 'axis': + return templateOptions.axis; + case 'sign': + return templateOptions.sign; + default: + return undefined; + } +} + +function isImageTextureTemplateSource( + source: ImageTextureSource +): source is ImageTextureTemplateSource { + return typeof source === 'object' && source !== null && !Array.isArray(source); +} + +function getCompositeImageBaseUrl( + options: TextureLoaderOptions, + context?: LoaderContext +): string | null { + if (context?.baseUrl) { + return context.baseUrl; + } + + if (options.baseUrl) { + return stripTrailingSlash(options.baseUrl); + } + + if (options.core?.baseUrl) { + return getSourceUrlDirectory(options.core.baseUrl); + } + + return null; +} + +function stripTrailingSlash(baseUrl: string): string { + if (baseUrl.endsWith('/')) { + return baseUrl.slice(0, -1); + } + + return baseUrl; +} + +function getSourceUrlDirectory(baseUrl: string): string { + return stripTrailingSlash(path.dirname(baseUrl)); +} + +function joinCompositeImageUrl(baseUrl: string, url: string): string { + if (isRequestLikeUrl(baseUrl)) { + return new URL(url, `${stripTrailingSlash(baseUrl)}/`).toString(); + } + + const normalizedBaseUrl = baseUrl.startsWith('/') ? baseUrl : `/${baseUrl}`; + const normalizedUrl = path.resolve(normalizedBaseUrl, url); + return baseUrl.startsWith('/') ? normalizedUrl : normalizedUrl.slice(1); +} + +function isRequestLikeUrl(url: string): boolean { + return ( + url.startsWith('http:') || + url.startsWith('https:') || + url.startsWith('file:') || + url.startsWith('blob:') + ); +} + +function getCompositeImageFetch( + options: TextureLoaderOptions, + context?: LoaderContext +): typeof fetch { + const fetchOption = options.fetch ?? options.core?.fetch; + + if (context?.fetch) { + return context.fetch as typeof fetch; + } + + if (typeof fetchOption === 'function') { + return fetchOption as typeof fetch; + } + + if (fetchOption && typeof fetchOption === 'object') { + return (url) => fetch(url, fetchOption); + } + + return fetch; +} + +function getCompositeImageSubloaderOptions(options: TextureLoaderOptions): TextureLoaderOptions { + const core = options.core; + const rest = {...options}; + delete rest.baseUrl; + if (!core?.baseUrl) { + return rest; + } + + const restCore = {...core}; + delete restCore.baseUrl; + return { + ...rest, + core: restCore + }; +} + +function normalizeCompositeImageManifestOptions( + options: TextureLoaderOptions +): TextureLoaderOptions { + if (options.image?.type || typeof ImageBitmap === 'undefined') { + return options; + } + + return { + ...options, + image: { + ...options.image, + type: 'imagebitmap' + } + }; +} + +function getCompositeImageMemberContext( + resolvedUrl: string, + response: Response, + context: LoaderContext +): LoaderContext { + const url = response.url || resolvedUrl; + const [urlWithoutQueryString, queryString = ''] = url.split('?'); + + return { + ...context, + url, + response, + filename: path.filename(urlWithoutQueryString), + baseUrl: path.dirname(urlWithoutQueryString), + queryString + }; +} + +function convertCompositeImageToTexture( + shape: CompositeImageManifest['shape'], + imageData: any +): Texture { + switch (shape) { + case 'image-texture': { + const data = normalizeCompositeImageMember(imageData); + return { + shape: 'texture', + type: '2d', + format: getCompositeTextureFormat(data), + data + }; + } + + case 'image-texture-array': { + const data = imageData.map((layer) => normalizeCompositeImageMember(layer)); + return { + shape: 'texture', + type: '2d-array', + format: getCompositeTextureFormat(data[0]), + data + }; + } + + case 'image-texture-cube': { + const data = IMAGE_TEXTURE_CUBE_FACES.map(({face}) => + normalizeCompositeImageMember(imageData[face]) + ); + return { + shape: 'texture', + type: 'cube', + format: getCompositeTextureFormat(data[0]), + data + }; + } + + case 'image-texture-cube-array': { + const data = imageData.map((layer) => + IMAGE_TEXTURE_CUBE_FACES.map(({face}) => normalizeCompositeImageMember(layer[face])) + ); + return { + shape: 'texture', + type: 'cube-array', + format: getCompositeTextureFormat(data[0][0]), + data + }; + } + + default: + throw new Error(`Unsupported composite image shape ${shape}`); + } +} + +function normalizeCompositeImageMember(imageData: any): TextureLevel[] { + if (Array.isArray(imageData)) { + if (imageData.length === 0) { + throw new Error('Composite image members must not be empty'); + } + + if (imageData.every(isTextureLevel)) { + return imageData; + } + + if (imageData.every(isImage)) { + return imageData.map((image) => getTextureLevelFromImage(image)); + } + + if (imageData.every((entry) => Array.isArray(entry) && entry.every(isTextureLevel))) { + if (imageData.length !== 1) { + throw new Error('Composite image members must resolve to a single image or mip chain'); + } + return imageData[0]; + } + } + + if (isTexture(imageData)) { + if (imageData.type !== '2d') { + throw new Error(`Composite image members must resolve to 2d textures, got ${imageData.type}`); + } + return imageData.data; + } + + if (isTextureLevel(imageData)) { + return [imageData]; + } + + if (isImage(imageData)) { + return [getTextureLevelFromImage(imageData)]; + } + + throw new Error('Composite image members must resolve to an image, mip chain, or texture'); +} + +function getTextureLevelFromImage(image: ImageType): TextureLevel { + const {width, height} = getImageSize(image); + return { + shape: 'texture-level', + compressed: false, + width, + height, + imageBitmap: + typeof ImageBitmap !== 'undefined' && image instanceof ImageBitmap ? image : undefined, + data: new Uint8Array(0), + textureFormat: 'rgba8unorm' + }; +} + +function getCompositeTextureFormat(textureLevels: TextureLevel[]): TextureFormat { + return textureLevels[0]?.textureFormat || 'rgba8unorm'; +} + +function isTextureLevel(textureLevel: unknown): textureLevel is TextureLevel { + return Boolean( + textureLevel && + typeof textureLevel === 'object' && + 'shape' in textureLevel && + textureLevel.shape === 'texture-level' + ); +} + +function isTexture(texture: unknown): texture is Texture { + return Boolean( + texture && typeof texture === 'object' && 'shape' in texture && texture.shape === 'texture' + ); +} + +function isAbsoluteCompositeImageUrl(url: string): boolean { + return ( + url.startsWith('data:') || + url.startsWith('blob:') || + url.startsWith('file:') || + url.startsWith('http:') || + url.startsWith('https:') || + url.startsWith('/') + ); +} diff --git a/modules/textures/src/lib/texture-api/generate-url.ts b/modules/textures/src/lib/texture-api/generate-url.ts index 33c7388bbc..c6d6dc04cf 100644 --- a/modules/textures/src/lib/texture-api/generate-url.ts +++ b/modules/textures/src/lib/texture-api/generate-url.ts @@ -2,23 +2,13 @@ // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors -import {resolvePath} from '@loaders.gl/loader-utils'; import type {GetUrl, UrlOptions} from './texture-api-types'; -// Generate a url by calling getUrl with mix of options, applying options.baseUrl +// Generate a member url by calling getUrl with merged options. export function generateUrl( getUrl: string | GetUrl, options: UrlOptions, urlOptions: Record ): string { - // Get url - let url = typeof getUrl === 'function' ? getUrl({...options, ...urlOptions}) : getUrl; - - // Apply options.baseUrl - const baseUrl = options.baseUrl; - if (baseUrl) { - url = baseUrl[baseUrl.length - 1] === '/' ? `${baseUrl}${url}` : `${baseUrl}/${url}`; - } - - return resolvePath(url); + return typeof getUrl === 'function' ? getUrl({...options, ...urlOptions}) : getUrl; } diff --git a/modules/textures/src/lib/texture-api/load-image-array.ts b/modules/textures/src/lib/texture-api/load-image-array.ts index 3bc3f1f422..22f87a5f2a 100644 --- a/modules/textures/src/lib/texture-api/load-image-array.ts +++ b/modules/textures/src/lib/texture-api/load-image-array.ts @@ -2,21 +2,30 @@ // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors -import {ImageLoader} from '@loaders.gl/images'; -import type {GetUrl} from './texture-api-types'; +import { + loadCompositeImageUrlTree, + normalizeCompositeImageOptions +} from '../composite-image/parse-composite-image'; +import type {GetUrl, TextureLoaderOptions} from './texture-api-types'; import {getImageUrls} from './load-image'; -import {deepLoad} from './deep-load'; +/** + * @deprecated Use `load(url, TextureArrayLoader)` for manifest-driven loading. + */ export async function loadImageTextureArray( count: number, getUrl: GetUrl, - options = {} + options: TextureLoaderOptions = {} ): Promise { const imageUrls = await getImageArrayUrls(count, getUrl, options); - return await deepLoad(imageUrls, ImageLoader.parse, options); + return await loadCompositeImageUrlTree(imageUrls, normalizeCompositeImageOptions(options)); } -export async function getImageArrayUrls(count: number, getUrl: GetUrl, options = {}): Promise { +export async function getImageArrayUrls( + count: number, + getUrl: GetUrl, + options: TextureLoaderOptions = {} +): Promise { const promises: Promise[] = []; for (let index = 0; index < count; index++) { const promise = getImageUrls(getUrl, options, {index}); diff --git a/modules/textures/src/lib/texture-api/load-image-cube.ts b/modules/textures/src/lib/texture-api/load-image-cube.ts index 07ab72088d..1ac282970f 100644 --- a/modules/textures/src/lib/texture-api/load-image-cube.ts +++ b/modules/textures/src/lib/texture-api/load-image-cube.ts @@ -2,47 +2,27 @@ // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors -import {ImageLoader} from '@loaders.gl/images'; -import type {GetUrl, UrlOptions} from './texture-api-types'; +import { + loadCompositeImageUrlTree, + normalizeCompositeImageOptions +} from '../composite-image/parse-composite-image'; +import { + IMAGE_TEXTURE_CUBE_FACES, + type ImageCubeTexture +} from '../composite-image/image-texture-cube'; +import type {GetUrl, TextureLoaderOptions} from './texture-api-types'; import {getImageUrls} from './load-image'; -import {deepLoad} from './deep-load'; - -// Returned map will be have keys corresponding to GL cubemap constants -const GL_TEXTURE_CUBE_MAP_POSITIVE_X = 0x8515; -const GL_TEXTURE_CUBE_MAP_NEGATIVE_X = 0x8516; -const GL_TEXTURE_CUBE_MAP_POSITIVE_Y = 0x8517; -const GL_TEXTURE_CUBE_MAP_NEGATIVE_Y = 0x8518; -const GL_TEXTURE_CUBE_MAP_POSITIVE_Z = 0x8519; -const GL_TEXTURE_CUBE_MAP_NEGATIVE_Z = 0x851a; - -const CUBE_FACES = [ - {face: GL_TEXTURE_CUBE_MAP_POSITIVE_X, direction: 'right', axis: 'x', sign: 'positive'}, - {face: GL_TEXTURE_CUBE_MAP_NEGATIVE_X, direction: 'left', axis: 'x', sign: 'negative'}, - {face: GL_TEXTURE_CUBE_MAP_POSITIVE_Y, direction: 'top', axis: 'y', sign: 'positive'}, - {face: GL_TEXTURE_CUBE_MAP_NEGATIVE_Y, direction: 'bottom', axis: 'y', sign: 'negative'}, - {face: GL_TEXTURE_CUBE_MAP_POSITIVE_Z, direction: 'front', axis: 'z', sign: 'positive'}, - {face: GL_TEXTURE_CUBE_MAP_NEGATIVE_Z, direction: 'back', axis: 'z', sign: 'negative'} -]; - -export type ImageCubeTexture = { - GL_TEXTURE_CUBE_MAP_POSITIVE_X: any; - GL_TEXTURE_CUBE_MAP_NEGATIVE_X: any; - GL_TEXTURE_CUBE_MAP_POSITIVE_Y: any; - GL_TEXTURE_CUBE_MAP_NEGATIVE_Y: any; - GL_TEXTURE_CUBE_MAP_POSITIVE_Z: any; - GL_TEXTURE_CUBE_MAP_NEGATIVE_Z: any; -}; // Returns an object with six key-value pairs containing the urls (or url mip arrays) // for each cube face -export async function getImageCubeUrls(getUrl: GetUrl, options: UrlOptions) { +export async function getImageCubeUrls(getUrl: GetUrl, options: TextureLoaderOptions) { // Calculate URLs const urls: Record = {}; const promises: Promise[] = []; let index = 0; - for (let i = 0; i < CUBE_FACES.length; ++i) { - const face = CUBE_FACES[index]; + for (let i = 0; i < IMAGE_TEXTURE_CUBE_FACES.length; ++i) { + const face = IMAGE_TEXTURE_CUBE_FACES[index]; const promise = getImageUrls(getUrl, options, {...face, index: index++}).then((url) => { urls[face.face] = url; }); @@ -56,10 +36,16 @@ export async function getImageCubeUrls(getUrl: GetUrl, options: UrlOptions) { // Returns an object with six key-value pairs containing the images (or image mip arrays) // for each cube face +/** + * @deprecated Use `load(url, TextureCubeLoader)` for manifest-driven loading. + */ export async function loadImageTextureCube( getUrl: GetUrl, - options = {} + options: TextureLoaderOptions = {} ): Promise { const urls = await getImageCubeUrls(getUrl, options); - return (await deepLoad(urls, ImageLoader.parse, options)) as ImageCubeTexture; + return (await loadCompositeImageUrlTree( + urls, + normalizeCompositeImageOptions(options) + )) as ImageCubeTexture; } diff --git a/modules/textures/src/lib/texture-api/load-image.ts b/modules/textures/src/lib/texture-api/load-image.ts index 2863c74c5e..a01eee1001 100644 --- a/modules/textures/src/lib/texture-api/load-image.ts +++ b/modules/textures/src/lib/texture-api/load-image.ts @@ -3,19 +3,29 @@ // Copyright (c) vis.gl contributors import {assert} from '@loaders.gl/loader-utils'; -import {ImageLoader, getImageSize} from '@loaders.gl/images'; -import type {GetUrl, UrlOptions} from './texture-api-types'; +import {getImageSize} from '@loaders.gl/images'; +import { + loadCompositeImageMember, + loadCompositeImageUrlTree, + normalizeCompositeImageOptions +} from '../composite-image/parse-composite-image'; +import type {GetUrl, TextureLoaderOptions, UrlOptions} from './texture-api-types'; import {generateUrl} from './generate-url'; -import {deepLoad, shallowLoad} from './deep-load'; -export async function loadImageTexture(getUrl: string | GetUrl, options = {}): Promise { +/** + * @deprecated Use `load(url, TextureLoader)` for manifest-driven loading. + */ +export async function loadImageTexture( + getUrl: string | GetUrl, + options: TextureLoaderOptions = {} +): Promise { const imageUrls = await getImageUrls(getUrl, options); - return await deepLoad(imageUrls, ImageLoader.parse, options); + return await loadCompositeImageUrlTree(imageUrls, normalizeCompositeImageOptions(options)); } export async function getImageUrls( getUrl: string | GetUrl, - options: any, + options: TextureLoaderOptions, urlOptions: UrlOptions = {} ): Promise { const mipLevels = (options && options.image && options.image.mipLevels) || 0; @@ -27,15 +37,16 @@ export async function getImageUrls( async function getMipmappedImageUrls( getUrl: string | GetUrl, mipLevels: number | 'auto', - options: any, + options: TextureLoaderOptions, urlOptions: UrlOptions ): Promise { const urls: string[] = []; + const normalizedOptions = normalizeCompositeImageOptions(options); // If no mip levels supplied, we need to load the level 0 image and calculate based on size if (mipLevels === 'auto') { const url = generateUrl(getUrl, options, {...urlOptions, lod: 0}); - const image = await shallowLoad(url, ImageLoader.parse, options); + const image = await loadCompositeImageMember(url, normalizedOptions); const {width, height} = getImageSize(image); mipLevels = getMipLevels({width, height}); diff --git a/modules/textures/src/lib/texture-api/texture-api-types.ts b/modules/textures/src/lib/texture-api/texture-api-types.ts index 0c6c6d8178..49637386d8 100644 --- a/modules/textures/src/lib/texture-api/texture-api-types.ts +++ b/modules/textures/src/lib/texture-api/texture-api-types.ts @@ -2,6 +2,9 @@ // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors +import type {LoaderOptions} from '@loaders.gl/loader-utils'; +import type {ImageLoaderOptions} from '@loaders.gl/images'; + export type {ImageType} from '@loaders.gl/images'; export type UrlOptions = { @@ -12,3 +15,15 @@ export type UrlOptions = { direction?: string; }; export type GetUrl = (options: UrlOptions) => string; + +export type TextureLoaderOptions = LoaderOptions & { + core?: NonNullable & { + /** Base URL for resolving composite image members when no loader context URL is available */ + baseUrl?: string; + }; + /** @deprecated Legacy helper alias kept for loadImageTexture* compatibility */ + baseUrl?: string; + image?: NonNullable & { + mipLevels?: number | 'auto'; + }; +}; diff --git a/modules/textures/src/texture-array-loader.ts b/modules/textures/src/texture-array-loader.ts new file mode 100644 index 0000000000..dcc527af4d --- /dev/null +++ b/modules/textures/src/texture-array-loader.ts @@ -0,0 +1,46 @@ +// loaders.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import type {LoaderContext, LoaderWithParser} from '@loaders.gl/loader-utils'; +import type {Texture} from '@loaders.gl/schema'; +import type {TextureLoaderOptions as TextureApiLoaderOptions} from './lib/texture-api/texture-api-types'; +import {VERSION} from './lib/utils/version'; +import { + parseCompositeImageManifest, + testCompositeImageManifestShape, + type ImageTextureArrayManifest +} from './lib/composite-image/parse-composite-image'; + +export type TextureArrayLoaderOptions = TextureApiLoaderOptions; +export type {ImageTextureArrayManifest as TextureArrayManifest}; + +export const TextureArrayLoader = { + dataType: null as unknown as Texture, + batchType: null as never, + id: 'texture-array', + name: 'Texture Array', + module: 'textures', + version: VERSION, + extensions: [], + mimeTypes: [], + text: true, + worker: false, + testText: (text: string) => testCompositeImageManifestShape(text, 'image-texture-array'), + options: { + image: {} + }, + parse: async ( + arrayBuffer: ArrayBuffer, + options?: TextureArrayLoaderOptions, + context?: LoaderContext + ) => + await parseCompositeImageManifest( + new TextDecoder().decode(arrayBuffer), + 'image-texture-array', + options, + context + ), + parseText: async (text: string, options?: TextureArrayLoaderOptions, context?: LoaderContext) => + await parseCompositeImageManifest(text, 'image-texture-array', options, context) +} as const satisfies LoaderWithParser; diff --git a/modules/textures/src/texture-cube-array-loader.ts b/modules/textures/src/texture-cube-array-loader.ts new file mode 100644 index 0000000000..f2c94c66ac --- /dev/null +++ b/modules/textures/src/texture-cube-array-loader.ts @@ -0,0 +1,49 @@ +// loaders.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import type {LoaderContext, LoaderWithParser} from '@loaders.gl/loader-utils'; +import type {Texture} from '@loaders.gl/schema'; +import type {TextureLoaderOptions as TextureApiLoaderOptions} from './lib/texture-api/texture-api-types'; +import {VERSION} from './lib/utils/version'; +import { + parseCompositeImageManifest, + testCompositeImageManifestShape, + type ImageTextureCubeArrayManifest +} from './lib/composite-image/parse-composite-image'; + +export type TextureCubeArrayLoaderOptions = TextureApiLoaderOptions; +export type {ImageTextureCubeArrayManifest as TextureCubeArrayManifest}; + +export const TextureCubeArrayLoader = { + dataType: null as unknown as Texture, + batchType: null as never, + id: 'texture-cube-array', + name: 'Texture Cube Array', + module: 'textures', + version: VERSION, + extensions: [], + mimeTypes: [], + text: true, + worker: false, + testText: (text: string) => testCompositeImageManifestShape(text, 'image-texture-cube-array'), + options: { + image: {} + }, + parse: async ( + arrayBuffer: ArrayBuffer, + options?: TextureCubeArrayLoaderOptions, + context?: LoaderContext + ) => + await parseCompositeImageManifest( + new TextDecoder().decode(arrayBuffer), + 'image-texture-cube-array', + options, + context + ), + parseText: async ( + text: string, + options?: TextureCubeArrayLoaderOptions, + context?: LoaderContext + ) => await parseCompositeImageManifest(text, 'image-texture-cube-array', options, context) +} as const satisfies LoaderWithParser; diff --git a/modules/textures/src/texture-cube-loader.ts b/modules/textures/src/texture-cube-loader.ts new file mode 100644 index 0000000000..a73f33102c --- /dev/null +++ b/modules/textures/src/texture-cube-loader.ts @@ -0,0 +1,46 @@ +// loaders.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import type {LoaderContext, LoaderWithParser} from '@loaders.gl/loader-utils'; +import type {Texture} from '@loaders.gl/schema'; +import type {TextureLoaderOptions as TextureApiLoaderOptions} from './lib/texture-api/texture-api-types'; +import {VERSION} from './lib/utils/version'; +import { + parseCompositeImageManifest, + testCompositeImageManifestShape, + type ImageTextureCubeManifest +} from './lib/composite-image/parse-composite-image'; + +export type TextureCubeLoaderOptions = TextureApiLoaderOptions; +export type {ImageTextureCubeManifest as TextureCubeManifest}; + +export const TextureCubeLoader = { + dataType: null as unknown as Texture, + batchType: null as never, + id: 'texture-cube', + name: 'Texture Cube', + module: 'textures', + version: VERSION, + extensions: [], + mimeTypes: [], + text: true, + worker: false, + testText: (text: string) => testCompositeImageManifestShape(text, 'image-texture-cube'), + options: { + image: {} + }, + parse: async ( + arrayBuffer: ArrayBuffer, + options?: TextureCubeLoaderOptions, + context?: LoaderContext + ) => + await parseCompositeImageManifest( + new TextDecoder().decode(arrayBuffer), + 'image-texture-cube', + options, + context + ), + parseText: async (text: string, options?: TextureCubeLoaderOptions, context?: LoaderContext) => + await parseCompositeImageManifest(text, 'image-texture-cube', options, context) +} as const satisfies LoaderWithParser; diff --git a/modules/textures/src/texture-loader.ts b/modules/textures/src/texture-loader.ts new file mode 100644 index 0000000000..0471f508ba --- /dev/null +++ b/modules/textures/src/texture-loader.ts @@ -0,0 +1,49 @@ +// loaders.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import type {LoaderContext, LoaderWithParser} from '@loaders.gl/loader-utils'; +import type {Texture} from '@loaders.gl/schema'; +import type {TextureLoaderOptions as TextureApiLoaderOptions} from './lib/texture-api/texture-api-types'; +import {VERSION} from './lib/utils/version'; +import { + parseCompositeImageManifest, + testCompositeImageManifestShape, + type ImageTextureManifest +} from './lib/composite-image/parse-composite-image'; + +export type TextureManifestLoaderOptions = TextureApiLoaderOptions; +export type {ImageTextureManifest as TextureManifest}; + +export const TextureLoader = { + dataType: null as unknown as Texture, + batchType: null as never, + id: 'texture', + name: 'Texture', + module: 'textures', + version: VERSION, + extensions: [], + mimeTypes: [], + text: true, + worker: false, + testText: (text: string) => testCompositeImageManifestShape(text, 'image-texture'), + options: { + image: {} + }, + parse: async ( + arrayBuffer: ArrayBuffer, + options?: TextureManifestLoaderOptions, + context?: LoaderContext + ) => + await parseCompositeImageManifest( + new TextDecoder().decode(arrayBuffer), + 'image-texture', + options, + context + ), + parseText: async ( + text: string, + options?: TextureManifestLoaderOptions, + context?: LoaderContext + ) => await parseCompositeImageManifest(text, 'image-texture', options, context) +} as const satisfies LoaderWithParser; diff --git a/modules/textures/test/data/composite-image/image-texture-array.json b/modules/textures/test/data/composite-image/image-texture-array.json new file mode 100644 index 0000000000..14431fbba9 --- /dev/null +++ b/modules/textures/test/data/composite-image/image-texture-array.json @@ -0,0 +1,7 @@ +{ + "shape": "image-texture-array", + "layers": [ + "../../../../images/test/data/ibl/papermill/environment/environment_back_0.jpg", + "../../../../images/test/data/ibl/papermill/environment/environment_front_0.jpg" + ] +} diff --git a/modules/textures/test/data/composite-image/image-texture-cube.json b/modules/textures/test/data/composite-image/image-texture-cube.json new file mode 100644 index 0000000000..be97c36bff --- /dev/null +++ b/modules/textures/test/data/composite-image/image-texture-cube.json @@ -0,0 +1,11 @@ +{ + "shape": "image-texture-cube", + "faces": { + "+X": "../../../../images/test/data/ibl/papermill/environment/environment_right_0.jpg", + "-X": "../../../../images/test/data/ibl/papermill/environment/environment_left_0.jpg", + "+Y": "../../../../images/test/data/ibl/papermill/environment/environment_top_0.jpg", + "-Y": "../../../../images/test/data/ibl/papermill/environment/environment_bottom_0.jpg", + "+Z": "../../../../images/test/data/ibl/papermill/environment/environment_front_0.jpg", + "-Z": "../../../../images/test/data/ibl/papermill/environment/environment_back_0.jpg" + } +} diff --git a/modules/textures/test/data/composite-image/image-texture-mipmaps.json b/modules/textures/test/data/composite-image/image-texture-mipmaps.json new file mode 100644 index 0000000000..f5b049d997 --- /dev/null +++ b/modules/textures/test/data/composite-image/image-texture-mipmaps.json @@ -0,0 +1,8 @@ +{ + "shape": "image-texture", + "mipmaps": [ + "../../../../images/test/data/ibl/papermill/specular/specular_back_0.jpg", + "../../../../images/test/data/ibl/papermill/specular/specular_back_1.jpg", + "../../../../images/test/data/ibl/papermill/specular/specular_back_2.jpg" + ] +} diff --git a/modules/textures/test/data/composite-image/image-texture.json b/modules/textures/test/data/composite-image/image-texture.json new file mode 100644 index 0000000000..aca45c23fc --- /dev/null +++ b/modules/textures/test/data/composite-image/image-texture.json @@ -0,0 +1,4 @@ +{ + "shape": "image-texture", + "image": "../../../../images/test/data/ibl/brdfLUT.png" +} diff --git a/modules/textures/test/index.ts b/modules/textures/test/index.ts index a82b73d96c..c6eab9acae 100644 --- a/modules/textures/test/index.ts +++ b/modules/textures/test/index.ts @@ -6,6 +6,7 @@ import './utils/format-utils.spec'; import './texture-api/async-deep-map.spec'; import './texture-api/load-image.spec'; +import './texture-loader.spec'; import './basis-loader.spec'; import './crunch-loader.spec'; diff --git a/modules/textures/test/texture-api/load-image.spec.ts b/modules/textures/test/texture-api/load-image.spec.ts index fbe0500dbe..a63d0b3e9d 100644 --- a/modules/textures/test/texture-api/load-image.spec.ts +++ b/modules/textures/test/texture-api/load-image.spec.ts @@ -3,21 +3,23 @@ // Copyright (c) vis.gl contributors import test from 'tape-promise/tape'; +import {fetchFile} from '@loaders.gl/core'; import {loadImageTexture, loadImageTextureArray, loadImageTextureCube} from '@loaders.gl/textures'; import {isImage} from '@loaders.gl/images'; const LUT_URL = '@loaders.gl/images/test/data/ibl/brdfLUT.png'; const PAPERMILL_URL = '@loaders.gl/images/test/data/ibl/papermill'; -test.skip('loadImageTexture#mipLevels=0', async (t) => { - const image = await loadImageTexture(LUT_URL); +test('loadImageTexture#mipLevels=0', async (t) => { + const image = await loadImageTexture(LUT_URL, {fetch: fetchFile}); t.ok(isImage(image)); t.end(); }); -test.skip('loadImageTexture#mipLevels=auto', async (t) => { +test('loadImageTexture#mipLevels=auto', async (t) => { const mipmappedImage = await loadImageTexture(({lod}) => `specular/specular_back_${lod}.jpg`, { baseUrl: PAPERMILL_URL, + fetch: fetchFile, image: { mipLevels: 'auto' } @@ -26,12 +28,13 @@ test.skip('loadImageTexture#mipLevels=auto', async (t) => { t.end(); }); -test.skip('loadImageTextureArray#mipLevels=0', async (t) => { +test('loadImageTextureArray#mipLevels=0', async (t) => { const images = await loadImageTextureArray( 10, ({index}) => `specular/specular_back_${index}.jpg`, { - baseUrl: PAPERMILL_URL + baseUrl: PAPERMILL_URL, + fetch: fetchFile } ); t.equal(images.length, 10, 'loadArray loaded 10 images'); @@ -39,12 +42,13 @@ test.skip('loadImageTextureArray#mipLevels=0', async (t) => { t.end(); }); -test.skip('loadImageTextureArray#mipLevels=auto', async (t) => { +test('loadImageTextureArray#mipLevels=auto', async (t) => { const images = await loadImageTextureArray( 1, ({index, lod}) => `specular/specular_back_${lod}.jpg`, { baseUrl: PAPERMILL_URL, + fetch: fetchFile, image: { mipLevels: 'auto' } @@ -58,11 +62,12 @@ test.skip('loadImageTextureArray#mipLevels=auto', async (t) => { t.end(); }); -test.skip('loadImageTextureCube#mipLevels=0', async (t) => { +test('loadImageTextureCube#mipLevels=0', async (t) => { const imageCube = await loadImageTextureCube( ({direction}) => `diffuse/diffuse_${direction}_0.jpg`, { - baseUrl: PAPERMILL_URL + baseUrl: PAPERMILL_URL, + fetch: fetchFile } ); t.equal(Object.keys(imageCube).length, 6, 'image cube has 6 images'); @@ -73,11 +78,12 @@ test.skip('loadImageTextureCube#mipLevels=0', async (t) => { t.end(); }); -test.skip('loadImageTextureCube#mipLevels=auto', async (t) => { +test('loadImageTextureCube#mipLevels=auto', async (t) => { const imageCube = await loadImageTextureCube( ({direction, lod}) => `specular/specular_${direction}_${lod}.jpg`, { baseUrl: PAPERMILL_URL, + fetch: fetchFile, image: { mipLevels: 'auto' } diff --git a/modules/textures/test/texture-loader.spec.ts b/modules/textures/test/texture-loader.spec.ts new file mode 100644 index 0000000000..811e252aae --- /dev/null +++ b/modules/textures/test/texture-loader.spec.ts @@ -0,0 +1,493 @@ +// loaders.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import test from 'tape-promise/tape'; +import {fetchFile, load, parse, selectLoader} from '@loaders.gl/core'; +import { + TextureArrayLoader, + TextureCubeArrayLoader, + TextureCubeLoader, + TextureLoader +} from '@loaders.gl/textures'; + +const IMAGE_TEXTURE_MANIFEST_URL = + '@loaders.gl/textures/test/data/composite-image/image-texture.json'; +const IMAGE_TEXTURE_MIPMAP_MANIFEST_URL = + '@loaders.gl/textures/test/data/composite-image/image-texture-mipmaps.json'; +const IMAGE_TEXTURE_ARRAY_MANIFEST_URL = + '@loaders.gl/textures/test/data/composite-image/image-texture-array.json'; +const IMAGE_TEXTURE_CUBE_MANIFEST_URL = + '@loaders.gl/textures/test/data/composite-image/image-texture-cube.json'; + +function checkImageTextureLevel(t, textureLevel, message: string) { + t.equal(textureLevel.shape, 'texture-level', `${message} has texture-level shape`); + t.notOk(textureLevel.compressed, `${message} is uncompressed`); + t.ok(textureLevel.width > 0, `${message} has width`); + t.ok(textureLevel.height > 0, `${message} has height`); + t.ok(textureLevel.data instanceof Uint8Array, `${message} uses a Uint8Array payload`); + t.equal(textureLevel.data.length, 0, `${message} uses an empty byte payload`); + t.equal(textureLevel.textureFormat, 'rgba8unorm', `${message} has canonical texture format`); + if (typeof ImageBitmap !== 'undefined') { + t.ok(textureLevel.imageBitmap instanceof ImageBitmap, `${message} preserves the ImageBitmap`); + } +} + +test('TextureLoader#load manifest', async (t) => { + const texture = await load(IMAGE_TEXTURE_MANIFEST_URL, TextureLoader); + t.equal(texture.shape, 'texture', 'returns a texture'); + t.equal(texture.type, '2d', 'returns a 2d texture'); + t.equal(texture.data.length, 1, 'returns one level'); + checkImageTextureLevel(t, texture.data[0], 'level 0'); + t.end(); +}); + +test('TextureLoader#load mipmaps manifest', async (t) => { + const texture = await load(IMAGE_TEXTURE_MIPMAP_MANIFEST_URL, TextureLoader); + t.equal(texture.shape, 'texture', 'returns a texture'); + t.equal(texture.type, '2d', 'returns a 2d texture'); + t.equal(texture.data.length, 3, 'loads all mip levels'); + texture.data.forEach((textureLevel, index) => + checkImageTextureLevel(t, textureLevel, `level ${index}`) + ); + t.end(); +}); + +test('TextureArrayLoader#load manifest', async (t) => { + const texture = await load(IMAGE_TEXTURE_ARRAY_MANIFEST_URL, TextureArrayLoader); + t.equal(texture.shape, 'texture', 'returns a texture'); + t.equal(texture.type, '2d-array', 'returns a 2d array texture'); + t.equal(texture.data.length, 2, 'loads every layer'); + texture.data.forEach((layer, index) => { + t.equal(layer.length, 1, `layer ${index} has one mip level`); + checkImageTextureLevel(t, layer[0], `layer ${index} level 0`); + }); + t.end(); +}); + +test('TextureCubeLoader#load manifest', async (t) => { + const texture = await load(IMAGE_TEXTURE_CUBE_MANIFEST_URL, TextureCubeLoader); + t.equal(texture.shape, 'texture', 'returns a texture'); + t.equal(texture.type, 'cube', 'returns a cube texture'); + t.equal(texture.data.length, 6, 'loads six cube faces'); + texture.data.forEach((faceLevels, index) => { + t.equal(faceLevels.length, 1, `face ${index} has one mip level`); + checkImageTextureLevel(t, faceLevels[0], `face ${index} level 0`); + }); + t.end(); +}); + +test('TextureLoader#parse with core.baseUrl', async (t) => { + const requestedUrls: string[] = []; + const memberUrl = '@loaders.gl/images/test/data/ibl/brdfLUT.png'; + const fetch = async (url: string): Promise => { + requestedUrls.push(url); + if (!url.endsWith('images/test/data/ibl/brdfLUT.png')) { + throw new Error(`Unexpected URL ${url}`); + } + return await fetchFile(memberUrl); + }; + + const manifestText = JSON.stringify({ + shape: 'image-texture', + image: '../../../../images/test/data/ibl/brdfLUT.png' + }); + + const texture = await parse(manifestText, TextureLoader, { + fetch, + core: { + baseUrl: IMAGE_TEXTURE_MANIFEST_URL + } + }); + + t.equal(texture.type, '2d', 'resolves relative member URLs against core.baseUrl'); + t.ok( + requestedUrls[0]?.endsWith('images/test/data/ibl/brdfLUT.png'), + 'normalizes aliased relative member URLs against core.baseUrl' + ); + checkImageTextureLevel(t, texture.data[0], 'level 0'); + t.end(); +}); + +test('TextureLoader#parse with extensionless core.baseUrl', async (t) => { + const requestedUrls: string[] = []; + const fetch = async (url: string): Promise => { + requestedUrls.push(url); + return await fetchFile('@loaders.gl/images/test/data/ibl/brdfLUT.png'); + }; + + const texture = await parse( + JSON.stringify({ + shape: 'image-texture', + image: 'member.png' + }), + TextureLoader, + { + fetch, + core: { + baseUrl: 'https://example.com/manifests/texture-manifest' + } + } + ); + + t.equal(texture.type, '2d', 'resolves against the source manifest directory'); + t.deepEqual( + requestedUrls, + ['https://example.com/manifests/member.png'], + 'extensionless manifest URLs still resolve sibling members' + ); + checkImageTextureLevel(t, texture.data[0], 'level 0'); + t.end(); +}); + +test('TextureLoader#template with auto mipLevels', async (t) => { + const requestedUrls: string[] = []; + const specularImagePattern = + /images\/test\/data\/ibl\/papermill\/specular\/specular_back_(\d+)\.jpg$/; + const fetch = async (url: string): Promise => { + requestedUrls.push(url); + const match = url.match(specularImagePattern); + if (!match) { + throw new Error(`Unexpected URL ${url}`); + } + return await fetchFile( + `@loaders.gl/images/test/data/ibl/papermill/specular/specular_back_${match[1]}.jpg` + ); + }; + + const manifestText = JSON.stringify({ + shape: 'image-texture', + mipLevels: 'auto', + template: '../../../../images/test/data/ibl/papermill/specular/specular_back_{lod}.jpg' + }); + + const texture = await parse(manifestText, TextureLoader, { + fetch, + core: { + baseUrl: IMAGE_TEXTURE_MIPMAP_MANIFEST_URL + } + }); + + t.equal(texture.type, '2d', 'returns a 2d texture'); + t.equal(texture.data.length, 10, 'template source expands the auto mip chain'); + t.ok( + requestedUrls.some((url) => + url.endsWith('images/test/data/ibl/papermill/specular/specular_back_0.jpg') + ), + 'template source resolves aliased relative member URLs' + ); + texture.data.forEach((textureLevel, index) => + checkImageTextureLevel(t, textureLevel, `level ${index}`) + ); + t.end(); +}); + +test('TextureLoader#template supports escaped braces', async (t) => { + const requestedUrls: string[] = []; + const fetch = async (url: string): Promise => { + requestedUrls.push(url); + return await fetchFile('@loaders.gl/images/test/data/ibl/brdfLUT.png'); + }; + + const texture = await parse( + JSON.stringify({ + shape: 'image-texture', + mipLevels: 1, + template: 'file\\{literal\\}.png' + }), + TextureLoader, + { + fetch, + core: { + baseUrl: 'https://example.com/manifest.json' + } + } + ); + + checkImageTextureLevel(t, texture.data[0], 'level 0'); + t.equal( + decodeURIComponent(requestedUrls[0]), + 'https://example.com/file{literal}.png', + 'escaped braces are preserved' + ); + t.end(); +}); + +test('TextureLoader#template reports invalid placeholders', async (t) => { + await t.rejects( + parse( + JSON.stringify({ + shape: 'image-texture', + mipLevels: 1, + template: 'texture-{unknown}.png' + }), + TextureLoader, + { + fetch: fetchFile, + core: { + baseUrl: 'https://example.com/manifest.json' + } + } + ), + /unsupported placeholder/, + 'invalid placeholders fail with a clear error' + ); + t.end(); +}); + +test('TextureArrayLoader#template supports index placeholder', async (t) => { + const requestedUrls: string[] = []; + const fetch = async (url: string): Promise => { + requestedUrls.push(url); + return await fetchFile('@loaders.gl/images/test/data/ibl/brdfLUT.png'); + }; + + const texture = await parse( + JSON.stringify({ + shape: 'image-texture-array', + layers: [ + {mipLevels: 1, template: 'layer-{index}.png'}, + {mipLevels: 1, template: 'layer-{index}.png'} + ] + }), + TextureArrayLoader, + { + fetch, + core: { + baseUrl: 'https://example.com/manifest.json' + } + } + ); + + t.equal(texture.type, '2d-array', 'template array returns a 2d array texture'); + t.equal(texture.data.length, 2, 'template array expands every layer'); + texture.data.forEach((layer, index) => + checkImageTextureLevel(t, layer[0], `layer ${index} level 0`) + ); + t.deepEqual( + requestedUrls, + ['https://example.com/layer-0.png', 'https://example.com/layer-1.png'], + 'index placeholder is expanded for each layer' + ); + t.end(); +}); + +test('TextureLoader#uses the top-level fetch function for members', async (t) => { + const requestedUrls: string[] = []; + const manifestUrl = 'https://example.com/image-texture.json'; + const memberUrl = 'https://example.com/member.png'; + + const fetch = async (url: string): Promise => { + requestedUrls.push(url); + + if (url === manifestUrl) { + return new Response(JSON.stringify({shape: 'image-texture', image: 'member.png'}), { + headers: {'Content-Type': 'application/json'} + }); + } + + if (url === memberUrl) { + return await fetchFile('@loaders.gl/images/test/data/ibl/brdfLUT.png'); + } + + throw new Error(`Unexpected URL ${url}`); + }; + + const texture = await load(manifestUrl, TextureLoader, {fetch}); + + checkImageTextureLevel(t, texture.data[0], 'level 0'); + t.deepEqual(requestedUrls, [manifestUrl, memberUrl], 'top-level fetch is reused for members'); + t.end(); +}); + +test('TextureLoader#uses top-level loaders for members', async (t) => { + const manifestUrl = 'https://example.com/image-texture.json'; + const memberUrl = 'https://example.com/member.foo'; + const CustomMemberLoader = { + id: 'custom-member', + name: 'Custom Member', + module: 'textures-test', + version: 'latest', + extensions: ['foo'], + mimeTypes: ['application/x.foo'], + parse: async () => [ + { + shape: 'texture-level', + compressed: true, + width: 4, + height: 4, + data: new Uint8Array([1, 2, 3]), + textureFormat: 'bc1-rgba-unorm' + } + ] + }; + + const fetch = async (url: string): Promise => { + if (url === manifestUrl) { + return new Response(JSON.stringify({shape: 'image-texture', image: 'member.foo'}), { + headers: {'Content-Type': 'application/json'} + }); + } + + if (url === memberUrl) { + return new Response(new Uint8Array([1, 2, 3]), { + headers: {'Content-Type': 'application/x.foo'} + }); + } + + throw new Error(`Unexpected URL ${url}`); + }; + + const texture = await load(manifestUrl, [TextureLoader, CustomMemberLoader], {fetch}); + + t.equal(texture.type, '2d', 'member parsing still wraps into a 2d texture'); + t.equal(texture.format, 'bc1-rgba-unorm', 'member parsing uses the custom loader result'); + t.equal(texture.data[0].compressed, true, 'member level remains compressed'); + t.deepEqual(Array.from(texture.data[0].data), [1, 2, 3], 'member level data is preserved'); + t.end(); +}); + +test('TextureCubeLoader#template supports cube placeholders', async (t) => { + const requestedUrls: string[] = []; + const fetch = async (url: string): Promise => { + requestedUrls.push(url); + return await fetchFile('@loaders.gl/images/test/data/ibl/brdfLUT.png'); + }; + + const texture = await parse( + JSON.stringify({ + shape: 'image-texture-cube', + faces: { + '+X': {mipLevels: 1, template: 'cube-{face}-{direction}.png'}, + '-X': {mipLevels: 1, template: 'cube-{face}-{direction}.png'}, + '+Y': {mipLevels: 1, template: 'cube-{face}-{direction}.png'}, + '-Y': {mipLevels: 1, template: 'cube-{face}-{direction}.png'}, + '+Z': {mipLevels: 1, template: 'cube-{face}-{direction}.png'}, + '-Z': {mipLevels: 1, template: 'cube-{face}-{direction}.png'} + } + }), + TextureCubeLoader, + { + fetch, + core: { + baseUrl: 'https://example.com/manifest.json' + } + } + ); + + t.equal(texture.type, 'cube', 'template cube returns a cube texture'); + t.equal(texture.data.length, 6, 'template cube expands every face'); + t.deepEqual( + requestedUrls, + [ + 'https://example.com/cube-+X-right.png', + 'https://example.com/cube--X-left.png', + 'https://example.com/cube-+Y-top.png', + 'https://example.com/cube--Y-bottom.png', + 'https://example.com/cube-+Z-front.png', + 'https://example.com/cube--Z-back.png' + ], + 'cube placeholders are expanded for every face' + ); + t.end(); +}); + +test('TextureCubeArrayLoader#template supports layer index and face placeholders', async (t) => { + const requestedUrls: string[] = []; + const fetch = async (url: string): Promise => { + requestedUrls.push(url); + return await fetchFile('@loaders.gl/images/test/data/ibl/brdfLUT.png'); + }; + + const texture = await parse( + JSON.stringify({ + shape: 'image-texture-cube-array', + layers: [ + { + faces: { + '+X': {mipLevels: 1, template: 'cube-{index}-{face}.png'}, + '-X': {mipLevels: 1, template: 'cube-{index}-{face}.png'}, + '+Y': {mipLevels: 1, template: 'cube-{index}-{face}.png'}, + '-Y': {mipLevels: 1, template: 'cube-{index}-{face}.png'}, + '+Z': {mipLevels: 1, template: 'cube-{index}-{face}.png'}, + '-Z': {mipLevels: 1, template: 'cube-{index}-{face}.png'} + } + }, + { + faces: { + '+X': {mipLevels: 1, template: 'cube-{index}-{face}.png'}, + '-X': {mipLevels: 1, template: 'cube-{index}-{face}.png'}, + '+Y': {mipLevels: 1, template: 'cube-{index}-{face}.png'}, + '-Y': {mipLevels: 1, template: 'cube-{index}-{face}.png'}, + '+Z': {mipLevels: 1, template: 'cube-{index}-{face}.png'}, + '-Z': {mipLevels: 1, template: 'cube-{index}-{face}.png'} + } + } + ] + }), + TextureCubeArrayLoader, + { + fetch, + core: { + baseUrl: 'https://example.com/manifest.json' + } + } + ); + + t.equal(texture.type, 'cube-array', 'cube array returns a cube-array texture'); + t.equal(texture.data.length, 2, 'cube array returns one cubemap per layer'); + t.equal(texture.data[0].length, 6, 'each layer contains six cube faces'); + t.equal(requestedUrls.length, 12, 'all cube array members are loaded'); + t.ok(requestedUrls.includes('https://example.com/cube-0-+X.png'), 'layer index 0 is expanded'); + t.ok(requestedUrls.includes('https://example.com/cube-1--Z.png'), 'layer index 1 is expanded'); + t.end(); +}); + +test('Texture loaders#select by shape', async (t) => { + const loader = await selectLoader( + JSON.stringify({ + shape: 'image-texture-array', + layers: ['layer-0.png'] + }), + [TextureLoader, TextureArrayLoader, TextureCubeLoader, TextureCubeArrayLoader] + ); + + t.is(loader, TextureArrayLoader, 'shape discriminator selects the matching loader'); + t.end(); +}); + +test('Texture loaders#load selects by shape for JSON responses', async (t) => { + const manifestUrl = 'https://example.com/texture-manifest'; + const memberUrl = 'https://example.com/member.png'; + + const fetch = async (url: string): Promise => { + if (url === manifestUrl) { + return new Response( + JSON.stringify({ + shape: 'image-texture-array', + layers: ['member.png'] + }), + { + headers: {'Content-Type': 'application/json'} + } + ); + } + + if (url === memberUrl) { + return await fetchFile('@loaders.gl/images/test/data/ibl/brdfLUT.png'); + } + + throw new Error(`Unexpected URL ${url}`); + }; + + const texture = await load( + manifestUrl, + [TextureLoader, TextureArrayLoader, TextureCubeLoader, TextureCubeArrayLoader], + {fetch} + ); + + t.equal(texture.type, '2d-array', 'shape discriminator selects the matching manifest loader'); + t.equal(texture.data.length, 1, 'loads the array layer through URL-based auto-selection'); + checkImageTextureLevel(t, texture.data[0][0], 'layer 0 level 0'); + t.end(); +});