@forgeax/engine-image 0.1.19 → 0.1.21
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/README.md +61 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/__tests__/pixel-surface.unit.test.d.ts +2 -0
- package/dist/__tests__/pixel-surface.unit.test.d.ts.map +1 -0
- package/dist/__tests__/texture-source-descriptor.integration.test.d.ts +2 -0
- package/dist/__tests__/texture-source-descriptor.integration.test.d.ts.map +1 -0
- package/dist/decode-image-from-file.mjs +2 -1
- package/dist/decode-image-from-file.mjs.map +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/hdr-decoder.mjs +2 -1
- package/dist/hdr-decoder.mjs.map +1 -1
- package/dist/image-importer.d.ts.map +1 -1
- package/dist/image-importer.mjs +222 -16
- package/dist/image-importer.mjs.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +341 -43
- package/dist/index.mjs.map +1 -1
- package/dist/parse-image.mjs +2 -1
- package/dist/parse-image.mjs.map +1 -1
- package/dist/pixel-surface.d.ts +53 -0
- package/dist/pixel-surface.d.ts.map +1 -0
- package/dist/runtime/asset-decoders.d.ts.map +1 -1
- package/dist/texture/importer.d.ts +42 -0
- package/dist/texture/importer.d.ts.map +1 -0
- package/dist/texture/source-descriptor.d.ts +24 -0
- package/dist/texture/source-descriptor.d.ts.map +1 -0
- package/package.json +6 -6
- package/src/__tests__/errors.test-d.ts +11 -3
- package/src/__tests__/image-importer-conversion-failure.unit.test.ts +31 -2
- package/src/__tests__/image-importer-topology.unit.test.ts +2 -2
- package/src/__tests__/image.unit.test.ts +2 -4
- package/src/__tests__/ktx2-basis-importer.unit.test.ts +2 -3
- package/src/__tests__/pixel-surface.unit.test.ts +94 -0
- package/src/__tests__/texture-source-descriptor.integration.test.ts +117 -0
- package/src/errors.ts +2 -0
- package/src/image-importer.ts +24 -14
- package/src/index.ts +8 -0
- package/src/pixel-surface.ts +428 -0
- package/src/runtime/asset-decoders.ts +40 -11
- package/src/texture/importer.ts +198 -0
- package/src/texture/source-descriptor.ts +123 -0
|
@@ -9,20 +9,21 @@ import type {
|
|
|
9
9
|
} from '@forgeax/engine-types';
|
|
10
10
|
import { describe, expectTypeOf, it } from 'vitest';
|
|
11
11
|
|
|
12
|
-
// Type-only tests. Verify the
|
|
12
|
+
// Type-only tests. Verify the 9-member ImageErrorCode closed union compiles
|
|
13
13
|
// an exhaustive switch without a default arm (charter P4 explicit failure).
|
|
14
14
|
// Verifies the discriminated ImageErrorDetail narrowing per .code (charter
|
|
15
15
|
// P3 machine-readable union > prose; AGENTS.md Error model row pattern).
|
|
16
16
|
//
|
|
17
17
|
// feat-20260521-sprite-atlas-animation M1 T-03 update: union grew from 5 to
|
|
18
18
|
// 8 with the atlas-* triplet (atlas-empty-input / atlas-size-exceeded /
|
|
19
|
-
// atlas-region-mismatch)
|
|
19
|
+
// atlas-region-mismatch); the 9th arm is the image-owned PixelSurface
|
|
20
|
+
// authoring error. The exact-member assertion + exhaustive switch
|
|
20
21
|
// arms below mirror the SSOT in @forgeax/engine-types ImageErrorCode +
|
|
21
22
|
// ImageErrorDetail so a future minor add still fails this file at compile
|
|
22
23
|
// time until the new arm + .expected + .hint rows are supplied.
|
|
23
24
|
|
|
24
25
|
describe('ImageErrorCode + ImageErrorDetail closed union compile contract', () => {
|
|
25
|
-
it('ImageErrorCode is the exact
|
|
26
|
+
it('ImageErrorCode is the exact 9-member union literal set', () => {
|
|
26
27
|
expectTypeOf<ImageErrorCode>().toEqualTypeOf<
|
|
27
28
|
| 'image-decode-failed'
|
|
28
29
|
| 'image-format-unsupported'
|
|
@@ -32,6 +33,7 @@ describe('ImageErrorCode + ImageErrorDetail closed union compile contract', () =
|
|
|
32
33
|
| 'atlas-empty-input'
|
|
33
34
|
| 'atlas-size-exceeded'
|
|
34
35
|
| 'atlas-region-mismatch'
|
|
36
|
+
| 'image-surface-invalid'
|
|
35
37
|
>();
|
|
36
38
|
});
|
|
37
39
|
|
|
@@ -54,6 +56,8 @@ describe('ImageErrorCode + ImageErrorDetail closed union compile contract', () =
|
|
|
54
56
|
return 'downscale source or split atlas under maxAtlasSize cap';
|
|
55
57
|
case 'atlas-region-mismatch':
|
|
56
58
|
return 'shelfPack regions exceed atlas footprint -- packer safety net';
|
|
59
|
+
case 'image-surface-invalid':
|
|
60
|
+
return 'repair PixelSurface authoring input';
|
|
57
61
|
}
|
|
58
62
|
}
|
|
59
63
|
expectTypeOf(recover).toBeFunction();
|
|
@@ -78,6 +82,8 @@ describe('ImageErrorCode + ImageErrorDetail closed union compile contract', () =
|
|
|
78
82
|
return `atlas-size:${detail.name}:${detail.width}x${detail.height}>${detail.maxAtlasSize}`;
|
|
79
83
|
case 'atlas-region-mismatch':
|
|
80
84
|
return `atlas-region:${detail.name}:${detail.regionsTotalPixels}>${detail.atlasPixels}`;
|
|
85
|
+
case 'image-surface-invalid':
|
|
86
|
+
return `surface:${detail.operation}:${detail.field}`;
|
|
81
87
|
}
|
|
82
88
|
}
|
|
83
89
|
expectTypeOf(describe_).toBeFunction();
|
|
@@ -102,6 +108,8 @@ describe('ImageErrorCode + ImageErrorDetail closed union compile contract', () =
|
|
|
102
108
|
return `${error.detail.name}:${error.detail.maxAtlasSize}`;
|
|
103
109
|
case 'atlas-region-mismatch':
|
|
104
110
|
return `${error.detail.name}:${error.detail.atlasPixels}`;
|
|
111
|
+
case 'image-surface-invalid':
|
|
112
|
+
return `${error.detail.operation}:${error.detail.field}`;
|
|
105
113
|
}
|
|
106
114
|
}
|
|
107
115
|
|
|
@@ -61,6 +61,8 @@ function expectConversionFailure(
|
|
|
61
61
|
code: diagnosticCode,
|
|
62
62
|
rule: expect.stringMatching(/^image-conversion-/),
|
|
63
63
|
severity: 'error',
|
|
64
|
+
expected: expect.any(String),
|
|
65
|
+
hint: expect.any(String),
|
|
64
66
|
}),
|
|
65
67
|
],
|
|
66
68
|
});
|
|
@@ -206,6 +208,25 @@ describe('image importer conversion failure through the public runner', () => {
|
|
|
206
208
|
expect(state.reads).toBe(6);
|
|
207
209
|
});
|
|
208
210
|
|
|
211
|
+
it.each([
|
|
212
|
+
{ colorSpace: 'srgb' as const, format: 'rgba8unorm-srgb' },
|
|
213
|
+
{ colorSpace: 'linear' as const, format: 'rgba8unorm' },
|
|
214
|
+
])('publishes a format matching the authored $colorSpace color space', async (input) => {
|
|
215
|
+
const result = await runImport(
|
|
216
|
+
meta('valid.png', { colorSpace: input.colorSpace, mipmap: 'none' }),
|
|
217
|
+
registry(),
|
|
218
|
+
{ readSource: source({ bytes: makePng(1, 1, [1, 2, 3, 255]), reads: 0 }) },
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
expect(result.ok).toBe(true);
|
|
222
|
+
if (!result.ok || 'skipped' in result.value) return;
|
|
223
|
+
expect(result.value.pack.assets[0]?.payload).toMatchObject({
|
|
224
|
+
colorSpace: input.colorSpace,
|
|
225
|
+
format: input.format,
|
|
226
|
+
mips: { kind: 'none' },
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
209
230
|
it.skipIf(!pkgBuilt)('repairs raw Basis color-space settings in the same registry', async () => {
|
|
210
231
|
const state = { bytes: await makeRawBasis(), reads: 0 };
|
|
211
232
|
const importerRegistry = registry();
|
|
@@ -226,7 +247,11 @@ describe('image importer conversion failure through the public runner', () => {
|
|
|
226
247
|
if (!repaired.ok || 'skipped' in repaired.value) return;
|
|
227
248
|
expect(repaired.value.pack.assets[0]).toMatchObject({
|
|
228
249
|
guid: GUID,
|
|
229
|
-
payload: {
|
|
250
|
+
payload: {
|
|
251
|
+
colorSpace: 'linear',
|
|
252
|
+
shape: { viewDimension: '2d', extent: { width: 4, height: 4 } },
|
|
253
|
+
mips: { kind: 'none' },
|
|
254
|
+
},
|
|
230
255
|
artifacts: {
|
|
231
256
|
body: {
|
|
232
257
|
mediaType: 'application/x-forgeax-basis',
|
|
@@ -270,7 +295,11 @@ describe('image importer conversion failure through the public runner', () => {
|
|
|
270
295
|
if (!repaired.ok || 'skipped' in repaired.value) return;
|
|
271
296
|
expect(repaired.value.pack.assets[0]).toMatchObject({
|
|
272
297
|
guid: GUID,
|
|
273
|
-
payload: {
|
|
298
|
+
payload: {
|
|
299
|
+
colorSpace: 'linear',
|
|
300
|
+
shape: { viewDimension: '2d', extent: { width: 4, height: 4 } },
|
|
301
|
+
mips: { kind: 'none' },
|
|
302
|
+
},
|
|
274
303
|
artifacts: {
|
|
275
304
|
body: {
|
|
276
305
|
mediaType: 'image/ktx2',
|
|
@@ -172,10 +172,10 @@ describe('image importer required output topology', () => {
|
|
|
172
172
|
sourceIndex: 0,
|
|
173
173
|
payload: {
|
|
174
174
|
kind: 'texture',
|
|
175
|
-
width: 1,
|
|
176
|
-
height: 1,
|
|
175
|
+
shape: { viewDimension: '2d', extent: { width: 1, height: 1 } },
|
|
177
176
|
format: 'rgba8unorm-srgb',
|
|
178
177
|
colorSpace: 'srgb',
|
|
178
|
+
mips: { kind: 'none' },
|
|
179
179
|
},
|
|
180
180
|
artifacts: {
|
|
181
181
|
body: {
|
|
@@ -601,8 +601,7 @@ import { makeCorruptPng, makeJpg, makePng } from './make-fixture.js';
|
|
|
601
601
|
|
|
602
602
|
const payload = asset?.payload as unknown as Record<string, unknown>;
|
|
603
603
|
expect(payload.kind).toBe('texture');
|
|
604
|
-
expect(payload.
|
|
605
|
-
expect(payload.height).toBe(4);
|
|
604
|
+
expect(payload.shape).toEqual({ viewDimension: '2d', extent: { width: 8, height: 4 } });
|
|
606
605
|
expect(payload.format).toBe('rgba8unorm-srgb');
|
|
607
606
|
expect(payload.colorSpace).toBe('srgb');
|
|
608
607
|
expect(payload.data).toBeInstanceOf(Uint8Array);
|
|
@@ -859,8 +858,7 @@ import { makeCorruptPng, makeJpg, makePng } from './make-fixture.js';
|
|
|
859
858
|
|
|
860
859
|
const payload = asset?.payload as TextureAsset;
|
|
861
860
|
expect(payload.kind).toBe('texture');
|
|
862
|
-
expect(payload.
|
|
863
|
-
expect(payload.height).toBe(4);
|
|
861
|
+
expect(payload.shape).toEqual({ viewDimension: '2d', extent: { width: 8, height: 4 } });
|
|
864
862
|
expect(payload.format).toBe('rgba8unorm-srgb');
|
|
865
863
|
expect(payload.colorSpace).toBe('srgb');
|
|
866
864
|
expect(payload.data.length).toBe(8 * 4 * 4);
|
|
@@ -97,11 +97,10 @@ describe.skipIf(!pkgBuilt)('KTX2/Basis source importer', () => {
|
|
|
97
97
|
if (!result.ok) return;
|
|
98
98
|
expect(result.value.assets[0]?.payload).toMatchObject({
|
|
99
99
|
kind: 'texture',
|
|
100
|
-
width: 4,
|
|
101
|
-
height: 4,
|
|
100
|
+
shape: { viewDimension: '2d', extent: { width: 4, height: 4 } },
|
|
102
101
|
format: 'rgba8unorm',
|
|
103
102
|
colorSpace: 'linear',
|
|
104
|
-
|
|
103
|
+
mips: { kind: 'none' },
|
|
105
104
|
});
|
|
106
105
|
expect(result.value.assets[0]?.artifacts.body?.assetCodec).toMatchObject({
|
|
107
106
|
name: 'basis',
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { createPixelSurface, toAssetPack } from '../index.js';
|
|
3
|
+
|
|
4
|
+
const META = {
|
|
5
|
+
guid: '01928000-7c00-7000-8000-000000000099',
|
|
6
|
+
colorSpace: 'srgb' as const,
|
|
7
|
+
mipmap: 'none' as const,
|
|
8
|
+
addressMode: 'repeat' as const,
|
|
9
|
+
filterMode: 'nearest' as const,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function surface(width = 4, height = 3) {
|
|
13
|
+
const result = createPixelSurface({ width, height, colorSpace: 'srgb', mipmap: false });
|
|
14
|
+
expect(result.ok).toBe(true);
|
|
15
|
+
if (!result.ok) throw result.error;
|
|
16
|
+
return result.value;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('PixelSurface', () => {
|
|
20
|
+
it('rejects malformed dimensions and channel/seed values through ImageError', () => {
|
|
21
|
+
const badDimension = createPixelSurface({ width: 0, height: 2 });
|
|
22
|
+
expect(badDimension.ok).toBe(false);
|
|
23
|
+
if (!badDimension.ok) {
|
|
24
|
+
expect(badDimension.error.code).toBe('image-surface-invalid');
|
|
25
|
+
if (badDimension.error.code === 'image-surface-invalid') {
|
|
26
|
+
expect(badDimension.error.detail.expected).toContain('positive integer');
|
|
27
|
+
expect(badDimension.error.detail.code).toBe('image-surface-invalid');
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const candidate = surface();
|
|
32
|
+
const badColor = candidate.setPixel(0, 0, [0, 0, 256, 255]);
|
|
33
|
+
expect(badColor.ok).toBe(false);
|
|
34
|
+
if (!badColor.ok && badColor.error.code === 'image-surface-invalid') {
|
|
35
|
+
expect(badColor.error.detail.operation).toBe('set-pixel');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const badSeed = candidate.fillNoise(Number.NaN);
|
|
39
|
+
expect(badSeed.ok).toBe(false);
|
|
40
|
+
if (!badSeed.ok && badSeed.error.code === 'image-surface-invalid') {
|
|
41
|
+
expect(badSeed.error.detail.operation).toBe('noise');
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('rounds coordinates, clips writes, and keeps exact RGBA8 channel order', () => {
|
|
46
|
+
const candidate = surface();
|
|
47
|
+
expect(candidate.setPixel(1.4, 1.6, [10, 20, 30, 40]).ok).toBe(true);
|
|
48
|
+
expect(candidate.data.slice((2 * 4 + 1) * 4, (2 * 4 + 2) * 4)).toEqual(
|
|
49
|
+
Uint8Array.of(10, 20, 30, 40),
|
|
50
|
+
);
|
|
51
|
+
expect(candidate.fillRect(-1.2, -1.2, 3.1, 3.1, [1, 2, 3, 4]).ok).toBe(true);
|
|
52
|
+
expect(candidate.data.slice(0, 4)).toEqual(Uint8Array.of(1, 2, 3, 4));
|
|
53
|
+
expect(candidate.fillCircle(3, 2, 1, { r: 9, g: 8, b: 7, a: 6 }).ok).toBe(true);
|
|
54
|
+
expect(candidate.data.slice((2 * 4 + 3) * 4, (2 * 4 + 4) * 4)).toEqual(
|
|
55
|
+
Uint8Array.of(9, 8, 7, 6),
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('snapshots overlapping blits before writing', () => {
|
|
60
|
+
const candidate = surface(4, 1);
|
|
61
|
+
for (let x = 0; x < 4; x += 1) {
|
|
62
|
+
expect(candidate.setPixel(x, 0, [x + 1, 0, 0, 255]).ok).toBe(true);
|
|
63
|
+
}
|
|
64
|
+
expect(candidate.blit(candidate, 1, 0, { x: 0, y: 0, width: 3, height: 1 }).ok).toBe(true);
|
|
65
|
+
expect(Array.from(candidate.data.filter((_value, index) => index % 4 === 0))).toEqual([
|
|
66
|
+
1, 1, 2, 3,
|
|
67
|
+
]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('produces byte-identical seeded noise and a regular TextureAsset/Pack carrier', () => {
|
|
71
|
+
const first = surface();
|
|
72
|
+
const second = surface();
|
|
73
|
+
expect(first.fillNoise(42, { min: 10, max: 200, alpha: 123 }).ok).toBe(true);
|
|
74
|
+
expect(second.noise(42, { min: 10, max: 200, alpha: 123 }).ok).toBe(true);
|
|
75
|
+
expect(first.data).toEqual(second.data);
|
|
76
|
+
expect(first.toTextureAsset()).toMatchObject({
|
|
77
|
+
kind: 'texture',
|
|
78
|
+
shape: {
|
|
79
|
+
viewDimension: '2d',
|
|
80
|
+
extent: { width: 4, height: 3 },
|
|
81
|
+
},
|
|
82
|
+
format: 'rgba8unorm-srgb',
|
|
83
|
+
colorSpace: 'srgb',
|
|
84
|
+
mips: { kind: 'none' },
|
|
85
|
+
});
|
|
86
|
+
const decoded = first.toDecodedImage();
|
|
87
|
+
const pack = toAssetPack(decoded, META);
|
|
88
|
+
expect(pack.subAssets).toEqual([{ guid: META.guid, sourceIndex: 0, kind: 'texture' }]);
|
|
89
|
+
expect(pack.importSettings.mipmap).toBe('none');
|
|
90
|
+
const changed = decoded.bytes.slice();
|
|
91
|
+
changed[0] = (changed[0] ?? 0) ^ 1;
|
|
92
|
+
expect(changed).not.toEqual(decoded.bytes);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { produceTextureSource } from '../texture/importer.js';
|
|
3
|
+
import type { TextureSourceDescriptor } from '../texture/source-descriptor.js';
|
|
4
|
+
import { parseTextureSourceDescriptor } from '../texture/source-descriptor.js';
|
|
5
|
+
|
|
6
|
+
const GUID = '019ffa97-3000-7000-8000-000000000101';
|
|
7
|
+
const SOURCE_KEY = 'density/volume';
|
|
8
|
+
const BODY_BYTES = 64 * 64 * 64;
|
|
9
|
+
|
|
10
|
+
function densityDescriptor(): TextureSourceDescriptor {
|
|
11
|
+
return {
|
|
12
|
+
schemaVersion: '1',
|
|
13
|
+
shape: {
|
|
14
|
+
viewDimension: '3d',
|
|
15
|
+
extent: { width: 64, height: 64, depth: 64 },
|
|
16
|
+
},
|
|
17
|
+
format: 'r8unorm',
|
|
18
|
+
colorSpace: 'linear',
|
|
19
|
+
mips: { kind: 'none' },
|
|
20
|
+
rawSibling: 'density.raw',
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function arrayDescriptor(): TextureSourceDescriptor {
|
|
25
|
+
return {
|
|
26
|
+
schemaVersion: '1',
|
|
27
|
+
shape: {
|
|
28
|
+
viewDimension: '2d-array',
|
|
29
|
+
extent: { width: 8, height: 4, layers: 3 },
|
|
30
|
+
},
|
|
31
|
+
format: 'r8unorm',
|
|
32
|
+
colorSpace: 'linear',
|
|
33
|
+
mips: { kind: 'none' },
|
|
34
|
+
rawSibling: 'array.raw',
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('texture source descriptor producer contract', () => {
|
|
39
|
+
it('produces one 64^3 density asset from its raw sibling', async () => {
|
|
40
|
+
const descriptor = densityDescriptor();
|
|
41
|
+
const raw = new Uint8Array(BODY_BYTES);
|
|
42
|
+
raw[0] = 17;
|
|
43
|
+
raw[raw.length - 1] = 231;
|
|
44
|
+
const readSibling = async (uri: string) => {
|
|
45
|
+
expect(uri).toBe(descriptor.rawSibling);
|
|
46
|
+
return { ok: true as const, value: raw };
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const result = await produceTextureSource({
|
|
50
|
+
descriptor,
|
|
51
|
+
guid: GUID,
|
|
52
|
+
sourceKey: SOURCE_KEY,
|
|
53
|
+
readSibling,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(result.ok).toBe(true);
|
|
57
|
+
if (!result.ok) return;
|
|
58
|
+
expect(result.value.guid).toBe(GUID);
|
|
59
|
+
expect(result.value.kind).toBe('texture');
|
|
60
|
+
expect(result.value.payload).toEqual({
|
|
61
|
+
kind: 'texture',
|
|
62
|
+
shape: descriptor.shape,
|
|
63
|
+
format: descriptor.format,
|
|
64
|
+
colorSpace: 'linear',
|
|
65
|
+
mips: { kind: 'none' },
|
|
66
|
+
data: raw,
|
|
67
|
+
});
|
|
68
|
+
expect(result.value.artifacts.body?.bytes).toEqual(raw);
|
|
69
|
+
expect(result.value.artifacts.body?.mediaType).toBe('application/x-forgeax-r8');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('keeps array and 3d payloads equivalent without minting slice identities', async () => {
|
|
73
|
+
const descriptors = [arrayDescriptor(), densityDescriptor()];
|
|
74
|
+
for (const descriptor of descriptors) {
|
|
75
|
+
const layoutBytes = descriptor.shape.viewDimension === '3d' ? BODY_BYTES : 8 * 4 * 3;
|
|
76
|
+
const raw = new Uint8Array(layoutBytes);
|
|
77
|
+
const result = await produceTextureSource({
|
|
78
|
+
descriptor,
|
|
79
|
+
guid: GUID,
|
|
80
|
+
sourceKey: SOURCE_KEY,
|
|
81
|
+
readSibling: async () => ({ ok: true as const, value: raw }),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
expect(result.ok).toBe(true);
|
|
85
|
+
if (!result.ok) continue;
|
|
86
|
+
expect(result.value.guid).toBe(GUID);
|
|
87
|
+
expect(result.value.payload.shape).toEqual(descriptor.shape);
|
|
88
|
+
expect(result.value.artifacts.body?.bytes).toEqual(raw);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('rejects missing sibling and invalid descriptor facts structurally', async () => {
|
|
93
|
+
const missing = await produceTextureSource({
|
|
94
|
+
descriptor: densityDescriptor(),
|
|
95
|
+
guid: GUID,
|
|
96
|
+
sourceKey: SOURCE_KEY,
|
|
97
|
+
readSibling: async () => ({ ok: false as const, error: new Error('missing raw sibling') }),
|
|
98
|
+
});
|
|
99
|
+
expect(missing.ok).toBe(false);
|
|
100
|
+
if (!missing.ok) {
|
|
101
|
+
expect(missing.error.code).toBe('source-read-failed');
|
|
102
|
+
expect(missing.error.detail).toMatchObject({ sourceKey: SOURCE_KEY, sibling: 'density.raw' });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const invalid = parseTextureSourceDescriptor({
|
|
106
|
+
...densityDescriptor(),
|
|
107
|
+
shape: { viewDimension: '3d', extent: { width: 64, height: 64, depth: 64 } },
|
|
108
|
+
format: 'bc7-rgba-unorm',
|
|
109
|
+
mips: { kind: 'none' },
|
|
110
|
+
});
|
|
111
|
+
expect(invalid.ok).toBe(false);
|
|
112
|
+
if (!invalid.ok) {
|
|
113
|
+
expect(invalid.error.code).toBe('texture-format-dimension-unsupported');
|
|
114
|
+
expect(invalid.error.detail).toMatchObject({ format: 'bc7-rgba-unorm', viewDimension: '3d' });
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
});
|
package/src/errors.ts
CHANGED
|
@@ -25,6 +25,8 @@ const IMAGE_ERROR_EXPECTED: Readonly<Record<ImageErrorCode, string>> = {
|
|
|
25
25
|
'atlas-size-exceeded':
|
|
26
26
|
'image width x height <= maxAtlasSize^2 and each image fits in the atlas footprint',
|
|
27
27
|
'atlas-region-mismatch': 'sum(regions[i].w x regions[i].h) <= atlasWidth x atlasHeight',
|
|
28
|
+
'image-surface-invalid':
|
|
29
|
+
'PixelSurface dimensions and authoring inputs satisfy the RGBA8 contract',
|
|
28
30
|
};
|
|
29
31
|
|
|
30
32
|
/** Runtime implementation of the correlated `ImageErrorFor<C>` envelope. */
|
package/src/image-importer.ts
CHANGED
|
@@ -46,6 +46,7 @@ import { IMPORT_ERROR_HINTS, ImportError } from '@forgeax/engine-types';
|
|
|
46
46
|
import type { CompressionMode } from './ktx2-encode.js';
|
|
47
47
|
import { encodeTextureToKtx2, resolveEncodeMode } from './ktx2-encode.js';
|
|
48
48
|
import { parseImage } from './parse-image.js';
|
|
49
|
+
import { importTextureSource } from './texture/importer.js';
|
|
49
50
|
|
|
50
51
|
/** Map a source path / mime hint to the parseImage mime literal. */
|
|
51
52
|
function mimeFromSource(source: string): 'image/png' | 'image/jpeg' | undefined {
|
|
@@ -319,13 +320,17 @@ async function importKtx2Source(ctx: ImportContext, bytes: Uint8Array): Promise<
|
|
|
319
320
|
kind: 'texture',
|
|
320
321
|
payload: {
|
|
321
322
|
kind: 'texture',
|
|
322
|
-
|
|
323
|
-
|
|
323
|
+
shape: {
|
|
324
|
+
viewDimension: '2d',
|
|
325
|
+
extent: { width: inspection.width, height: inspection.height },
|
|
326
|
+
},
|
|
324
327
|
format: colorSpaceToFormat(inspection.colorSpace),
|
|
325
328
|
data: bytes,
|
|
326
329
|
colorSpace: inspection.colorSpace,
|
|
327
|
-
|
|
328
|
-
|
|
330
|
+
mips:
|
|
331
|
+
inspection.levelCount > 1
|
|
332
|
+
? { kind: 'packed', levelCount: inspection.levelCount }
|
|
333
|
+
: { kind: 'none' },
|
|
329
334
|
},
|
|
330
335
|
refs: [],
|
|
331
336
|
artifacts: {
|
|
@@ -481,13 +486,17 @@ async function importBasisSource(ctx: ImportContext, bytes: Uint8Array): Promise
|
|
|
481
486
|
kind: 'texture',
|
|
482
487
|
payload: {
|
|
483
488
|
kind: 'texture',
|
|
484
|
-
|
|
485
|
-
|
|
489
|
+
shape: {
|
|
490
|
+
viewDimension: '2d',
|
|
491
|
+
extent: { width: inspection.width, height: inspection.height },
|
|
492
|
+
},
|
|
486
493
|
format: colorSpaceToFormat(colorSpace),
|
|
487
494
|
data: bytes,
|
|
488
495
|
colorSpace,
|
|
489
|
-
|
|
490
|
-
|
|
496
|
+
mips:
|
|
497
|
+
inspection.levelCount > 1
|
|
498
|
+
? { kind: 'packed', levelCount: inspection.levelCount }
|
|
499
|
+
: { kind: 'none' },
|
|
491
500
|
},
|
|
492
501
|
refs: [],
|
|
493
502
|
artifacts: {
|
|
@@ -574,6 +583,9 @@ async function maybeEncodeTextureBytes(
|
|
|
574
583
|
}
|
|
575
584
|
|
|
576
585
|
async function importImage(ctx: ImportContext): Promise<ImportResult> {
|
|
586
|
+
if (ctx.source.toLowerCase().endsWith('.texture.json')) {
|
|
587
|
+
return importTextureSource(ctx);
|
|
588
|
+
}
|
|
577
589
|
const requiredKind = requiredImageOutputKind(ctx.source);
|
|
578
590
|
if (requiredKind !== undefined) {
|
|
579
591
|
const topologyError = validateImageOutputTopology(ctx, requiredKind);
|
|
@@ -730,12 +742,11 @@ async function importImage(ctx: ImportContext): Promise<ImportResult> {
|
|
|
730
742
|
if (sub.kind !== 'texture') continue;
|
|
731
743
|
const payload: TextureAsset = {
|
|
732
744
|
kind: 'texture',
|
|
733
|
-
width: dec.width,
|
|
734
|
-
height: dec.height,
|
|
745
|
+
shape: { viewDimension: '2d', extent: { width: dec.width, height: dec.height } },
|
|
735
746
|
format: colorSpaceToFormat(colorSpace),
|
|
736
747
|
data: encodedBytes ?? dec.bytes,
|
|
737
748
|
colorSpace,
|
|
738
|
-
mipmap,
|
|
749
|
+
mips: mipmap ? { kind: 'generate' } : { kind: 'none' },
|
|
739
750
|
};
|
|
740
751
|
out.push({
|
|
741
752
|
guid: sub.guid,
|
|
@@ -825,11 +836,10 @@ export const decodeImageForImport: ImportContext['decodeImage'] = async (
|
|
|
825
836
|
texture: {
|
|
826
837
|
kind: 'texture' as const,
|
|
827
838
|
data: cookedBytes,
|
|
828
|
-
width: tex.width,
|
|
829
|
-
height: tex.height,
|
|
839
|
+
shape: { viewDimension: '2d', extent: { width: tex.width, height: tex.height } },
|
|
830
840
|
format: colorSpaceToFormat(colorSpace),
|
|
831
841
|
colorSpace,
|
|
832
|
-
mipmap,
|
|
842
|
+
mips: mipmap ? { kind: 'generate' as const } : { kind: 'none' as const },
|
|
833
843
|
},
|
|
834
844
|
bytes: cookedBytes,
|
|
835
845
|
mediaType,
|
package/src/index.ts
CHANGED
|
@@ -23,6 +23,14 @@ export { decodeHdr } from './hdr-decoder.js';
|
|
|
23
23
|
export { decodeImageInBrowser } from './image-decoder-browser.js';
|
|
24
24
|
export type { JpegModule, UpngModule } from './image-decoder-node.js';
|
|
25
25
|
export { loadJpeg, loadUpng } from './image-decoder-node.js';
|
|
26
|
+
export {
|
|
27
|
+
createPixelSurface,
|
|
28
|
+
type PixelColor,
|
|
29
|
+
type PixelSurface,
|
|
30
|
+
type PixelSurfaceNoiseOptions,
|
|
31
|
+
type PixelSurfaceOptions,
|
|
32
|
+
type PixelSurfaceRect,
|
|
33
|
+
} from './pixel-surface.js';
|
|
26
34
|
export type {
|
|
27
35
|
EmittedSubAsset,
|
|
28
36
|
ExistingExternalAssetPackage,
|