@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zubin Khavarian
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,132 @@
1
+ # @zkmake/three-textures
2
+
3
+ A dev panel for the textures in a three.js scene. See every texture the scene draws with, download
4
+ one, paint over it, and put it back: the scene shows your version right away, in the real renderer,
5
+ lighting and post-processing. Flip A/B against the original, keep swaps across reloads, and
6
+ live-link a file so every save in your paint app shows up in the running scene.
7
+
8
+ Compressed textures (KTX2, Basis) swap too: the panel reads them back from the GPU to a PNG you can
9
+ edit, and swaps your image in by binding an image twin with the same settings wherever the
10
+ original was used. Vanilla three and React Three Fiber.
11
+
12
+ ```sh
13
+ bun add -d @zkmake/three-textures # or npm i -D / pnpm add -D
14
+ ```
15
+
16
+ Live demo: [three-textures.pages.dev](https://three-textures.pages.dev/), with a vanilla three and
17
+ a React Three Fiber take on the same scene: an image texture and a clone of it, a data texture, and
18
+ a KTX2 atlas cut into four cards. Download the atlas, paint on it, drop it back.
19
+
20
+ ## Vanilla three
21
+
22
+ ```ts
23
+ import { mountTexturePanel } from "@zkmake/three-textures/ui";
24
+
25
+ const panel = mountTexturePanel({ scene, renderer });
26
+ // later: panel.dispose()
27
+ ```
28
+
29
+ ## React Three Fiber
30
+
31
+ ```tsx
32
+ import { TexturePanel } from "@zkmake/three-textures/react";
33
+
34
+ <Canvas>
35
+ {import.meta.env.DEV && <TexturePanel />}
36
+ {/* … */}
37
+ </Canvas>;
38
+ ```
39
+
40
+ It reads the scene and renderer from the canvas and asks for a frame after each swap, so
41
+ `frameloop="demand"` works.
42
+
43
+ ## What each row does
44
+
45
+ | Button | What it does |
46
+ | ----------------------------------- | ------------------------------------------------------------------------------------- |
47
+ | thumbnail | A large preview, on the side away from the panel |
48
+ | download | The swapped file; else the original file; else a GPU readback as PNG |
49
+ | replace (or drop a file on the row) | Swap an image in |
50
+ | live link (Chrome, Edge) | Pick a file once; every save re-applies it. Survives a reload (the browser asks once) |
51
+ | A/B | Show the original, keep the swap |
52
+ | revert | Put the original back and forget the swap |
53
+
54
+ Swaps are kept in IndexedDB under `storageKey` (default `three-textures`) and come back on reload by
55
+ texture name. **Reset all** clears them.
56
+
57
+ ## How a swap works
58
+
59
+ - **Image textures** swap in place: the texture's `Source` gets your image and every texture sharing
60
+ it (clones included) re-uploads. Anything holding the texture, your own code included, sees the
61
+ swap, and every setting stays.
62
+ - **Compressed and data textures** can't take an image in place, so each gets an image twin with its
63
+ wrap, filters, colour space, uv channel and transform, bound into every material slot and uniform
64
+ that held the original, including meshes mounted later. `flipY` is off, the way compressed and data
65
+ textures lay their rows out, so a downloaded readback uploads back the same way up.
66
+
67
+ Downloads come out as the source image would: an sRGB texture is encoded back to sRGB, and a `flipY`
68
+ texture is turned back over. Readbacks and thumbnails of textures without a file need a
69
+ `WebGLRenderer`; with WebGPU they're skipped.
70
+
71
+ ## The panel
72
+
73
+ It's the same frame as the three-meter HUD (three-meter's `mountDevPanel`): drag it by the grip disc
74
+ to any edge, the sliders disc shows or hides the list (compact is the brand label and the count), and
75
+ the blend disc dims it while the pointer is away. Both panels together:
76
+
77
+ ```ts
78
+ mountPerfHud(monitor, { defaultPlacement: { edge: "left" } });
79
+ mountTexturePanel({ scene, renderer }); // right edge by default
80
+ ```
81
+
82
+ ## Options
83
+
84
+ ```ts
85
+ mountTexturePanel({
86
+ scene,
87
+ renderer,
88
+ // Textures to name (and list before the scene shows them), with the file each came from.
89
+ textures: [{ name: "bunny", texture: bunnyTexture, src: bunnyUrl }],
90
+ // Redraw anything derived from a swapped texture, such as an atlas built on a canvas.
91
+ onSwap: ({ id, bound, state }) => rebuildAtlasFor(id, bound[0]),
92
+ storageKey: "my-game", // null keeps swaps for this page only
93
+ theme: "system", // or "dark", "light"
94
+ defaultPlacement: { edge: "right", align: "start" },
95
+ compact: false, // start with the list hidden (remembered after the first toggle)
96
+ });
97
+ ```
98
+
99
+ Textures are found in every material's texture properties (not only three's named maps) and uniforms
100
+ (`ShaderMaterial`, custom shader materials), plus the scene's background and environment. A texture
101
+ held only by your code (never bound to a material) needs `textures` to be listed.
102
+
103
+ ## In your own dev panel
104
+
105
+ `createTexturePanel(lab)` is the bare element, for a host with its own tabs. `TextureLab` is the
106
+ engine with no UI:
107
+
108
+ ```ts
109
+ import { TextureLab } from "@zkmake/three-textures";
110
+ import { createTexturePanel } from "@zkmake/three-textures/ui";
111
+
112
+ const lab = new TextureLab({ scene, renderer });
113
+ const panel = createTexturePanel(lab);
114
+
115
+ tab.append(panel.element);
116
+ panel.setEdge("right"); // the preview opens on the left
117
+ panel.setActive(false); // pause the scene re-read while the tab is hidden
118
+
119
+ await lab.swap("bunny", file);
120
+ await lab.showOriginal("bunny", true);
121
+ ```
122
+
123
+ ## Examples
124
+
125
+ [`apps/three-textures-site`](https://github.com/zkmake/three-kit/tree/main/apps/three-textures-site)
126
+ is what runs at three-textures.pages.dev: [`src/demos/vanilla.ts`](https://github.com/zkmake/three-kit/blob/main/apps/three-textures-site/src/demos/vanilla.ts)
127
+ with `mountTexturePanel`, [`src/demos/r3f.tsx`](https://github.com/zkmake/three-kit/blob/main/apps/three-textures-site/src/demos/r3f.tsx)
128
+ with `<TexturePanel />`, each beside the three-meter HUD. `?r3f` opens on Fiber.
129
+
130
+ ## License
131
+
132
+ MIT
@@ -0,0 +1,33 @@
1
+ import { a as TextureInfo, c as StoredSwap, d as memoryStore, f as TextureKind, h as sizeOf, i as TextureEntry, l as SwapStore, m as kindOf, n as LinkState, o as TextureLab, p as formatOf, r as SwapEvent, s as TextureLabOptions, t as EntryState, u as indexedDbStore } from "./lab-CN1v1x-y.js";
2
+ import { Material, Object3D, Texture, WebGLRenderer } from "three";
3
+ //#region src/core/discover.d.ts
4
+ /** A texture's `Source`: the image data it uploads, shared by its clones. */
5
+ type TextureSource = Texture["source"];
6
+ /** Somewhere a texture is bound: `owner[key]`. A uniform's owner is the uniform, `{ value }`. */
7
+ type TextureRef = {
8
+ owner: Record<string, unknown>;
9
+ key: string;
10
+ /** The material to recompile after rebinding, when the owner is one of its slots or uniforms. */
11
+ material: Material | null;
12
+ };
13
+ type Found = {
14
+ source: TextureSource;
15
+ textures: Set<Texture>;
16
+ refs: TextureRef[];
17
+ /** `material.slot` names, for a fallback label and the "used by" count. */
18
+ slots: Set<string>;
19
+ materials: Set<Material>;
20
+ };
21
+ /** Every texture under `root`, grouped by source. Materials shared by many meshes are read once. */
22
+ export declare const findTextures: (root: Object3D) => Map<TextureSource, Found>;
23
+ //#endregion
24
+ //#region src/core/readback.d.ts
25
+ /** Pixels of `texture` at `width × height`, top row first, as they'd be saved to a file. */
26
+ export declare const readTexture: (renderer: WebGLRenderer, texture: Texture, width: number, height: number) => ImageData;
27
+ /** `readTexture` as a PNG. */
28
+ export declare const readTextureBlob: (renderer: WebGLRenderer, texture: Texture, width: number, height: number) => Promise<Blob>;
29
+ /** Whether this renderer can read textures back (WebGL, not WebGPU). */
30
+ export declare const canReadBack: (renderer: unknown) => renderer is WebGLRenderer;
31
+ //#endregion
32
+ export { type EntryState, type Found, type LinkState, type StoredSwap, type SwapEvent, type SwapStore, type TextureEntry, type TextureInfo, type TextureKind, TextureLab, type TextureLabOptions, type TextureRef, formatOf, indexedDbStore, kindOf, memoryStore, sizeOf };
33
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ import { a as readTexture, c as kindOf, i as canReadBack, l as sizeOf, n as indexedDbStore, o as readTextureBlob, r as memoryStore, s as formatOf, t as TextureLab, u as findTextures } from "./lab-CozZhJaq.js";
2
+ export { TextureLab, canReadBack, findTextures, formatOf, indexedDbStore, kindOf, memoryStore, readTexture, readTextureBlob, sizeOf };
@@ -0,0 +1,177 @@
1
+ import { Object3D, Texture } from "three";
2
+ //#region src/core/kinds.d.ts
3
+ type TextureKind = "image" | "compressed" | "data" | "unsupported";
4
+ declare const kindOf: (texture: Texture) => TextureKind;
5
+ /** Pixel size of the base level. */
6
+ declare const sizeOf: (texture: Texture) => {
7
+ width: number;
8
+ height: number;
9
+ };
10
+ /** A short format label: `ASTC`, `ETC2`, `RGBA` data, or the image's own. */
11
+ declare const formatOf: (texture: Texture) => string;
12
+ //#endregion
13
+ //#region src/core/store.d.ts
14
+ /**
15
+ * Where swaps survive a reload. IndexedDB by default: painted textures are megabytes each, past
16
+ * what `localStorage` holds, and a live-linked file's handle can be kept too (handles clone into
17
+ * IndexedDB; they can't be stringified).
18
+ */
19
+ type StoredSwap = {
20
+ blob: Blob;
21
+ fileName: string | null;
22
+ showingOriginal: boolean;
23
+ /** A live-linked file, to resume after a reload (the browser asks for permission again). */
24
+ handle?: FileSystemFileHandleLike | undefined;
25
+ };
26
+ /** The slice of the File System Access API's file handle the live link uses. */
27
+ type FileSystemFileHandleLike = {
28
+ name: string;
29
+ getFile(): Promise<File>;
30
+ queryPermission?(options: {
31
+ mode: "read";
32
+ }): Promise<PermissionState>;
33
+ requestPermission?(options: {
34
+ mode: "read";
35
+ }): Promise<PermissionState>;
36
+ };
37
+ type SwapStore = {
38
+ get(id: string): Promise<StoredSwap | undefined>;
39
+ set(id: string, swap: StoredSwap): Promise<void>;
40
+ delete(id: string): Promise<void>;
41
+ clear(): Promise<void>;
42
+ };
43
+ /** A store that lives as long as the page: tests, or `storageKey: null`. */
44
+ declare const memoryStore: () => SwapStore;
45
+ /** Swaps in IndexedDB, keyed `<storageKey>/<texture name>`. Falls back to memory without it. */
46
+ declare const indexedDbStore: (storageKey: string) => SwapStore;
47
+ //#endregion
48
+ //#region src/core/lab.d.ts
49
+ type TextureInfo = {
50
+ name: string;
51
+ texture: Texture;
52
+ /** The file it was loaded from, for downloads and thumbnails. */
53
+ src?: string;
54
+ };
55
+ type SwapEvent = {
56
+ id: string;
57
+ /** Every texture sharing the swapped pixels (the originals). */
58
+ textures: Texture[];
59
+ /** The textures drawing now: the originals after an in-place swap or a revert, else twins. */
60
+ bound: Texture[];
61
+ state: EntryState;
62
+ };
63
+ type TextureLabOptions = {
64
+ /** Where to look for textures. */
65
+ scene: Object3D;
66
+ /** A `WebGLRenderer`, for reading back textures with no file (compressed, data, generated). */
67
+ renderer?: unknown;
68
+ /** Textures to list whether or not the scene shows them yet, with names and source files. */
69
+ textures?: readonly TextureInfo[];
70
+ /** IndexedDB key prefix for saved swaps. `null` keeps swaps for this page only. Default `three-textures`. */
71
+ storageKey?: string | null;
72
+ /** Replaces the store (tests). */
73
+ store?: SwapStore;
74
+ /** After any swap, toggle or revert: redraw derived textures (atlases) from `bound`. */
75
+ onSwap?: (event: SwapEvent) => void;
76
+ /** Ask for a frame, for render-on-demand loops (R3F `frameloop="demand"`). */
77
+ invalidate?: () => void;
78
+ };
79
+ type EntryState = "original" | "swapped" | "showing-original";
80
+ type LinkState = "off" | "live" | "paused";
81
+ type TextureEntry = {
82
+ id: string;
83
+ kind: TextureKind;
84
+ /** `ASTC`, `ETC2`, `webp`, `canvas`, `data`… */
85
+ format: string;
86
+ width: number;
87
+ height: number;
88
+ colorSpace: string;
89
+ /** The file it came from, when known. */
90
+ src: string | null;
91
+ /** How many materials use it. */
92
+ materials: number;
93
+ state: EntryState;
94
+ link: LinkState;
95
+ /** Name of the file swapped in. */
96
+ swapName: string | null;
97
+ /** Why it can't be swapped, when it can't. */
98
+ unsupported: string | null;
99
+ /** Counts every change to what it shows: a cache key for thumbnails. */
100
+ revision: number;
101
+ /** One of the original textures. */
102
+ texture: Texture;
103
+ };
104
+ declare class TextureLab {
105
+ private readonly options;
106
+ private readonly store;
107
+ private readonly records;
108
+ private readonly byId;
109
+ /** Sources of rebind twins, so a refresh doesn't list them as textures of their own. */
110
+ private readonly twinSources;
111
+ private readonly listeners;
112
+ private readonly restoring;
113
+ private poll;
114
+ private disposed;
115
+ constructor(options: TextureLabOptions);
116
+ /** Whether this browser can live-link a file (the File System Access API: Chrome, Edge). */
117
+ static get canLink(): boolean;
118
+ /** Whether downloads and thumbnails can read textures back from the GPU. */
119
+ get canReadBack(): boolean;
120
+ subscribe(listener: () => void): () => void;
121
+ /** Every texture, by name. */
122
+ entries(): TextureEntry[];
123
+ entry(id: string): TextureEntry | null;
124
+ /**
125
+ * Look at the scene again: textures that appeared get entries (and their saved swap back), a
126
+ * rebind swap reaches slots mounted since. Entries whose textures left the scene stay while
127
+ * swapped or registered, so their state isn't lost.
128
+ */
129
+ refresh(): void;
130
+ /** Swap `blob` (an image file) in for the texture `id`. */
131
+ swap(id: string, blob: Blob, { fileName, persist }?: {
132
+ fileName?: string | null;
133
+ persist?: boolean;
134
+ }): Promise<void>;
135
+ /** A/B: show the original (`true`) or the swap (`false`). */
136
+ showOriginal(id: string, original: boolean): Promise<void>;
137
+ /** Back to the original, forgetting the swap. */
138
+ revert(id: string): Promise<void>;
139
+ /** Every entry back to its original, and every saved swap cleared. */
140
+ revertAll(): Promise<void>;
141
+ /**
142
+ * The pixels showing now as a file: the swapped file, else the original file when its URL is
143
+ * known and fetchable, else a GPU readback as PNG.
144
+ */
145
+ toBlob(id: string): Promise<{
146
+ blob: Blob;
147
+ fileName: string;
148
+ }>;
149
+ /** Save the pixels showing now to the downloads folder. */
150
+ download(id: string): Promise<void>;
151
+ /** A small picture of what's showing now, as a data URL; `null` when there's no way to draw it. */
152
+ thumbnail(id: string, max?: number): string | null;
153
+ /**
154
+ * Live-link a file: pick it once, and every save re-applies it. Call from a click (the browser
155
+ * shows a file picker, or asks to resume permission for a link restored after a reload).
156
+ */
157
+ link(id: string): Promise<void>;
158
+ /** Stop following the file; the last version stays swapped in. */
159
+ unlink(id: string): Promise<void>;
160
+ dispose(): void;
161
+ private add;
162
+ private nameOf;
163
+ private uniqueName;
164
+ private isRegistered;
165
+ private restore;
166
+ private save;
167
+ private startPolling;
168
+ private checkLinks;
169
+ private stopLink;
170
+ private require;
171
+ private entryOf;
172
+ private changed;
173
+ private notify;
174
+ }
175
+ //#endregion
176
+ export { TextureInfo as a, StoredSwap as c, memoryStore as d, TextureKind as f, sizeOf as h, TextureEntry as i, SwapStore as l, kindOf as m, LinkState as n, TextureLab as o, formatOf as p, SwapEvent as r, TextureLabOptions as s, EntryState as t, indexedDbStore as u };
177
+ //# sourceMappingURL=lab-CN1v1x-y.d.ts.map