@zkmake/three-textures 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +132 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +2 -0
- package/dist/lab-CN1v1x-y.d.ts +177 -0
- package/dist/lab-CozZhJaq.js +864 -0
- package/dist/lab-CozZhJaq.js.map +1 -0
- package/dist/mount-Bk5wVa_1.d.ts +25 -0
- package/dist/mount-D6SH40yP.js +507 -0
- package/dist/mount-D6SH40yP.js.map +1 -0
- package/dist/react.d.ts +14 -0
- package/dist/react.js +51 -0
- package/dist/react.js.map +1 -0
- package/dist/ui.d.ts +28 -0
- package/dist/ui.js +2 -0
- package/package.json +80 -0
|
@@ -0,0 +1,864 @@
|
|
|
1
|
+
import { GLSL3, Mesh, NoColorSpace, OrthographicCamera, PlaneGeometry, RGBAFormat, RawShaderMaterial, SRGBColorSpace, Texture, UnsignedByteType, WebGLRenderTarget } from "three";
|
|
2
|
+
//#region src/core/discover.ts
|
|
3
|
+
const isTexture = (value) => value !== null && typeof value === "object" && value.isTexture === true;
|
|
4
|
+
const add = (found, texture, ref, slot) => {
|
|
5
|
+
let entry = found.get(texture.source);
|
|
6
|
+
if (!entry) {
|
|
7
|
+
entry = {
|
|
8
|
+
source: texture.source,
|
|
9
|
+
textures: /* @__PURE__ */ new Set(),
|
|
10
|
+
refs: [],
|
|
11
|
+
slots: /* @__PURE__ */ new Set(),
|
|
12
|
+
materials: /* @__PURE__ */ new Set()
|
|
13
|
+
};
|
|
14
|
+
found.set(texture.source, entry);
|
|
15
|
+
}
|
|
16
|
+
entry.textures.add(texture);
|
|
17
|
+
entry.refs.push(ref);
|
|
18
|
+
entry.slots.add(slot);
|
|
19
|
+
if (ref.material) entry.materials.add(ref.material);
|
|
20
|
+
};
|
|
21
|
+
const scanMaterial = (found, material) => {
|
|
22
|
+
const record = material;
|
|
23
|
+
const label = material.name || material.type;
|
|
24
|
+
for (const key of Object.keys(record)) {
|
|
25
|
+
const value = record[key];
|
|
26
|
+
if (isTexture(value)) add(found, value, {
|
|
27
|
+
owner: record,
|
|
28
|
+
key,
|
|
29
|
+
material
|
|
30
|
+
}, `${label}.${key}`);
|
|
31
|
+
}
|
|
32
|
+
const uniforms = material.uniforms;
|
|
33
|
+
for (const [name, uniform] of Object.entries(uniforms ?? {})) if (uniform && isTexture(uniform.value)) add(found, uniform.value, {
|
|
34
|
+
owner: uniform,
|
|
35
|
+
key: "value",
|
|
36
|
+
material
|
|
37
|
+
}, `${label}.${name}`);
|
|
38
|
+
};
|
|
39
|
+
/** Every texture under `root`, grouped by source. Materials shared by many meshes are read once. */
|
|
40
|
+
const findTextures = (root) => {
|
|
41
|
+
const found = /* @__PURE__ */ new Map();
|
|
42
|
+
const seen = /* @__PURE__ */ new Set();
|
|
43
|
+
const scene = root;
|
|
44
|
+
for (const key of ["background", "environment"]) {
|
|
45
|
+
const value = scene[key];
|
|
46
|
+
if (isTexture(value)) add(found, value, {
|
|
47
|
+
owner: scene,
|
|
48
|
+
key,
|
|
49
|
+
material: null
|
|
50
|
+
}, `scene.${key}`);
|
|
51
|
+
}
|
|
52
|
+
root.traverse((object) => {
|
|
53
|
+
const held = object.material;
|
|
54
|
+
for (const material of Array.isArray(held) ? held : held ? [held] : []) if (!seen.has(material)) {
|
|
55
|
+
seen.add(material);
|
|
56
|
+
scanMaterial(found, material);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
return found;
|
|
60
|
+
};
|
|
61
|
+
//#endregion
|
|
62
|
+
//#region src/core/kinds.ts
|
|
63
|
+
/** An element three uploads with `texImage2D` as it is: what an image swap can replace. */
|
|
64
|
+
const isImageLike = (data) => {
|
|
65
|
+
if (data === null || typeof data !== "object") return false;
|
|
66
|
+
const hasSize = "width" in data && "height" in data;
|
|
67
|
+
return typeof HTMLImageElement !== "undefined" && data instanceof HTMLImageElement || typeof HTMLCanvasElement !== "undefined" && data instanceof HTMLCanvasElement || typeof ImageBitmap !== "undefined" && data instanceof ImageBitmap || typeof OffscreenCanvas !== "undefined" && data instanceof OffscreenCanvas || hasSize && !("data" in data) && !("mipmaps" in data);
|
|
68
|
+
};
|
|
69
|
+
const kindOf = (texture) => {
|
|
70
|
+
const flags = texture;
|
|
71
|
+
if (flags.isCompressedArrayTexture || flags.isCompressedCubeTexture || flags.isData3DTexture || flags.isDataArrayTexture || flags.isCubeTexture || flags.isVideoTexture || flags.isDepthTexture || flags.isFramebufferTexture || flags.isRenderTargetTexture || flags.isArrayTexture) return "unsupported";
|
|
72
|
+
if (flags.isCompressedTexture) return "compressed";
|
|
73
|
+
if (flags.isDataTexture) return "data";
|
|
74
|
+
return isImageLike(texture.source.data) ? "image" : "unsupported";
|
|
75
|
+
};
|
|
76
|
+
/** Pixel size of the base level. */
|
|
77
|
+
const sizeOf = (texture) => {
|
|
78
|
+
const image = texture.source.data;
|
|
79
|
+
const base = texture.mipmaps?.[0];
|
|
80
|
+
return {
|
|
81
|
+
width: base?.width ?? image?.width ?? 0,
|
|
82
|
+
height: base?.height ?? image?.height ?? 0
|
|
83
|
+
};
|
|
84
|
+
};
|
|
85
|
+
/** GPU compressed format families, by three's (GL-valued) format constants. */
|
|
86
|
+
const FAMILIES = [
|
|
87
|
+
[
|
|
88
|
+
33776,
|
|
89
|
+
33779,
|
|
90
|
+
"BC1–3"
|
|
91
|
+
],
|
|
92
|
+
[
|
|
93
|
+
35840,
|
|
94
|
+
35843,
|
|
95
|
+
"PVRTC"
|
|
96
|
+
],
|
|
97
|
+
[
|
|
98
|
+
35916,
|
|
99
|
+
35919,
|
|
100
|
+
"BC1–3"
|
|
101
|
+
],
|
|
102
|
+
[
|
|
103
|
+
36196,
|
|
104
|
+
36196,
|
|
105
|
+
"ETC1"
|
|
106
|
+
],
|
|
107
|
+
[
|
|
108
|
+
36283,
|
|
109
|
+
36286,
|
|
110
|
+
"BC4–5"
|
|
111
|
+
],
|
|
112
|
+
[
|
|
113
|
+
36492,
|
|
114
|
+
36495,
|
|
115
|
+
"BC6–7"
|
|
116
|
+
],
|
|
117
|
+
[
|
|
118
|
+
37488,
|
|
119
|
+
37497,
|
|
120
|
+
"ETC2"
|
|
121
|
+
],
|
|
122
|
+
[
|
|
123
|
+
37808,
|
|
124
|
+
37853,
|
|
125
|
+
"ASTC"
|
|
126
|
+
]
|
|
127
|
+
];
|
|
128
|
+
/** A short format label: `ASTC`, `ETC2`, `RGBA` data, or the image's own. */
|
|
129
|
+
const formatOf = (texture) => {
|
|
130
|
+
const kind = kindOf(texture);
|
|
131
|
+
if (kind === "compressed") {
|
|
132
|
+
const format = texture.format;
|
|
133
|
+
const family = FAMILIES.find(([low, high]) => format >= low && format <= high);
|
|
134
|
+
return family ? family[2] : "compressed";
|
|
135
|
+
}
|
|
136
|
+
if (kind === "data") return "data";
|
|
137
|
+
const data = texture.source.data;
|
|
138
|
+
const src = data?.currentSrc || data?.src || "";
|
|
139
|
+
return /\.([a-z0-9]+)(?:[?#]|$)/i.exec(src)?.[1]?.toLowerCase() ?? (data && "getContext" in data ? "canvas" : "image");
|
|
140
|
+
};
|
|
141
|
+
//#endregion
|
|
142
|
+
//#region src/core/names.ts
|
|
143
|
+
/** Vite's default asset name is `[name]-[hash][ext]` with an 8-character base64url hash. */
|
|
144
|
+
const VITE_HASH = /-[A-Za-z0-9_-]{8}(\.[^.]+)$/;
|
|
145
|
+
/** The file name at the end of a URL, query and hash dropped, bundler hash stripped. */
|
|
146
|
+
const fileNameOf = (url) => {
|
|
147
|
+
if (url.startsWith("data:") || url.startsWith("blob:")) return null;
|
|
148
|
+
const last = decodeURIComponent(url.split(/[?#]/)[0].split("/").pop() ?? "");
|
|
149
|
+
return last ? last.replace(VITE_HASH, "$1") : null;
|
|
150
|
+
};
|
|
151
|
+
/** The URL an image texture was loaded from, when it has one. */
|
|
152
|
+
const sourceUrlOf = (texture) => {
|
|
153
|
+
const data = texture.source.data;
|
|
154
|
+
const url = data?.currentSrc || data?.src;
|
|
155
|
+
return typeof url === "string" && url.length > 0 ? url : null;
|
|
156
|
+
};
|
|
157
|
+
/** A label without its extension, for a row: `bunny-baked.webp` reads as `bunny-baked`. */
|
|
158
|
+
const stem = (file) => file.replace(/\.[^.]+$/, "");
|
|
159
|
+
//#endregion
|
|
160
|
+
//#region src/core/readback.ts
|
|
161
|
+
/**
|
|
162
|
+
* Read a texture back from the GPU as pixels: the one way to see (and save) a compressed, data or
|
|
163
|
+
* generated texture, which has no file behind it. WebGL only.
|
|
164
|
+
*
|
|
165
|
+
* The texture is drawn onto a quad in a render target the size asked for, sampled raw (no uv
|
|
166
|
+
* transform), and read back. Two corrections make the PNG a file an artist can edit and upload
|
|
167
|
+
* back unchanged:
|
|
168
|
+
* - Rows: three uploads an image with `flipY` bottom row first, so its readback is flipped back;
|
|
169
|
+
* a `flipY: false` texture (compressed, data, glTF) is written in the order it was uploaded.
|
|
170
|
+
* - Colour: an sRGB texture samples as linear, so it's encoded back to sRGB bytes.
|
|
171
|
+
*/
|
|
172
|
+
const VERTEX = `
|
|
173
|
+
in vec3 position;
|
|
174
|
+
in vec2 uv;
|
|
175
|
+
out vec2 vUv;
|
|
176
|
+
|
|
177
|
+
void main() {
|
|
178
|
+
vUv = uv;
|
|
179
|
+
gl_Position = vec4(position.xy, 0.0, 1.0);
|
|
180
|
+
}
|
|
181
|
+
`;
|
|
182
|
+
const FRAGMENT = `
|
|
183
|
+
precision highp float;
|
|
184
|
+
uniform sampler2D map;
|
|
185
|
+
uniform bool encodeSRGB;
|
|
186
|
+
in vec2 vUv;
|
|
187
|
+
out vec4 color;
|
|
188
|
+
|
|
189
|
+
vec3 toSRGB(vec3 linear) {
|
|
190
|
+
return mix(linear * 12.92, 1.055 * pow(linear, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, linear));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
void main() {
|
|
194
|
+
vec4 texel = texture(map, vUv);
|
|
195
|
+
|
|
196
|
+
color = encodeSRGB ? vec4(toSRGB(texel.rgb), texel.a) : texel;
|
|
197
|
+
}
|
|
198
|
+
`;
|
|
199
|
+
/** WebGL ignores `flipY` for an `ImageBitmap`: it always uploads top row first. */
|
|
200
|
+
const uploadsFlipped = (texture) => texture.flipY && !(typeof ImageBitmap !== "undefined" && texture.source.data instanceof ImageBitmap);
|
|
201
|
+
let quad = null;
|
|
202
|
+
const camera = new OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
|
203
|
+
const quadFor = (texture) => {
|
|
204
|
+
quad ??= new Mesh(new PlaneGeometry(2, 2), new RawShaderMaterial({
|
|
205
|
+
glslVersion: GLSL3,
|
|
206
|
+
vertexShader: VERTEX,
|
|
207
|
+
fragmentShader: FRAGMENT,
|
|
208
|
+
uniforms: {
|
|
209
|
+
map: { value: null },
|
|
210
|
+
encodeSRGB: { value: false }
|
|
211
|
+
},
|
|
212
|
+
depthTest: false,
|
|
213
|
+
depthWrite: false
|
|
214
|
+
}));
|
|
215
|
+
quad.frustumCulled = false;
|
|
216
|
+
quad.material.uniforms.map.value = texture;
|
|
217
|
+
quad.material.uniforms.encodeSRGB.value = texture.colorSpace === SRGBColorSpace;
|
|
218
|
+
return quad;
|
|
219
|
+
};
|
|
220
|
+
/** Pixels of `texture` at `width × height`, top row first, as they'd be saved to a file. */
|
|
221
|
+
const readTexture = (renderer, texture, width, height) => {
|
|
222
|
+
const target = new WebGLRenderTarget(width, height, {
|
|
223
|
+
type: UnsignedByteType,
|
|
224
|
+
format: RGBAFormat,
|
|
225
|
+
colorSpace: NoColorSpace,
|
|
226
|
+
depthBuffer: false
|
|
227
|
+
});
|
|
228
|
+
const previous = renderer.getRenderTarget();
|
|
229
|
+
const xr = renderer.xr.enabled;
|
|
230
|
+
const autoClear = renderer.autoClear;
|
|
231
|
+
const pixels = new Uint8Array(width * height * 4);
|
|
232
|
+
try {
|
|
233
|
+
renderer.xr.enabled = false;
|
|
234
|
+
renderer.autoClear = true;
|
|
235
|
+
renderer.setRenderTarget(target);
|
|
236
|
+
renderer.render(quadFor(texture), camera);
|
|
237
|
+
renderer.readRenderTargetPixels(target, 0, 0, width, height, pixels);
|
|
238
|
+
} finally {
|
|
239
|
+
renderer.setRenderTarget(previous);
|
|
240
|
+
renderer.xr.enabled = xr;
|
|
241
|
+
renderer.autoClear = autoClear;
|
|
242
|
+
target.dispose();
|
|
243
|
+
quad.material.uniforms.map.value = null;
|
|
244
|
+
}
|
|
245
|
+
const out = new ImageData(width, height);
|
|
246
|
+
const row = width * 4;
|
|
247
|
+
const flipped = uploadsFlipped(texture);
|
|
248
|
+
for (let y = 0; y < height; y += 1) {
|
|
249
|
+
const from = flipped ? height - 1 - y : y;
|
|
250
|
+
out.data.set(pixels.subarray(from * row, from * row + row), y * row);
|
|
251
|
+
}
|
|
252
|
+
return out;
|
|
253
|
+
};
|
|
254
|
+
/** `readTexture` as a PNG. */
|
|
255
|
+
const readTextureBlob = async (renderer, texture, width, height) => {
|
|
256
|
+
const canvas = document.createElement("canvas");
|
|
257
|
+
canvas.width = width;
|
|
258
|
+
canvas.height = height;
|
|
259
|
+
canvas.getContext("2d").putImageData(readTexture(renderer, texture, width, height), 0, 0);
|
|
260
|
+
return new Promise((resolve, reject) => canvas.toBlob((blob) => blob ? resolve(blob) : reject(/* @__PURE__ */ new Error("toBlob failed")), "image/png"));
|
|
261
|
+
};
|
|
262
|
+
/** Whether this renderer can read textures back (WebGL, not WebGPU). */
|
|
263
|
+
const canReadBack = (renderer) => renderer !== null && typeof renderer === "object" && typeof renderer.readRenderTargetPixels === "function" && renderer.isWebGLRenderer === true;
|
|
264
|
+
//#endregion
|
|
265
|
+
//#region src/core/store.ts
|
|
266
|
+
/** A store that lives as long as the page: tests, or `storageKey: null`. */
|
|
267
|
+
const memoryStore = () => {
|
|
268
|
+
const swaps = /* @__PURE__ */ new Map();
|
|
269
|
+
return {
|
|
270
|
+
get: async (id) => swaps.get(id),
|
|
271
|
+
set: async (id, swap) => {
|
|
272
|
+
swaps.set(id, swap);
|
|
273
|
+
},
|
|
274
|
+
delete: async (id) => {
|
|
275
|
+
swaps.delete(id);
|
|
276
|
+
},
|
|
277
|
+
clear: async () => {
|
|
278
|
+
swaps.clear();
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
};
|
|
282
|
+
const DATABASE = "zkmake-three-textures";
|
|
283
|
+
/** Sorts after every character a key can hold: the top of a key-prefix range. */
|
|
284
|
+
const LAST_CHAR = String.fromCharCode(65535);
|
|
285
|
+
const STORE = "swaps";
|
|
286
|
+
const request = (req) => new Promise((resolve, reject) => {
|
|
287
|
+
req.onsuccess = () => resolve(req.result);
|
|
288
|
+
req.onerror = () => reject(req.error);
|
|
289
|
+
});
|
|
290
|
+
/** Swaps in IndexedDB, keyed `<storageKey>/<texture name>`. Falls back to memory without it. */
|
|
291
|
+
const indexedDbStore = (storageKey) => {
|
|
292
|
+
if (typeof indexedDB === "undefined") return memoryStore();
|
|
293
|
+
let opened = null;
|
|
294
|
+
const database = () => {
|
|
295
|
+
opened ??= new Promise((resolve, reject) => {
|
|
296
|
+
const open = indexedDB.open(DATABASE, 1);
|
|
297
|
+
open.onupgradeneeded = () => open.result.createObjectStore(STORE);
|
|
298
|
+
open.onsuccess = () => resolve(open.result);
|
|
299
|
+
open.onerror = () => reject(open.error);
|
|
300
|
+
});
|
|
301
|
+
return opened;
|
|
302
|
+
};
|
|
303
|
+
const key = (id) => `${storageKey}/${id}`;
|
|
304
|
+
const store = async (mode) => (await database()).transaction(STORE, mode).objectStore(STORE);
|
|
305
|
+
return {
|
|
306
|
+
get: async (id) => await request((await store("readonly")).get(key(id))),
|
|
307
|
+
set: async (id, swap) => {
|
|
308
|
+
await request((await store("readwrite")).put(swap, key(id)));
|
|
309
|
+
},
|
|
310
|
+
delete: async (id) => {
|
|
311
|
+
await request((await store("readwrite")).delete(key(id)));
|
|
312
|
+
},
|
|
313
|
+
clear: async () => {
|
|
314
|
+
const range = IDBKeyRange.bound(`${storageKey}/`, `${storageKey}/${LAST_CHAR}`);
|
|
315
|
+
await request((await store("readwrite")).delete(range));
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
};
|
|
319
|
+
//#endregion
|
|
320
|
+
//#region src/core/swap.ts
|
|
321
|
+
/**
|
|
322
|
+
* Put an image in place of a texture's pixels, and take it out again.
|
|
323
|
+
*
|
|
324
|
+
* - **In place**, for an image texture: the shared `Source` gets the new image and every texture
|
|
325
|
+
* on it re-uploads. Anything holding the texture — materials, the app's own references — sees
|
|
326
|
+
* the swap, and the texture keeps all its settings.
|
|
327
|
+
* - **Rebind**, for compressed and data textures: their data is GPU-ready blocks or raw bytes, so
|
|
328
|
+
* an image can't go in their place. Each gets an image twin with its settings (wrap, filters,
|
|
329
|
+
* colour space, uv channel and transform) and the twin is bound wherever the original was: the
|
|
330
|
+
* material slots and uniforms `findTextures` saw. `flipY` is off, the way compressed and data
|
|
331
|
+
* textures lay their rows, so a readback of the original uploads back the same way up.
|
|
332
|
+
*/
|
|
333
|
+
const canSwap = (kind) => kind !== "unsupported";
|
|
334
|
+
const bump = (textures) => {
|
|
335
|
+
for (const texture of textures) texture.needsUpdate = true;
|
|
336
|
+
};
|
|
337
|
+
const recompile = (materials) => {
|
|
338
|
+
for (const material of materials) if (material) material.needsUpdate = true;
|
|
339
|
+
};
|
|
340
|
+
const twinOf = (original, source) => {
|
|
341
|
+
const twin = new Texture();
|
|
342
|
+
twin.source = source;
|
|
343
|
+
twin.name = original.name;
|
|
344
|
+
twin.mapping = original.mapping;
|
|
345
|
+
twin.channel = original.channel;
|
|
346
|
+
twin.wrapS = original.wrapS;
|
|
347
|
+
twin.wrapT = original.wrapT;
|
|
348
|
+
twin.magFilter = original.magFilter;
|
|
349
|
+
twin.minFilter = original.minFilter;
|
|
350
|
+
twin.anisotropy = original.anisotropy;
|
|
351
|
+
twin.colorSpace = original.colorSpace;
|
|
352
|
+
twin.offset.copy(original.offset);
|
|
353
|
+
twin.repeat.copy(original.repeat);
|
|
354
|
+
twin.center.copy(original.center);
|
|
355
|
+
twin.rotation = original.rotation;
|
|
356
|
+
twin.matrixAutoUpdate = original.matrixAutoUpdate;
|
|
357
|
+
twin.matrix.copy(original.matrix);
|
|
358
|
+
twin.userData = { ...original.userData };
|
|
359
|
+
twin.flipY = false;
|
|
360
|
+
twin.premultiplyAlpha = false;
|
|
361
|
+
twin.generateMipmaps = true;
|
|
362
|
+
twin.needsUpdate = true;
|
|
363
|
+
return twin;
|
|
364
|
+
};
|
|
365
|
+
/** Bind twins into every ref that still holds an original. Returns the new bindings. */
|
|
366
|
+
const bindRefs = (refs, twins) => {
|
|
367
|
+
const made = [];
|
|
368
|
+
for (const ref of refs) {
|
|
369
|
+
const original = ref.owner[ref.key];
|
|
370
|
+
const twin = twins.get(original);
|
|
371
|
+
if (twin) {
|
|
372
|
+
ref.owner[ref.key] = twin;
|
|
373
|
+
made.push({
|
|
374
|
+
...ref,
|
|
375
|
+
original,
|
|
376
|
+
twin
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
recompile(made.map((binding) => binding.material));
|
|
381
|
+
return made;
|
|
382
|
+
};
|
|
383
|
+
/** Show `image` on the texture: a first swap, or a new image over an earlier one. */
|
|
384
|
+
const applyImage = (found, kind, image, existing) => {
|
|
385
|
+
if (kind === "image") {
|
|
386
|
+
const original = existing?.mode === "in-place" ? existing.original : found.source.data;
|
|
387
|
+
found.source.data = image;
|
|
388
|
+
bump(found.textures);
|
|
389
|
+
return {
|
|
390
|
+
mode: "in-place",
|
|
391
|
+
image,
|
|
392
|
+
showingOriginal: false,
|
|
393
|
+
original,
|
|
394
|
+
textures: new Set(found.textures)
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
if (existing?.mode === "rebind") {
|
|
398
|
+
existing.source.data = image;
|
|
399
|
+
bump(existing.twins.values());
|
|
400
|
+
const applied = {
|
|
401
|
+
...existing,
|
|
402
|
+
image,
|
|
403
|
+
showingOriginal: false
|
|
404
|
+
};
|
|
405
|
+
if (existing.showingOriginal) showOriginal(applied, false);
|
|
406
|
+
return applied;
|
|
407
|
+
}
|
|
408
|
+
const SourceClass = found.source.constructor;
|
|
409
|
+
const source = new SourceClass(image);
|
|
410
|
+
const twins = new Map([...found.textures].map((texture) => [texture, twinOf(texture, source)]));
|
|
411
|
+
return {
|
|
412
|
+
mode: "rebind",
|
|
413
|
+
image,
|
|
414
|
+
showingOriginal: false,
|
|
415
|
+
source,
|
|
416
|
+
twins,
|
|
417
|
+
bindings: bindRefs(found.refs, twins)
|
|
418
|
+
};
|
|
419
|
+
};
|
|
420
|
+
/**
|
|
421
|
+
* A rebind swap reaches slots found after it: a mesh mounted later with the original texture.
|
|
422
|
+
* Call after re-finding the scene's textures. In-place swaps need nothing: they changed the data.
|
|
423
|
+
*/
|
|
424
|
+
const bindNewRefs = (found, applied) => {
|
|
425
|
+
if (applied.mode !== "rebind" || applied.showingOriginal) return;
|
|
426
|
+
for (const texture of found.textures) if (!applied.twins.has(texture)) applied.twins.set(texture, twinOf(texture, applied.source));
|
|
427
|
+
applied.bindings.push(...bindRefs(found.refs, applied.twins));
|
|
428
|
+
};
|
|
429
|
+
/** Flip between the original and the swap (A/B), keeping both. */
|
|
430
|
+
const showOriginal = (applied, original) => {
|
|
431
|
+
applied.showingOriginal = original;
|
|
432
|
+
if (applied.mode === "in-place") {
|
|
433
|
+
const first = [...applied.textures][0];
|
|
434
|
+
if (first) first.source.data = original ? applied.original : applied.image;
|
|
435
|
+
bump(applied.textures);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
for (const binding of applied.bindings) {
|
|
439
|
+
const from = original ? binding.twin : binding.original;
|
|
440
|
+
if (binding.owner[binding.key] === from) binding.owner[binding.key] = original ? binding.original : binding.twin;
|
|
441
|
+
}
|
|
442
|
+
recompile(applied.bindings.map((binding) => binding.material));
|
|
443
|
+
};
|
|
444
|
+
/** Undo the swap: the original data or bindings back, twins freed. */
|
|
445
|
+
const revertImage = (applied) => {
|
|
446
|
+
showOriginal(applied, true);
|
|
447
|
+
if (applied.mode === "rebind") for (const twin of applied.twins.values()) twin.dispose();
|
|
448
|
+
if (typeof ImageBitmap !== "undefined" && applied.image instanceof ImageBitmap) applied.image.close();
|
|
449
|
+
};
|
|
450
|
+
/** The textures drawing now: twins while a rebind swap shows, the originals otherwise. */
|
|
451
|
+
const boundTextures = (found, applied) => applied?.mode === "rebind" && !applied.showingOriginal ? [...applied.twins.values()] : [...found.textures];
|
|
452
|
+
//#endregion
|
|
453
|
+
//#region src/core/lab.ts
|
|
454
|
+
const LINK_POLL_MS = 600;
|
|
455
|
+
/** Decode to the same kind of object the texture held: WebGL ignores `flipY` for bitmaps. */
|
|
456
|
+
const decode = async (blob, like) => {
|
|
457
|
+
if (typeof ImageBitmap !== "undefined" && like instanceof ImageBitmap) return {
|
|
458
|
+
image: await createImageBitmap(blob, {
|
|
459
|
+
premultiplyAlpha: "none",
|
|
460
|
+
colorSpaceConversion: "none"
|
|
461
|
+
}),
|
|
462
|
+
url: null
|
|
463
|
+
};
|
|
464
|
+
const url = URL.createObjectURL(blob);
|
|
465
|
+
const image = new Image();
|
|
466
|
+
image.src = url;
|
|
467
|
+
await image.decode();
|
|
468
|
+
return {
|
|
469
|
+
image,
|
|
470
|
+
url
|
|
471
|
+
};
|
|
472
|
+
};
|
|
473
|
+
const drawToDataUrl = (draw, width, height) => {
|
|
474
|
+
const canvas = document.createElement("canvas");
|
|
475
|
+
canvas.width = width;
|
|
476
|
+
canvas.height = height;
|
|
477
|
+
draw(canvas.getContext("2d"));
|
|
478
|
+
return canvas.toDataURL("image/png");
|
|
479
|
+
};
|
|
480
|
+
const fit = (width, height, max) => {
|
|
481
|
+
const scale = Math.min(1, max / Math.max(width, height, 1));
|
|
482
|
+
return {
|
|
483
|
+
width: Math.max(1, Math.round(width * scale)),
|
|
484
|
+
height: Math.max(1, Math.round(height * scale))
|
|
485
|
+
};
|
|
486
|
+
};
|
|
487
|
+
var TextureLab = class {
|
|
488
|
+
options;
|
|
489
|
+
store;
|
|
490
|
+
records = /* @__PURE__ */ new Map();
|
|
491
|
+
byId = /* @__PURE__ */ new Map();
|
|
492
|
+
/** Sources of rebind twins, so a refresh doesn't list them as textures of their own. */
|
|
493
|
+
twinSources = /* @__PURE__ */ new Set();
|
|
494
|
+
listeners = /* @__PURE__ */ new Set();
|
|
495
|
+
restoring = /* @__PURE__ */ new Set();
|
|
496
|
+
poll = null;
|
|
497
|
+
disposed = false;
|
|
498
|
+
constructor(options) {
|
|
499
|
+
this.options = options;
|
|
500
|
+
this.store = options.store ?? (options.storageKey === null ? memoryStore() : indexedDbStore(options.storageKey ?? "three-textures"));
|
|
501
|
+
this.refresh();
|
|
502
|
+
}
|
|
503
|
+
/** Whether this browser can live-link a file (the File System Access API: Chrome, Edge). */
|
|
504
|
+
static get canLink() {
|
|
505
|
+
return typeof window !== "undefined" && typeof window.showOpenFilePicker === "function";
|
|
506
|
+
}
|
|
507
|
+
/** Whether downloads and thumbnails can read textures back from the GPU. */
|
|
508
|
+
get canReadBack() {
|
|
509
|
+
return canReadBack(this.options.renderer);
|
|
510
|
+
}
|
|
511
|
+
subscribe(listener) {
|
|
512
|
+
this.listeners.add(listener);
|
|
513
|
+
return () => {
|
|
514
|
+
this.listeners.delete(listener);
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
/** Every texture, by name. */
|
|
518
|
+
entries() {
|
|
519
|
+
return [...this.byId.values()].map((record) => this.entryOf(record)).sort((a, b) => a.id.localeCompare(b.id));
|
|
520
|
+
}
|
|
521
|
+
entry(id) {
|
|
522
|
+
const record = this.byId.get(id);
|
|
523
|
+
return record ? this.entryOf(record) : null;
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Look at the scene again: textures that appeared get entries (and their saved swap back), a
|
|
527
|
+
* rebind swap reaches slots mounted since. Entries whose textures left the scene stay while
|
|
528
|
+
* swapped or registered, so their state isn't lost.
|
|
529
|
+
*/
|
|
530
|
+
refresh() {
|
|
531
|
+
if (this.disposed) return;
|
|
532
|
+
const found = findTextures(this.options.scene);
|
|
533
|
+
let changed = false;
|
|
534
|
+
for (const info of this.options.textures ?? []) if (!found.has(info.texture.source)) found.set(info.texture.source, {
|
|
535
|
+
source: info.texture.source,
|
|
536
|
+
textures: /* @__PURE__ */ new Set([info.texture]),
|
|
537
|
+
refs: [],
|
|
538
|
+
slots: /* @__PURE__ */ new Set(),
|
|
539
|
+
materials: /* @__PURE__ */ new Set()
|
|
540
|
+
});
|
|
541
|
+
const seen = /* @__PURE__ */ new Set();
|
|
542
|
+
for (const [source, item] of found) {
|
|
543
|
+
if (this.twinSources.has(source)) continue;
|
|
544
|
+
seen.add(source);
|
|
545
|
+
const record = this.records.get(source);
|
|
546
|
+
if (record) {
|
|
547
|
+
const before = record.found.materials.size;
|
|
548
|
+
record.found = item;
|
|
549
|
+
if (record.applied) bindNewRefs(item, record.applied);
|
|
550
|
+
changed ||= before !== item.materials.size;
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
this.add(item);
|
|
554
|
+
changed = true;
|
|
555
|
+
}
|
|
556
|
+
for (const [source, record] of this.records) if (!seen.has(source) && !record.applied && !this.isRegistered(source)) {
|
|
557
|
+
this.records.delete(source);
|
|
558
|
+
this.byId.delete(record.id);
|
|
559
|
+
changed = true;
|
|
560
|
+
}
|
|
561
|
+
if (changed) this.notify();
|
|
562
|
+
}
|
|
563
|
+
/** Swap `blob` (an image file) in for the texture `id`. */
|
|
564
|
+
async swap(id, blob, { fileName = blob.name ?? null, persist = true } = {}) {
|
|
565
|
+
const record = this.require(id);
|
|
566
|
+
if (!canSwap(record.kind)) throw new Error(`three-textures: "${id}" can't be swapped (${record.kind})`);
|
|
567
|
+
const { image, url } = await decode(blob, record.found.source.data);
|
|
568
|
+
const previousUrl = record.url;
|
|
569
|
+
record.applied = applyImage(record.found, record.kind, image, record.applied);
|
|
570
|
+
if (record.applied.mode === "rebind") this.twinSources.add(record.applied.source);
|
|
571
|
+
record.blob = blob;
|
|
572
|
+
record.swapName = fileName || null;
|
|
573
|
+
record.url = url;
|
|
574
|
+
if (previousUrl && previousUrl !== url) URL.revokeObjectURL(previousUrl);
|
|
575
|
+
if (persist) await this.save(record);
|
|
576
|
+
this.changed(record);
|
|
577
|
+
}
|
|
578
|
+
/** A/B: show the original (`true`) or the swap (`false`). */
|
|
579
|
+
async showOriginal(id, original) {
|
|
580
|
+
const record = this.require(id);
|
|
581
|
+
if (!record.applied || record.applied.showingOriginal === original) return;
|
|
582
|
+
showOriginal(record.applied, original);
|
|
583
|
+
await this.save(record);
|
|
584
|
+
this.changed(record);
|
|
585
|
+
}
|
|
586
|
+
/** Back to the original, forgetting the swap. */
|
|
587
|
+
async revert(id) {
|
|
588
|
+
const record = this.require(id);
|
|
589
|
+
this.stopLink(record);
|
|
590
|
+
record.handle = null;
|
|
591
|
+
await this.store.delete(id);
|
|
592
|
+
if (!record.applied) {
|
|
593
|
+
this.notify();
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
revertImage(record.applied);
|
|
597
|
+
if (record.applied.mode === "rebind") this.twinSources.delete(record.applied.source);
|
|
598
|
+
record.applied = null;
|
|
599
|
+
record.blob = null;
|
|
600
|
+
record.swapName = null;
|
|
601
|
+
if (record.url) {
|
|
602
|
+
URL.revokeObjectURL(record.url);
|
|
603
|
+
record.url = null;
|
|
604
|
+
}
|
|
605
|
+
this.changed(record);
|
|
606
|
+
}
|
|
607
|
+
/** Every entry back to its original, and every saved swap cleared. */
|
|
608
|
+
async revertAll() {
|
|
609
|
+
for (const record of this.byId.values()) if (record.applied || record.handle) await this.revert(record.id);
|
|
610
|
+
await this.store.clear();
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* The pixels showing now as a file: the swapped file, else the original file when its URL is
|
|
614
|
+
* known and fetchable, else a GPU readback as PNG.
|
|
615
|
+
*/
|
|
616
|
+
async toBlob(id) {
|
|
617
|
+
const record = this.require(id);
|
|
618
|
+
const fallbackName = `${id}.png`;
|
|
619
|
+
if (record.applied && !record.applied.showingOriginal && record.blob) return {
|
|
620
|
+
blob: record.blob,
|
|
621
|
+
fileName: record.swapName ?? fallbackName
|
|
622
|
+
};
|
|
623
|
+
if (record.src) try {
|
|
624
|
+
const response = await fetch(record.src);
|
|
625
|
+
if (response.ok) return {
|
|
626
|
+
blob: await response.blob(),
|
|
627
|
+
fileName: fileNameOf(record.src) ?? fallbackName
|
|
628
|
+
};
|
|
629
|
+
} catch {}
|
|
630
|
+
const renderer = this.options.renderer;
|
|
631
|
+
if (!canReadBack(renderer)) throw new Error(`three-textures: "${id}" has no file to download and the renderer can't read it back`);
|
|
632
|
+
const texture = [...record.found.textures][0];
|
|
633
|
+
const { width, height } = sizeOf(texture);
|
|
634
|
+
return {
|
|
635
|
+
blob: await readTextureBlob(renderer, texture, width, height),
|
|
636
|
+
fileName: fallbackName
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
/** Save the pixels showing now to the downloads folder. */
|
|
640
|
+
async download(id) {
|
|
641
|
+
const { blob, fileName } = await this.toBlob(id);
|
|
642
|
+
const url = URL.createObjectURL(blob);
|
|
643
|
+
const link = document.createElement("a");
|
|
644
|
+
link.href = url;
|
|
645
|
+
link.download = fileName;
|
|
646
|
+
document.body.append(link);
|
|
647
|
+
link.click();
|
|
648
|
+
link.remove();
|
|
649
|
+
setTimeout(() => URL.revokeObjectURL(url), 1e3);
|
|
650
|
+
}
|
|
651
|
+
/** A small picture of what's showing now, as a data URL; `null` when there's no way to draw it. */
|
|
652
|
+
thumbnail(id, max = 96) {
|
|
653
|
+
const record = this.require(id);
|
|
654
|
+
const bound = boundTextures(record.found, record.applied)[0];
|
|
655
|
+
if (!bound) return null;
|
|
656
|
+
const data = bound.source.data;
|
|
657
|
+
if (kindOf(bound) === "image" || record.applied && !record.applied.showingOriginal) {
|
|
658
|
+
const { width, height } = fit(data.width, data.height, max);
|
|
659
|
+
try {
|
|
660
|
+
return drawToDataUrl((context) => context.drawImage(data, 0, 0, width, height), width, height);
|
|
661
|
+
} catch {
|
|
662
|
+
return null;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
const renderer = this.options.renderer;
|
|
666
|
+
if (!canReadBack(renderer)) return null;
|
|
667
|
+
const { width, height } = fit(sizeOf(bound).width, sizeOf(bound).height, max);
|
|
668
|
+
return drawToDataUrl((context) => context.putImageData(readTexture(renderer, bound, width, height), 0, 0), width, height);
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* Live-link a file: pick it once, and every save re-applies it. Call from a click (the browser
|
|
672
|
+
* shows a file picker, or asks to resume permission for a link restored after a reload).
|
|
673
|
+
*/
|
|
674
|
+
async link(id) {
|
|
675
|
+
const record = this.require(id);
|
|
676
|
+
let handle = record.handle;
|
|
677
|
+
if (handle) {
|
|
678
|
+
if ((await handle.requestPermission?.({ mode: "read" }) ?? "granted") !== "granted") return;
|
|
679
|
+
} else {
|
|
680
|
+
const picker = window.showOpenFilePicker;
|
|
681
|
+
if (!picker) throw new Error("three-textures: live links need the File System Access API (Chrome, Edge)");
|
|
682
|
+
[handle] = await picker({ types: [{
|
|
683
|
+
description: "Images",
|
|
684
|
+
accept: { "image/*": [
|
|
685
|
+
".png",
|
|
686
|
+
".jpg",
|
|
687
|
+
".jpeg",
|
|
688
|
+
".webp",
|
|
689
|
+
".avif"
|
|
690
|
+
] }
|
|
691
|
+
}] });
|
|
692
|
+
}
|
|
693
|
+
const file = await handle.getFile();
|
|
694
|
+
record.handle = handle;
|
|
695
|
+
record.link = "live";
|
|
696
|
+
record.lastModified = file.lastModified;
|
|
697
|
+
await this.swap(id, file, { fileName: handle.name });
|
|
698
|
+
this.startPolling();
|
|
699
|
+
}
|
|
700
|
+
/** Stop following the file; the last version stays swapped in. */
|
|
701
|
+
async unlink(id) {
|
|
702
|
+
const record = this.require(id);
|
|
703
|
+
this.stopLink(record);
|
|
704
|
+
record.handle = null;
|
|
705
|
+
await this.save(record);
|
|
706
|
+
this.notify();
|
|
707
|
+
}
|
|
708
|
+
dispose() {
|
|
709
|
+
this.disposed = true;
|
|
710
|
+
if (this.poll) clearInterval(this.poll);
|
|
711
|
+
this.listeners.clear();
|
|
712
|
+
}
|
|
713
|
+
add(item) {
|
|
714
|
+
const id = this.uniqueName(this.nameOf(item));
|
|
715
|
+
const texture = [...item.textures][0];
|
|
716
|
+
const registered = (this.options.textures ?? []).find((info) => info.texture.source === item.source);
|
|
717
|
+
const record = {
|
|
718
|
+
id,
|
|
719
|
+
found: item,
|
|
720
|
+
kind: kindOf(texture),
|
|
721
|
+
src: registered?.src ?? sourceUrlOf(texture),
|
|
722
|
+
applied: null,
|
|
723
|
+
blob: null,
|
|
724
|
+
swapName: null,
|
|
725
|
+
url: null,
|
|
726
|
+
handle: null,
|
|
727
|
+
link: "off",
|
|
728
|
+
lastModified: 0,
|
|
729
|
+
revision: 0
|
|
730
|
+
};
|
|
731
|
+
this.records.set(item.source, record);
|
|
732
|
+
this.byId.set(id, record);
|
|
733
|
+
this.restore(record);
|
|
734
|
+
}
|
|
735
|
+
nameOf(item) {
|
|
736
|
+
const registered = (this.options.textures ?? []).find((info) => info.texture.source === item.source);
|
|
737
|
+
if (registered) return registered.name;
|
|
738
|
+
for (const texture of item.textures) if (texture.name) return texture.name;
|
|
739
|
+
const url = sourceUrlOf([...item.textures][0]);
|
|
740
|
+
const file = url ? fileNameOf(url) : null;
|
|
741
|
+
return file ? stem(file) : [...item.slots][0] ?? "texture";
|
|
742
|
+
}
|
|
743
|
+
uniqueName(name) {
|
|
744
|
+
let unique = name;
|
|
745
|
+
for (let n = 2; this.byId.has(unique); n += 1) unique = `${name} ${n}`;
|
|
746
|
+
return unique;
|
|
747
|
+
}
|
|
748
|
+
isRegistered(source) {
|
|
749
|
+
return (this.options.textures ?? []).some((info) => info.texture.source === source);
|
|
750
|
+
}
|
|
751
|
+
async restore(record) {
|
|
752
|
+
if (this.restoring.has(record.id)) return;
|
|
753
|
+
this.restoring.add(record.id);
|
|
754
|
+
try {
|
|
755
|
+
const saved = await this.store.get(record.id);
|
|
756
|
+
if (!saved || this.disposed || record.applied || !canSwap(record.kind)) return;
|
|
757
|
+
await this.swap(record.id, saved.blob, {
|
|
758
|
+
fileName: saved.fileName,
|
|
759
|
+
persist: false
|
|
760
|
+
});
|
|
761
|
+
if (saved.showingOriginal) await this.showOriginal(record.id, true);
|
|
762
|
+
if (saved.handle) {
|
|
763
|
+
record.handle = saved.handle;
|
|
764
|
+
record.link = "paused";
|
|
765
|
+
this.notify();
|
|
766
|
+
}
|
|
767
|
+
} catch (error) {
|
|
768
|
+
console.warn(`three-textures: couldn't restore the swap for "${record.id}"`, error);
|
|
769
|
+
} finally {
|
|
770
|
+
this.restoring.delete(record.id);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
async save(record) {
|
|
774
|
+
if (!record.blob || !record.applied) return;
|
|
775
|
+
const swap = {
|
|
776
|
+
blob: record.blob,
|
|
777
|
+
fileName: record.swapName,
|
|
778
|
+
showingOriginal: record.applied.showingOriginal,
|
|
779
|
+
handle: record.handle ?? void 0
|
|
780
|
+
};
|
|
781
|
+
try {
|
|
782
|
+
await this.store.set(record.id, swap);
|
|
783
|
+
} catch (error) {
|
|
784
|
+
try {
|
|
785
|
+
await this.store.set(record.id, {
|
|
786
|
+
...swap,
|
|
787
|
+
handle: void 0
|
|
788
|
+
});
|
|
789
|
+
} catch {
|
|
790
|
+
console.warn(`three-textures: couldn't save the swap for "${record.id}"`, error);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
startPolling() {
|
|
795
|
+
this.poll ??= setInterval(() => void this.checkLinks(), LINK_POLL_MS);
|
|
796
|
+
}
|
|
797
|
+
async checkLinks() {
|
|
798
|
+
let live = 0;
|
|
799
|
+
for (const record of this.byId.values()) {
|
|
800
|
+
if (record.link !== "live" || !record.handle) continue;
|
|
801
|
+
live += 1;
|
|
802
|
+
try {
|
|
803
|
+
const file = await record.handle.getFile();
|
|
804
|
+
if (file.lastModified !== record.lastModified) {
|
|
805
|
+
record.lastModified = file.lastModified;
|
|
806
|
+
await this.swap(record.id, file, { fileName: record.handle.name });
|
|
807
|
+
}
|
|
808
|
+
} catch {
|
|
809
|
+
record.link = "paused";
|
|
810
|
+
this.notify();
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
if (live === 0 && this.poll) {
|
|
814
|
+
clearInterval(this.poll);
|
|
815
|
+
this.poll = null;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
stopLink(record) {
|
|
819
|
+
record.link = "off";
|
|
820
|
+
}
|
|
821
|
+
require(id) {
|
|
822
|
+
const record = this.byId.get(id);
|
|
823
|
+
if (!record) throw new Error(`three-textures: no texture "${id}"`);
|
|
824
|
+
return record;
|
|
825
|
+
}
|
|
826
|
+
entryOf(record) {
|
|
827
|
+
const texture = [...record.found.textures][0];
|
|
828
|
+
const { width, height } = sizeOf(texture);
|
|
829
|
+
return {
|
|
830
|
+
id: record.id,
|
|
831
|
+
kind: record.kind,
|
|
832
|
+
format: formatOf(texture),
|
|
833
|
+
width,
|
|
834
|
+
height,
|
|
835
|
+
colorSpace: texture.colorSpace,
|
|
836
|
+
src: record.src,
|
|
837
|
+
materials: record.found.materials.size,
|
|
838
|
+
state: !record.applied ? "original" : record.applied.showingOriginal ? "showing-original" : "swapped",
|
|
839
|
+
link: record.link,
|
|
840
|
+
swapName: record.swapName,
|
|
841
|
+
unsupported: canSwap(record.kind) ? null : "cube, 3D, array, video and render-target textures can't be swapped",
|
|
842
|
+
revision: record.revision,
|
|
843
|
+
texture
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
changed(record) {
|
|
847
|
+
record.revision += 1;
|
|
848
|
+
this.options.onSwap?.({
|
|
849
|
+
id: record.id,
|
|
850
|
+
textures: [...record.found.textures],
|
|
851
|
+
bound: boundTextures(record.found, record.applied),
|
|
852
|
+
state: this.entryOf(record).state
|
|
853
|
+
});
|
|
854
|
+
this.options.invalidate?.();
|
|
855
|
+
this.notify();
|
|
856
|
+
}
|
|
857
|
+
notify() {
|
|
858
|
+
for (const listener of this.listeners) listener();
|
|
859
|
+
}
|
|
860
|
+
};
|
|
861
|
+
//#endregion
|
|
862
|
+
export { readTexture as a, kindOf as c, canReadBack as i, sizeOf as l, indexedDbStore as n, readTextureBlob as o, memoryStore as r, formatOf as s, TextureLab as t, findTextures as u };
|
|
863
|
+
|
|
864
|
+
//# sourceMappingURL=lab-CozZhJaq.js.map
|