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
+
+
+
+
+
+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
+
+
+
+
+
+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
+
+
+
+
+
+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
+
+
+
+
+
+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();
+});