@aethervtt/shard-sprite 0.0.1

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 Patrick Dias
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,3 @@
1
+ # @aethervtt/shard-sprite
2
+
3
+ Part of [Shard](https://github.com/aethervtt/shard), a WebGPU-first 2D/3D game engine in TypeScript. See the repository for documentation.
@@ -0,0 +1,112 @@
1
+ import { AssetStore } from '@aethervtt/shard-assets';
2
+ import { type AssetRef } from '@aethervtt/shard-core';
3
+ export declare const TextureAtlasSchema: import("@aethervtt/shard-core").ComponentDef<{
4
+ readonly texture: import("@aethervtt/shard-core").FieldType<AssetRef<"Texture"> | null, "object">;
5
+ readonly normals: import("@aethervtt/shard-core").FieldType<AssetRef<"Texture"> | null, "object">;
6
+ readonly grid: import("@aethervtt/shard-core").FieldType<import("@aethervtt/shard-core").InferFields<{
7
+ columns: import("@aethervtt/shard-core").FieldType<number, "u32">;
8
+ rows: import("@aethervtt/shard-core").FieldType<number, "u32">;
9
+ cellWidth: import("@aethervtt/shard-core").FieldType<number, "u32">;
10
+ cellHeight: import("@aethervtt/shard-core").FieldType<number, "u32">;
11
+ margin: import("@aethervtt/shard-core").FieldType<number, "u32">;
12
+ spacing: import("@aethervtt/shard-core").FieldType<number, "u32">;
13
+ prefix: import("@aethervtt/shard-core").FieldType<string, "object">;
14
+ }>, "object">;
15
+ readonly regions: import("@aethervtt/shard-core").FieldType<import("@aethervtt/shard-core").InferFields<{
16
+ name: import("@aethervtt/shard-core").FieldType<string, "object">;
17
+ rect: import("@aethervtt/shard-core").FieldType<import("@aethervtt/shard-core").Vec4, "f32">;
18
+ pivot: import("@aethervtt/shard-core").FieldType<import("@aethervtt/shard-core").Vec2, "f32">;
19
+ outline: import("@aethervtt/shard-core").FieldType<import("@aethervtt/shard-core").Vec2[], "object">;
20
+ }>[], "object">;
21
+ }>;
22
+ /**
23
+ * Named pixel rectangles of one texture. Region indices are stable in file order (grid cells
24
+ * first), and tilemaps store them: tile N is region N − 1.
25
+ */
26
+ export declare class TextureAtlas {
27
+ texture: AssetRef<'Texture'> | null;
28
+ /** A normal map with the same layout, or null. */
29
+ normals: AssetRef<'Texture'> | null;
30
+ names: string[];
31
+ /** x, y, width, height per region, in pixels. */
32
+ rects: Float32Array;
33
+ /** Default anchor per region (normalized, y down). */
34
+ pivots: Float32Array;
35
+ /** Alpha outline per region (normalized x, y pairs, y down), where the atlas has one. */
36
+ outlines: (Float32Array | undefined)[];
37
+ private index;
38
+ /** Bumps when the regions change (hot reload), so sprites re-read their rects. */
39
+ version: number;
40
+ constructor(texture: AssetRef<'Texture'> | null, regions: {
41
+ name: string;
42
+ rect: number[];
43
+ pivot?: number[];
44
+ outline?: number[][];
45
+ }[], normals?: AssetRef<'Texture'> | null);
46
+ get count(): number;
47
+ /** A region's index by name, or -1. */
48
+ region(name: string): number;
49
+ copyFrom(other: TextureAtlas): void;
50
+ /** Builds an atlas from its JSON form (`*.atlas.json`), expanding a grid into regions. */
51
+ static fromJson(json: unknown, resolve?: (path: string) => AssetRef | undefined): TextureAtlas;
52
+ }
53
+ export declare class TextureAtlasStore extends AssetStore<TextureAtlas, 'TextureAtlas'> {
54
+ constructor();
55
+ }
56
+ export declare const TextureAtlases: import("@aethervtt/shard-core").ResourceDef<TextureAtlasStore>;
57
+ export declare const TextureAtlasAssetType: import("@aethervtt/shard-assets").AssetTypeDef<TextureAtlas>;
58
+ /** `*.atlas.json`: regions written by hand, or a grid. */
59
+ export declare const TextureAtlasImporter: import("@aethervtt/shard-assets").ImporterDef;
60
+ export declare const AtlasPackSchema: import("@aethervtt/shard-core").ComponentDef<{
61
+ readonly folder: import("@aethervtt/shard-core").FieldType<string, "object">;
62
+ readonly padding: import("@aethervtt/shard-core").FieldType<number, "u32">;
63
+ readonly extrude: import("@aethervtt/shard-core").FieldType<number, "u32">;
64
+ readonly maxSize: import("@aethervtt/shard-core").FieldType<number, "u32">;
65
+ readonly mipmaps: import("@aethervtt/shard-core").FieldType<boolean, "u8">;
66
+ readonly outlines: import("@aethervtt/shard-core").FieldType<boolean, "u8">;
67
+ readonly normalMap: import("@aethervtt/shard-core").FieldType<"directx" | "opengl", "u8">;
68
+ }>;
69
+ /**
70
+ * Packs images into one texture: premultiplied (in linear light, then encoded back to sRGB), with
71
+ * `extrude` edge pixels copied outward. Returns the pixels and each image's rect.
72
+ */
73
+ export declare function packImages(images: {
74
+ name: string;
75
+ width: number;
76
+ height: number;
77
+ data: Uint8Array;
78
+ }[], options: {
79
+ padding: number;
80
+ extrude: number;
81
+ maxSize: number;
82
+ }): {
83
+ width: number;
84
+ height: number;
85
+ data: Uint8Array;
86
+ rects: number[][];
87
+ };
88
+ /**
89
+ * A normal-map page with the same layout as a packed atlas: each companion at its image's rect,
90
+ * edges extruded, and a flat normal everywhere else (regions without a companion light flat).
91
+ */
92
+ export declare function packNormals(width: number, height: number, rects: number[][], normals: ({
93
+ width: number;
94
+ height: number;
95
+ data: Uint8Array;
96
+ } | undefined)[], extrude: number): Uint8Array;
97
+ /** A region's alpha outline, normalized to its size (x, y pairs, y down), as JSON points. */
98
+ export declare function regionOutline(img: {
99
+ width: number;
100
+ height: number;
101
+ data: Uint8Array;
102
+ }): number[][];
103
+ /**
104
+ * `*.atlas-pack.json`: packs a folder of images into an atlas and its texture (`#Texture`), plus a
105
+ * normal-map page (`#Normals`) when images have `name_n.png` companions.
106
+ */
107
+ export declare const AtlasPackImporter: import("@aethervtt/shard-assets").ImporterDef;
108
+ /**
109
+ * The atlas texture with each region outlined and labeled with its index, and its alpha outline
110
+ * (green) where it has one. An atlas with normals shows albedo and normals side by side.
111
+ */
112
+ export declare const textureAtlasPreview: import("@aethervtt/shard-assets").AssetPreview;
package/dist/atlas.js ADDED
@@ -0,0 +1,511 @@
1
+ import {
2
+ AssetStore,
3
+ assetServer,
4
+ defineAssetPreview,
5
+ defineAssetType,
6
+ defineDataAsset,
7
+ defineImporter
8
+ } from "@aethervtt/shard-assets";
9
+ import {
10
+ defineResource,
11
+ defineSchema,
12
+ ShardError,
13
+ t
14
+ } from "@aethervtt/shard-core";
15
+ import {
16
+ buildMips,
17
+ decodeImage,
18
+ flipGreen,
19
+ packRects,
20
+ readKtx2,
21
+ writeKtx2
22
+ } from "@aethervtt/shard-texture";
23
+ import { alphaOutline } from "./outline.js";
24
+ import { drawLabel, drawLine, fitImage, outline } from "./preview.js";
25
+ const TextureAtlasSchema = defineSchema(
26
+ "sprite/TextureAtlas",
27
+ {
28
+ texture: t.handle("Texture", { description: "The image the regions cut from." }),
29
+ normals: t.handle("Texture", {
30
+ description: "A normal map with the same layout as texture (2D lighting): regions sample it at the same rect."
31
+ }),
32
+ grid: t.struct(
33
+ {
34
+ columns: t.u32({ description: "Cells across (0: no grid)." }),
35
+ rows: t.u32({ description: "Cells down." }),
36
+ cellWidth: t.u32({ description: "Cell width in pixels." }),
37
+ cellHeight: t.u32({ description: "Cell height in pixels." }),
38
+ margin: t.u32({ description: "Pixels before the first cell, on every side." }),
39
+ spacing: t.u32({ description: "Pixels between cells." }),
40
+ prefix: t.string({ default: "cell", description: "Region names: prefix0, prefix1, \u2026" })
41
+ },
42
+ {
43
+ description: "A regular sheet: its cells become regions, row by row, before any explicit regions."
44
+ }
45
+ ),
46
+ regions: t.list(
47
+ t.struct({
48
+ name: t.string({ description: 'Unique name, e.g. "hero/idle_0".' }),
49
+ rect: t.vec4({ min: 0, description: "x, y, width, height in pixels (y down)." }),
50
+ pivot: t.vec2({
51
+ default: [0.5, 0.5],
52
+ description: "Default anchor in normalized region space (0, 0 is the top left)."
53
+ }),
54
+ outline: t.list(t.vec2, {
55
+ description: "Alpha outline in normalized region space (0, 0 top left), for LightOccluder2d shape 'sprite'. Packed atlases with outlines: true fill it in."
56
+ })
57
+ }),
58
+ { description: "Named rectangles of the texture." }
59
+ )
60
+ },
61
+ {
62
+ description: "Named regions of one texture, for sprites, animation clips, and tilemaps (tile N is region N \u2212 1)."
63
+ }
64
+ );
65
+ class TextureAtlas {
66
+ texture;
67
+ /** A normal map with the same layout, or null. */
68
+ normals = null;
69
+ names;
70
+ /** x, y, width, height per region, in pixels. */
71
+ rects;
72
+ /** Default anchor per region (normalized, y down). */
73
+ pivots;
74
+ /** Alpha outline per region (normalized x, y pairs, y down), where the atlas has one. */
75
+ outlines;
76
+ index;
77
+ /** Bumps when the regions change (hot reload), so sprites re-read their rects. */
78
+ version = 0;
79
+ constructor(texture, regions, normals = null) {
80
+ this.texture = texture;
81
+ this.normals = normals;
82
+ this.names = regions.map((r) => r.name);
83
+ this.outlines = regions.map(
84
+ (r) => r.outline && r.outline.length >= 3 ? new Float32Array(r.outline.flat()) : void 0
85
+ );
86
+ this.rects = new Float32Array(regions.length * 4);
87
+ this.pivots = new Float32Array(regions.length * 2);
88
+ this.index = /* @__PURE__ */ new Map();
89
+ regions.forEach((r, i) => {
90
+ this.rects.set(r.rect.slice(0, 4), i * 4);
91
+ this.pivots[i * 2] = r.pivot?.[0] ?? 0.5;
92
+ this.pivots[i * 2 + 1] = r.pivot?.[1] ?? 0.5;
93
+ this.index.set(r.name, i);
94
+ });
95
+ }
96
+ get count() {
97
+ return this.names.length;
98
+ }
99
+ /** A region's index by name, or -1. */
100
+ region(name) {
101
+ return this.index.get(name) ?? -1;
102
+ }
103
+ copyFrom(other) {
104
+ this.texture = other.texture;
105
+ this.normals = other.normals;
106
+ this.outlines = other.outlines;
107
+ this.names = other.names;
108
+ this.rects = other.rects;
109
+ this.pivots = other.pivots;
110
+ this.index = other.index;
111
+ this.version++;
112
+ }
113
+ /** Builds an atlas from its JSON form (`*.atlas.json`), expanding a grid into regions. */
114
+ static fromJson(json, resolve) {
115
+ const value = TextureAtlasSchema.deserialize(json);
116
+ const regions = [];
117
+ const g = value.grid;
118
+ if (g.columns > 0 && g.rows > 0) {
119
+ for (let row = 0; row < g.rows; row++) {
120
+ for (let col = 0; col < g.columns; col++) {
121
+ regions.push({
122
+ name: `${g.prefix}${row * g.columns + col}`,
123
+ rect: [
124
+ g.margin + col * (g.cellWidth + g.spacing),
125
+ g.margin + row * (g.cellHeight + g.spacing),
126
+ g.cellWidth,
127
+ g.cellHeight
128
+ ]
129
+ });
130
+ }
131
+ }
132
+ }
133
+ for (const r of value.regions)
134
+ regions.push({ name: r.name, rect: r.rect, pivot: r.pivot, outline: r.outline });
135
+ const names = /* @__PURE__ */ new Set();
136
+ for (const r of regions) {
137
+ if (names.has(r.name)) {
138
+ throw new ShardError("sprite/duplicate-region", `Region "${r.name}" appears twice`, {
139
+ hint: "Region names are unique within an atlas."
140
+ });
141
+ }
142
+ names.add(r.name);
143
+ }
144
+ const ref = value.texture?.path ? resolve?.(value.texture.path) ?? value.texture : value.texture;
145
+ const normals = value.normals?.path ? resolve?.(value.normals.path) ?? value.normals : value.normals?.guid ? value.normals : null;
146
+ return new TextureAtlas(
147
+ ref ?? null,
148
+ regions,
149
+ normals ?? null
150
+ );
151
+ }
152
+ }
153
+ class TextureAtlasStore extends AssetStore {
154
+ constructor() {
155
+ super("TextureAtlas");
156
+ }
157
+ }
158
+ const TextureAtlases = defineResource("sprite/TextureAtlases", {
159
+ description: "Loaded texture atlases by guid.",
160
+ init: () => new TextureAtlasStore()
161
+ });
162
+ const resolver = (ctx) => (path) => ctx.resolve(path);
163
+ const TextureAtlasAssetType = defineAssetType("TextureAtlas", {
164
+ store: TextureAtlases,
165
+ load: (artifact, ctx) => TextureAtlas.fromJson(artifact.json, resolver(ctx)),
166
+ update: (existing, next) => existing.copyFrom(next)
167
+ });
168
+ const TextureAtlasImporter = defineDataAsset("TextureAtlas", TextureAtlasSchema, {
169
+ extension: "atlas"
170
+ });
171
+ const AtlasPackSchema = defineSchema(
172
+ "sprite/AtlasPack",
173
+ {
174
+ folder: t.string({
175
+ description: "Folder of images to pack, relative to this file. Empty: the folder named like this file (hero.atlas-pack.json packs hero/)."
176
+ }),
177
+ padding: t.u32({ default: 2, max: 16, description: "Pixels between regions." }),
178
+ extrude: t.u32({
179
+ default: 1,
180
+ max: 16,
181
+ description: "Edge pixels repeated outward (at most the padding), so filtering doesn't bleed."
182
+ }),
183
+ maxSize: t.u32({ default: 4096, min: 64, max: 16384, description: "Largest texture side." }),
184
+ mipmaps: t.bool({ description: "Generate mips (off: sprites are usually drawn near 1:1)." }),
185
+ outlines: t.bool({
186
+ description: "Trace each region's alpha outline (at most 32 points) for LightOccluder2d shape 'sprite'."
187
+ }),
188
+ normalMap: t.enum(["opengl", "directx"], {
189
+ description: "Convention of the name_n.png normal-map companions: opengl (+Y up) or directx (green flipped on import)."
190
+ })
191
+ },
192
+ {
193
+ description: "Packs a folder of images into one power-of-two atlas texture (premultiplied alpha). Regions are named after the files. name_n.png companions pack into a matching normal-map page (2D lighting)."
194
+ }
195
+ );
196
+ const IMAGE = /\.(png|jpe?g|webp)$/i;
197
+ const srgbToLinear = new Float32Array(256).map((_, i) => {
198
+ const c = i / 255;
199
+ return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
200
+ });
201
+ const linearToSrgb = (v) => {
202
+ const c = v <= 31308e-7 ? v * 12.92 : 1.055 * v ** (1 / 2.4) - 0.055;
203
+ return Math.round(Math.min(1, Math.max(0, c)) * 255);
204
+ };
205
+ function packImages(images, options) {
206
+ const padding = options.padding;
207
+ const extrude = Math.min(options.extrude, padding);
208
+ let packed;
209
+ try {
210
+ packed = packRects(images, options.maxSize, padding);
211
+ } catch (cause) {
212
+ throw new ShardError("sprite/atlas-too-large", cause.message, {
213
+ hint: "Raise maxSize, or split the images into several atlases."
214
+ });
215
+ }
216
+ if (packed.pages.length > 1) {
217
+ throw new ShardError(
218
+ "sprite/atlas-too-large",
219
+ `${images.length} images need ${packed.pages.length} ${options.maxSize}px pages`,
220
+ { hint: "Raise maxSize, or split the images into several atlases." }
221
+ );
222
+ }
223
+ const { width, height } = packed.pages[0] ?? { width: 1, height: 1 };
224
+ const data = new Uint8Array(width * height * 4);
225
+ const rects = [];
226
+ images.forEach((img, i) => {
227
+ const p = packed.placements[i];
228
+ rects.push([p.x, p.y, img.width, img.height]);
229
+ for (let y = -extrude; y < img.height + extrude; y++) {
230
+ const sy = Math.min(img.height - 1, Math.max(0, y));
231
+ for (let x = -extrude; x < img.width + extrude; x++) {
232
+ const sx = Math.min(img.width - 1, Math.max(0, x));
233
+ const s = (sy * img.width + sx) * 4;
234
+ const d = ((p.y + y) * width + p.x + x) * 4;
235
+ const a = img.data[s + 3] / 255;
236
+ data[d] = linearToSrgb(srgbToLinear[img.data[s]] * a);
237
+ data[d + 1] = linearToSrgb(srgbToLinear[img.data[s + 1]] * a);
238
+ data[d + 2] = linearToSrgb(srgbToLinear[img.data[s + 2]] * a);
239
+ data[d + 3] = img.data[s + 3];
240
+ }
241
+ }
242
+ });
243
+ return { width, height, data, rects };
244
+ }
245
+ function packNormals(width, height, rects, normals, extrude) {
246
+ const data = new Uint8Array(width * height * 4);
247
+ for (let p = 0; p < width * height; p++) data.set(FLAT_NORMAL, p * 4);
248
+ normals.forEach((img, i) => {
249
+ if (!img) return;
250
+ const [px, py] = rects[i];
251
+ for (let y = -extrude; y < img.height + extrude; y++) {
252
+ const sy = Math.min(img.height - 1, Math.max(0, y));
253
+ for (let x = -extrude; x < img.width + extrude; x++) {
254
+ const sx = Math.min(img.width - 1, Math.max(0, x));
255
+ const s = (sy * img.width + sx) * 4;
256
+ const d = ((py + y) * width + px + x) * 4;
257
+ data[d] = img.data[s];
258
+ data[d + 1] = img.data[s + 1];
259
+ data[d + 2] = img.data[s + 2];
260
+ data[d + 3] = 255;
261
+ }
262
+ }
263
+ });
264
+ return data;
265
+ }
266
+ const FLAT_NORMAL = [128, 128, 255, 255];
267
+ function regionOutline(img) {
268
+ const flat = alphaOutline(
269
+ img.width,
270
+ img.height,
271
+ (x, y) => img.data[(y * img.width + x) * 4 + 3] / 255
272
+ );
273
+ const out = [];
274
+ for (let k = 0; k < flat.length; k += 2) {
275
+ out.push([
276
+ Math.round(flat[k] / img.width * 1e5) / 1e5,
277
+ Math.round(flat[k + 1] / img.height * 1e5) / 1e5
278
+ ]);
279
+ }
280
+ return out;
281
+ }
282
+ const COMPANION = /_n$/;
283
+ const AtlasPackImporter = defineImporter({
284
+ name: "atlas-pack",
285
+ version: 1,
286
+ extensions: [".atlas-pack.json"],
287
+ settings: defineSchema(
288
+ "sprite/AtlasPackSettings",
289
+ {},
290
+ { description: "None: options live in the file." }
291
+ ),
292
+ async import(source, ctx) {
293
+ let json;
294
+ try {
295
+ json = source.text().trim() ? JSON.parse(source.text()) : {};
296
+ } catch (cause) {
297
+ throw new ShardError("assets/import-failed", `${source.path} isn't valid JSON`, {
298
+ path: source.path,
299
+ cause
300
+ });
301
+ }
302
+ const errors = AtlasPackSchema.validate(json);
303
+ if (errors.length > 0) {
304
+ throw new ShardError("assets/import-failed", `${source.path}: ${errors[0].message}`, {
305
+ path: errors[0].path,
306
+ details: errors
307
+ });
308
+ }
309
+ const options = AtlasPackSchema.deserialize(json);
310
+ const base = source.path.split("/").pop().replace(/\.atlas-pack\.json$/i, "");
311
+ const folder = options.folder || base;
312
+ const files = (await ctx.list(folder)).filter((f) => IMAGE.test(f));
313
+ if (files.length === 0) {
314
+ ctx.warn(`No images in ${ctx.resolve(folder)}; the atlas is empty.`);
315
+ }
316
+ const images = [];
317
+ const companions = /* @__PURE__ */ new Map();
318
+ for (const file of files) {
319
+ const image = await decodeImage(await ctx.read(file));
320
+ if (image.kind !== "u8") {
321
+ throw new ShardError("sprite/unsupported-image", `${file}: HDR images can't be packed`, {
322
+ path: file
323
+ });
324
+ }
325
+ const name = file.split("/").pop().replace(IMAGE, "");
326
+ const entry = {
327
+ name,
328
+ width: image.width,
329
+ height: image.height,
330
+ data: image.data
331
+ };
332
+ if (COMPANION.test(name)) {
333
+ const img = options.normalMap === "directx" ? flipGreen(image) : image;
334
+ companions.set(name.replace(COMPANION, ""), {
335
+ width: img.width,
336
+ height: img.height,
337
+ data: img.data,
338
+ file
339
+ });
340
+ } else images.push(entry);
341
+ }
342
+ images.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
343
+ const normals = images.map((img) => {
344
+ const n = companions.get(img.name);
345
+ if (n && (n.width !== img.width || n.height !== img.height)) {
346
+ throw new ShardError(
347
+ "texture/normal-map-mismatch",
348
+ `${n.file} is ${n.width}\xD7${n.height}, but ${img.name} is ${img.width}\xD7${img.height}`,
349
+ { path: n.file, hint: "A normal-map companion must match its image pixel for pixel." }
350
+ );
351
+ }
352
+ return n;
353
+ });
354
+ for (const name of companions.keys()) {
355
+ if (!images.some((img) => img.name === name)) {
356
+ ctx.warn(`${name}_n has no ${name} image to pair with; it isn't packed.`);
357
+ }
358
+ }
359
+ const packed = packImages(images, options);
360
+ const chain = buildMips(
361
+ { width: packed.width, height: packed.height, kind: "u8", data: packed.data },
362
+ {
363
+ usage: "color",
364
+ mipmaps: options.mipmaps,
365
+ maxSize: 16384,
366
+ flipY: false,
367
+ premultiplyAlpha: false
368
+ }
369
+ );
370
+ const regions = images.map((img, i) => ({
371
+ name: img.name,
372
+ rect: packed.rects[i],
373
+ pivot: [0.5, 0.5],
374
+ ...options.outlines ? { outline: regionOutline(img) } : {}
375
+ }));
376
+ const hasNormals = normals.some((n) => n !== void 0);
377
+ const extra = [];
378
+ if (hasNormals) {
379
+ const page = packNormals(
380
+ packed.width,
381
+ packed.height,
382
+ packed.rects,
383
+ normals,
384
+ Math.min(options.extrude, options.padding)
385
+ );
386
+ const normalChain = buildMips(
387
+ { width: packed.width, height: packed.height, kind: "u8", data: page },
388
+ {
389
+ usage: "normal",
390
+ mipmaps: options.mipmaps,
391
+ maxSize: 16384,
392
+ flipY: false,
393
+ premultiplyAlpha: false
394
+ }
395
+ );
396
+ extra.push({
397
+ label: "Normals",
398
+ type: "Texture",
399
+ bytes: writeKtx2(normalChain, "normal", 1, false),
400
+ info: {
401
+ width: packed.width,
402
+ height: packed.height,
403
+ usage: "normal",
404
+ companions: normals.filter(Boolean).length
405
+ }
406
+ });
407
+ }
408
+ return {
409
+ assets: [
410
+ {
411
+ label: "",
412
+ type: "TextureAtlas",
413
+ json: {
414
+ texture: { path: "#Texture" },
415
+ ...hasNormals ? { normals: { path: "#Normals" } } : {},
416
+ regions
417
+ },
418
+ dependencies: hasNormals ? ["#Texture", "#Normals"] : ["#Texture"],
419
+ info: {
420
+ regions: images.length,
421
+ size: `${packed.width}x${packed.height}`,
422
+ folder: ctx.resolve(folder),
423
+ normals: hasNormals,
424
+ outlines: options.outlines
425
+ }
426
+ },
427
+ {
428
+ label: "Texture",
429
+ type: "Texture",
430
+ bytes: writeKtx2(chain, "color", 1, true),
431
+ info: {
432
+ width: packed.width,
433
+ height: packed.height,
434
+ usage: "color",
435
+ premultiplied: true,
436
+ mips: chain.levels.length
437
+ }
438
+ },
439
+ ...extra
440
+ ]
441
+ };
442
+ }
443
+ });
444
+ const textureAtlasPreview = defineAssetPreview(
445
+ "TextureAtlas",
446
+ async (world, path, width, height) => {
447
+ const server = assetServer(world);
448
+ const artifact = await server.artifact(path);
449
+ const atlas = TextureAtlas.fromJson(artifact.json);
450
+ const pathOf = (ref) => ref?.path?.startsWith("#") ? `${path.split("#")[0]}${ref.path}` : ref?.path;
451
+ const texturePath = pathOf(atlas.texture);
452
+ if (!texturePath) {
453
+ throw new ShardError("sprite/no-texture", `${path} has no texture`, { path: "/texture" });
454
+ }
455
+ const ktx = readKtx2((await server.artifact(texturePath)).bytes);
456
+ const normalsPath = pathOf(atlas.normals);
457
+ const nktx = normalsPath ? readKtx2((await server.artifact(normalsPath)).bytes) : void 0;
458
+ const sw = nktx ? ktx.width * 2 + 4 : ktx.width;
459
+ const src = new Uint8Array(sw * ktx.height * 4);
460
+ for (let y = 0; y < ktx.height; y++) {
461
+ src.set(ktx.levels[0].subarray(y * ktx.width * 4, (y + 1) * ktx.width * 4), y * sw * 4);
462
+ if (nktx && nktx.width === ktx.width && nktx.height === ktx.height) {
463
+ src.set(
464
+ nktx.levels[0].subarray(y * ktx.width * 4, (y + 1) * ktx.width * 4),
465
+ (y * sw + ktx.width + 4) * 4
466
+ );
467
+ }
468
+ }
469
+ const image = fitImage(src, sw, ktx.height, width, height);
470
+ const scale = image.width / sw;
471
+ const panels = nktx ? [0, ktx.width + 4] : [0];
472
+ for (const ox of panels) {
473
+ for (let i = 0; i < atlas.count; i++) {
474
+ const [x, y, w, h] = atlas.rects.subarray(i * 4, i * 4 + 4);
475
+ outline(image, (x + ox) * scale, y * scale, w * scale, h * scale, [255, 0, 180, 255]);
476
+ const o = atlas.outlines[i];
477
+ if (o) {
478
+ const n = o.length / 2;
479
+ for (let k = 0; k < n; k++) {
480
+ const j = (k + 1) % n;
481
+ drawLine(
482
+ image,
483
+ (x + ox + o[k * 2] * w) * scale,
484
+ (y + o[k * 2 + 1] * h) * scale,
485
+ (x + ox + o[j * 2] * w) * scale,
486
+ (y + o[j * 2 + 1] * h) * scale,
487
+ [40, 255, 90, 255]
488
+ );
489
+ }
490
+ }
491
+ if (ox === 0)
492
+ drawLabel(image, String(i), Math.round(x * scale) + 2, Math.round(y * scale) + 2);
493
+ }
494
+ }
495
+ return image;
496
+ }
497
+ );
498
+ export {
499
+ AtlasPackImporter,
500
+ AtlasPackSchema,
501
+ TextureAtlas,
502
+ TextureAtlasAssetType,
503
+ TextureAtlasImporter,
504
+ TextureAtlasSchema,
505
+ TextureAtlasStore,
506
+ TextureAtlases,
507
+ packImages,
508
+ packNormals,
509
+ regionOutline,
510
+ textureAtlasPreview
511
+ };