@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
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DecodedImage,
|
|
3
|
+
ImageColorSpace,
|
|
4
|
+
ImageError,
|
|
5
|
+
ImageErrorDetailFor,
|
|
6
|
+
ImageMeta,
|
|
7
|
+
TextureAsset,
|
|
8
|
+
} from '@forgeax/engine-types';
|
|
9
|
+
import { imageError } from './errors.js';
|
|
10
|
+
import { err, ok, type Result } from './result.js';
|
|
11
|
+
import { type ExternalAssetPackage, toAssetPack } from './to-asset-pack.js';
|
|
12
|
+
|
|
13
|
+
/** Four finite RGBA8 channels in source order. */
|
|
14
|
+
export type PixelColor =
|
|
15
|
+
| readonly [number, number, number, number]
|
|
16
|
+
| Readonly<{ r: number; g: number; b: number; a: number }>;
|
|
17
|
+
|
|
18
|
+
/** Optional source rectangle for a blit; coordinates are rounded and clipped. */
|
|
19
|
+
export interface PixelSurfaceRect {
|
|
20
|
+
readonly x?: number;
|
|
21
|
+
readonly y?: number;
|
|
22
|
+
readonly width?: number;
|
|
23
|
+
readonly height?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface PixelSurfaceNoiseOptions {
|
|
27
|
+
/** Inclusive lower channel bound, default 0. */
|
|
28
|
+
readonly min?: number;
|
|
29
|
+
/** Inclusive upper channel bound, default 255. */
|
|
30
|
+
readonly max?: number;
|
|
31
|
+
/** Alpha written for every generated pixel, default 255. */
|
|
32
|
+
readonly alpha?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface PixelSurfaceOptions {
|
|
36
|
+
readonly width: number;
|
|
37
|
+
readonly height: number;
|
|
38
|
+
readonly colorSpace?: ImageColorSpace;
|
|
39
|
+
readonly mipmap?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface PixelSurface {
|
|
43
|
+
readonly width: number;
|
|
44
|
+
readonly height: number;
|
|
45
|
+
readonly colorSpace: ImageColorSpace;
|
|
46
|
+
readonly mipmap: boolean;
|
|
47
|
+
/** Tight RGBA8 level-zero bytes in row-major order. */
|
|
48
|
+
readonly data: Uint8Array;
|
|
49
|
+
|
|
50
|
+
setPixel(x: number, y: number, color: PixelColor): Result<void, ImageError>;
|
|
51
|
+
fillRect(
|
|
52
|
+
x: number,
|
|
53
|
+
y: number,
|
|
54
|
+
width: number,
|
|
55
|
+
height: number,
|
|
56
|
+
color: PixelColor,
|
|
57
|
+
): Result<void, ImageError>;
|
|
58
|
+
fillCircle(cx: number, cy: number, radius: number, color: PixelColor): Result<void, ImageError>;
|
|
59
|
+
blit(
|
|
60
|
+
source: PixelSurface,
|
|
61
|
+
destinationX: number,
|
|
62
|
+
destinationY: number,
|
|
63
|
+
sourceRect?: PixelSurfaceRect,
|
|
64
|
+
): Result<void, ImageError>;
|
|
65
|
+
/** Fill RGB channels with deterministic seeded noise and set alpha uniformly. */
|
|
66
|
+
fillNoise(seed: number, options?: PixelSurfaceNoiseOptions): Result<void, ImageError>;
|
|
67
|
+
/** Alias kept on the same value for the concise authoring spelling. */
|
|
68
|
+
noise(seed: number, options?: PixelSurfaceNoiseOptions): Result<void, ImageError>;
|
|
69
|
+
|
|
70
|
+
toDecodedImage(): DecodedImage;
|
|
71
|
+
toTextureAsset(): TextureAsset;
|
|
72
|
+
toAssetPack(meta: ImageMeta): ExternalAssetPackage;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
type SurfaceOperation = ImageErrorDetailFor<'image-surface-invalid'>['operation'];
|
|
76
|
+
|
|
77
|
+
function invalid(
|
|
78
|
+
operation: SurfaceOperation,
|
|
79
|
+
field: string,
|
|
80
|
+
value: string | number,
|
|
81
|
+
expected: string,
|
|
82
|
+
): Result<never, ImageError> {
|
|
83
|
+
return err(
|
|
84
|
+
imageError({
|
|
85
|
+
code: 'image-surface-invalid',
|
|
86
|
+
operation,
|
|
87
|
+
field,
|
|
88
|
+
value,
|
|
89
|
+
expected,
|
|
90
|
+
}),
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function finiteNumber(
|
|
95
|
+
operation: SurfaceOperation,
|
|
96
|
+
field: string,
|
|
97
|
+
value: number,
|
|
98
|
+
): Result<number, ImageError> {
|
|
99
|
+
if (!Number.isFinite(value)) {
|
|
100
|
+
return invalid(operation, field, String(value), 'a finite number');
|
|
101
|
+
}
|
|
102
|
+
return ok(value);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function positiveDimension(
|
|
106
|
+
operation: SurfaceOperation,
|
|
107
|
+
field: string,
|
|
108
|
+
value: number,
|
|
109
|
+
): Result<number, ImageError> {
|
|
110
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
111
|
+
return invalid(operation, field, value, 'a positive integer');
|
|
112
|
+
}
|
|
113
|
+
return ok(value);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function colorChannels(
|
|
117
|
+
operation: SurfaceOperation,
|
|
118
|
+
color: PixelColor,
|
|
119
|
+
): Result<readonly [number, number, number, number], ImageError> {
|
|
120
|
+
const channels = Array.isArray(color)
|
|
121
|
+
? color
|
|
122
|
+
: color !== null && typeof color === 'object'
|
|
123
|
+
? [
|
|
124
|
+
(color as Readonly<{ r: number; g: number; b: number; a: number }>).r,
|
|
125
|
+
(color as Readonly<{ r: number; g: number; b: number; a: number }>).g,
|
|
126
|
+
(color as Readonly<{ r: number; g: number; b: number; a: number }>).b,
|
|
127
|
+
(color as Readonly<{ r: number; g: number; b: number; a: number }>).a,
|
|
128
|
+
]
|
|
129
|
+
: undefined;
|
|
130
|
+
if (channels === undefined || channels.length !== 4) {
|
|
131
|
+
return invalid(operation, 'color', 'malformed', 'four finite RGBA8 channels');
|
|
132
|
+
}
|
|
133
|
+
const normalized: [number, number, number, number] = [0, 0, 0, 0];
|
|
134
|
+
for (let index = 0; index < channels.length; index += 1) {
|
|
135
|
+
const channel = channels[index];
|
|
136
|
+
if (channel === undefined || !Number.isFinite(channel) || channel < 0 || channel > 255) {
|
|
137
|
+
return invalid(
|
|
138
|
+
operation,
|
|
139
|
+
`color[${index}]`,
|
|
140
|
+
channel ?? 'missing',
|
|
141
|
+
'a finite number in [0, 255]',
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
normalized[index] = Math.round(channel);
|
|
145
|
+
}
|
|
146
|
+
return ok(normalized);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function rounded(
|
|
150
|
+
operation: SurfaceOperation,
|
|
151
|
+
field: string,
|
|
152
|
+
value: number,
|
|
153
|
+
): Result<number, ImageError> {
|
|
154
|
+
const result = finiteNumber(operation, field, value);
|
|
155
|
+
return result.ok ? ok(Math.round(result.value)) : result;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function rectangle(
|
|
159
|
+
operation: 'fill-rect' | 'blit',
|
|
160
|
+
x: number,
|
|
161
|
+
y: number,
|
|
162
|
+
width: number,
|
|
163
|
+
height: number,
|
|
164
|
+
): Result<readonly [number, number, number, number], ImageError> {
|
|
165
|
+
const values: [number, number, number, number] = [x, y, width, height];
|
|
166
|
+
for (let index = 0; index < values.length; index += 1) {
|
|
167
|
+
const value = values[index];
|
|
168
|
+
if (value === undefined || !Number.isFinite(value)) {
|
|
169
|
+
return invalid(
|
|
170
|
+
operation,
|
|
171
|
+
['x', 'y', 'width', 'height'][index] ?? 'rectangle',
|
|
172
|
+
String(value),
|
|
173
|
+
'a finite number',
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (width <= 0 || height <= 0) {
|
|
178
|
+
return invalid(
|
|
179
|
+
operation,
|
|
180
|
+
width <= 0 ? 'width' : 'height',
|
|
181
|
+
width <= 0 ? width : height,
|
|
182
|
+
'a positive number',
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
return ok([Math.round(x), Math.round(y), Math.round(width), Math.round(height)]);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function writePixel(
|
|
189
|
+
data: Uint8Array,
|
|
190
|
+
width: number,
|
|
191
|
+
x: number,
|
|
192
|
+
y: number,
|
|
193
|
+
color: ArrayLike<number>,
|
|
194
|
+
): void {
|
|
195
|
+
if (x < 0 || y < 0 || x >= width) return;
|
|
196
|
+
const offset = (y * width + x) * 4;
|
|
197
|
+
data[offset] = color[0] ?? 0;
|
|
198
|
+
data[offset + 1] = color[1] ?? 0;
|
|
199
|
+
data[offset + 2] = color[2] ?? 0;
|
|
200
|
+
data[offset + 3] = color[3] ?? 0;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function fillClippedRect(
|
|
204
|
+
data: Uint8Array,
|
|
205
|
+
width: number,
|
|
206
|
+
height: number,
|
|
207
|
+
x: number,
|
|
208
|
+
y: number,
|
|
209
|
+
rectWidth: number,
|
|
210
|
+
rectHeight: number,
|
|
211
|
+
color: readonly number[],
|
|
212
|
+
): void {
|
|
213
|
+
const left = Math.max(0, x);
|
|
214
|
+
const top = Math.max(0, y);
|
|
215
|
+
const right = Math.min(width, x + rectWidth);
|
|
216
|
+
const bottom = Math.min(height, y + rectHeight);
|
|
217
|
+
for (let row = top; row < bottom; row += 1) {
|
|
218
|
+
for (let column = left; column < right; column += 1) {
|
|
219
|
+
writePixel(data, width, column, row, color);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function nextRandom(state: number): number {
|
|
225
|
+
// xorshift32 is small, deterministic across runtimes, and has no ambient
|
|
226
|
+
// state. The seed is normalized once by fillNoise, so zero remains valid.
|
|
227
|
+
let value = state >>> 0;
|
|
228
|
+
value ^= value << 13;
|
|
229
|
+
value ^= value >>> 17;
|
|
230
|
+
value ^= value << 5;
|
|
231
|
+
return value >>> 0;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function makeSurface(options: PixelSurfaceOptions): Result<PixelSurface, ImageError> {
|
|
235
|
+
const width = positiveDimension('create', 'width', options.width);
|
|
236
|
+
if (!width.ok) return width;
|
|
237
|
+
const height = positiveDimension('create', 'height', options.height);
|
|
238
|
+
if (!height.ok) return height;
|
|
239
|
+
if (
|
|
240
|
+
options.colorSpace !== undefined &&
|
|
241
|
+
options.colorSpace !== 'srgb' &&
|
|
242
|
+
options.colorSpace !== 'linear'
|
|
243
|
+
) {
|
|
244
|
+
return invalid('create', 'colorSpace', String(options.colorSpace), "'srgb' or 'linear'");
|
|
245
|
+
}
|
|
246
|
+
if (options.mipmap !== undefined && typeof options.mipmap !== 'boolean') {
|
|
247
|
+
return invalid('create', 'mipmap', String(options.mipmap), 'a boolean');
|
|
248
|
+
}
|
|
249
|
+
const data = new Uint8Array(width.value * height.value * 4);
|
|
250
|
+
const colorSpace = options.colorSpace ?? 'srgb';
|
|
251
|
+
const mipmap = options.mipmap ?? false;
|
|
252
|
+
|
|
253
|
+
const surface: PixelSurface = {
|
|
254
|
+
width: width.value,
|
|
255
|
+
height: height.value,
|
|
256
|
+
colorSpace,
|
|
257
|
+
mipmap,
|
|
258
|
+
data,
|
|
259
|
+
setPixel(x, y, color) {
|
|
260
|
+
const px = rounded('set-pixel', 'x', x);
|
|
261
|
+
if (!px.ok) return px;
|
|
262
|
+
const py = rounded('set-pixel', 'y', y);
|
|
263
|
+
if (!py.ok) return py;
|
|
264
|
+
const normalized = colorChannels('set-pixel', color);
|
|
265
|
+
if (!normalized.ok) return normalized;
|
|
266
|
+
writePixel(data, width.value, px.value, py.value, normalized.value);
|
|
267
|
+
return ok(undefined);
|
|
268
|
+
},
|
|
269
|
+
fillRect(x, y, rectWidth, rectHeight, color) {
|
|
270
|
+
const rect = rectangle('fill-rect', x, y, rectWidth, rectHeight);
|
|
271
|
+
if (!rect.ok) return rect;
|
|
272
|
+
const normalized = colorChannels('fill-rect', color);
|
|
273
|
+
if (!normalized.ok) return normalized;
|
|
274
|
+
fillClippedRect(
|
|
275
|
+
data,
|
|
276
|
+
width.value,
|
|
277
|
+
height.value,
|
|
278
|
+
rect.value[0] ?? 0,
|
|
279
|
+
rect.value[1] ?? 0,
|
|
280
|
+
rect.value[2] ?? 0,
|
|
281
|
+
rect.value[3] ?? 0,
|
|
282
|
+
normalized.value,
|
|
283
|
+
);
|
|
284
|
+
return ok(undefined);
|
|
285
|
+
},
|
|
286
|
+
fillCircle(cx, cy, radius, color) {
|
|
287
|
+
const centerX = rounded('fill-circle', 'cx', cx);
|
|
288
|
+
if (!centerX.ok) return centerX;
|
|
289
|
+
const centerY = rounded('fill-circle', 'cy', cy);
|
|
290
|
+
if (!centerY.ok) return centerY;
|
|
291
|
+
const circleRadius = rounded('fill-circle', 'radius', radius);
|
|
292
|
+
if (!circleRadius.ok) return circleRadius;
|
|
293
|
+
if (circleRadius.value <= 0) {
|
|
294
|
+
return invalid('fill-circle', 'radius', circleRadius.value, 'a positive number');
|
|
295
|
+
}
|
|
296
|
+
const normalized = colorChannels('fill-circle', color);
|
|
297
|
+
if (!normalized.ok) return normalized;
|
|
298
|
+
const radiusSquared = circleRadius.value * circleRadius.value;
|
|
299
|
+
const left = Math.max(0, centerX.value - circleRadius.value);
|
|
300
|
+
const right = Math.min(width.value - 1, centerX.value + circleRadius.value);
|
|
301
|
+
const top = Math.max(0, centerY.value - circleRadius.value);
|
|
302
|
+
const bottom = Math.min(height.value - 1, centerY.value + circleRadius.value);
|
|
303
|
+
for (let row = top; row <= bottom; row += 1) {
|
|
304
|
+
for (let column = left; column <= right; column += 1) {
|
|
305
|
+
const dx = column - centerX.value;
|
|
306
|
+
const dy = row - centerY.value;
|
|
307
|
+
if (dx * dx + dy * dy <= radiusSquared)
|
|
308
|
+
writePixel(data, width.value, column, row, normalized.value);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return ok(undefined);
|
|
312
|
+
},
|
|
313
|
+
blit(source, destinationX, destinationY, sourceRect) {
|
|
314
|
+
const destination = rounded('blit', 'destinationX', destinationX);
|
|
315
|
+
if (!destination.ok) return destination;
|
|
316
|
+
const destinationYResult = rounded('blit', 'destinationY', destinationY);
|
|
317
|
+
if (!destinationYResult.ok) return destinationYResult;
|
|
318
|
+
if (
|
|
319
|
+
source === null ||
|
|
320
|
+
typeof source !== 'object' ||
|
|
321
|
+
!Number.isInteger(source.width) ||
|
|
322
|
+
!Number.isInteger(source.height) ||
|
|
323
|
+
!(source.data instanceof Uint8Array) ||
|
|
324
|
+
source.data.length !== source.width * source.height * 4
|
|
325
|
+
) {
|
|
326
|
+
return invalid('blit', 'source', 'malformed', 'a valid PixelSurface');
|
|
327
|
+
}
|
|
328
|
+
const rect = rectangle(
|
|
329
|
+
'blit',
|
|
330
|
+
sourceRect?.x ?? 0,
|
|
331
|
+
sourceRect?.y ?? 0,
|
|
332
|
+
sourceRect?.width ?? source.width,
|
|
333
|
+
sourceRect?.height ?? source.height,
|
|
334
|
+
);
|
|
335
|
+
if (!rect.ok) return rect;
|
|
336
|
+
const sourceX = rect.value[0] ?? 0;
|
|
337
|
+
const sourceY = rect.value[1] ?? 0;
|
|
338
|
+
const sourceWidth = rect.value[2] ?? 0;
|
|
339
|
+
const sourceHeight = rect.value[3] ?? 0;
|
|
340
|
+
const left = Math.max(0, sourceX);
|
|
341
|
+
const top = Math.max(0, sourceY);
|
|
342
|
+
const right = Math.min(source.width, sourceX + sourceWidth);
|
|
343
|
+
const bottom = Math.min(source.height, sourceY + sourceHeight);
|
|
344
|
+
if (right <= left || bottom <= top) return ok(undefined);
|
|
345
|
+
const snapshot = source.data.slice();
|
|
346
|
+
for (let row = top; row < bottom; row += 1) {
|
|
347
|
+
for (let column = left; column < right; column += 1) {
|
|
348
|
+
const targetX = destination.value + column - sourceX;
|
|
349
|
+
const targetY = destinationYResult.value + row - sourceY;
|
|
350
|
+
if (targetX < 0 || targetY < 0 || targetX >= width.value || targetY >= height.value)
|
|
351
|
+
continue;
|
|
352
|
+
const sourceOffset = (row * source.width + column) * 4;
|
|
353
|
+
writePixel(
|
|
354
|
+
data,
|
|
355
|
+
width.value,
|
|
356
|
+
targetX,
|
|
357
|
+
targetY,
|
|
358
|
+
snapshot.subarray(sourceOffset, sourceOffset + 4),
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return ok(undefined);
|
|
363
|
+
},
|
|
364
|
+
fillNoise(seed, noiseOptions) {
|
|
365
|
+
if (!Number.isFinite(seed)) return invalid('noise', 'seed', String(seed), 'a finite number');
|
|
366
|
+
const min = noiseOptions?.min ?? 0;
|
|
367
|
+
const max = noiseOptions?.max ?? 255;
|
|
368
|
+
const alpha = noiseOptions?.alpha ?? 255;
|
|
369
|
+
for (const [field, value] of [
|
|
370
|
+
['min', min],
|
|
371
|
+
['max', max],
|
|
372
|
+
['alpha', alpha],
|
|
373
|
+
] as const) {
|
|
374
|
+
if (!Number.isFinite(value) || value < 0 || value > 255) {
|
|
375
|
+
return invalid('noise', field, value, 'a finite number in [0, 255]');
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
if (min > max) return invalid('noise', 'min', min, 'a value no greater than max');
|
|
379
|
+
let state = Math.trunc(seed) >>> 0 || 0x6d2b79f5;
|
|
380
|
+
const span = max - min;
|
|
381
|
+
for (let index = 0; index < data.length; index += 4) {
|
|
382
|
+
state = nextRandom(state);
|
|
383
|
+
const value = min + (state / 0x100000000) * span;
|
|
384
|
+
const channel = Math.round(value);
|
|
385
|
+
data[index] = channel;
|
|
386
|
+
data[index + 1] = channel;
|
|
387
|
+
data[index + 2] = channel;
|
|
388
|
+
data[index + 3] = Math.round(alpha);
|
|
389
|
+
}
|
|
390
|
+
return ok(undefined);
|
|
391
|
+
},
|
|
392
|
+
noise(seed, noiseOptions) {
|
|
393
|
+
return surface.fillNoise(seed, noiseOptions);
|
|
394
|
+
},
|
|
395
|
+
toDecodedImage() {
|
|
396
|
+
return {
|
|
397
|
+
bytes: data.slice(),
|
|
398
|
+
width: width.value,
|
|
399
|
+
height: height.value,
|
|
400
|
+
mime: 'image/png',
|
|
401
|
+
colorSpace,
|
|
402
|
+
mipmap,
|
|
403
|
+
};
|
|
404
|
+
},
|
|
405
|
+
toTextureAsset() {
|
|
406
|
+
return {
|
|
407
|
+
kind: 'texture',
|
|
408
|
+
shape: {
|
|
409
|
+
viewDimension: '2d',
|
|
410
|
+
extent: { width: width.value, height: height.value },
|
|
411
|
+
},
|
|
412
|
+
format: colorSpace === 'srgb' ? 'rgba8unorm-srgb' : 'rgba8unorm',
|
|
413
|
+
data: data.slice(),
|
|
414
|
+
colorSpace,
|
|
415
|
+
mips: mipmap ? { kind: 'generate' } : { kind: 'none' },
|
|
416
|
+
};
|
|
417
|
+
},
|
|
418
|
+
toAssetPack(meta) {
|
|
419
|
+
return toAssetPack(surface.toDecodedImage(), meta);
|
|
420
|
+
},
|
|
421
|
+
};
|
|
422
|
+
return ok(surface);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Create a deterministic, image-owned RGBA8 authoring surface. */
|
|
426
|
+
export function createPixelSurface(options: PixelSurfaceOptions): Result<PixelSurface, ImageError> {
|
|
427
|
+
return makeSurface(options);
|
|
428
|
+
}
|
|
@@ -37,11 +37,32 @@ function compressedImageTarget(colorSpace: TextureAsset['colorSpace']): TextureA
|
|
|
37
37
|
return colorSpace === 'srgb' ? 'rgba8unorm-srgb' : 'rgba8unorm';
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
function
|
|
40
|
+
function validTextureSurface(
|
|
41
41
|
value: unknown,
|
|
42
|
-
): value is Pick<TextureAsset, '
|
|
42
|
+
): value is Pick<TextureAsset, 'shape' | 'format' | 'data' | 'colorSpace' | 'mips'> {
|
|
43
43
|
if (value === null || typeof value !== 'object') return false;
|
|
44
44
|
const candidate = value as Partial<TextureAsset>;
|
|
45
|
+
const shape = candidate.shape;
|
|
46
|
+
const extent = shape?.viewDimension === '2d' ? shape.extent : undefined;
|
|
47
|
+
const mips = candidate.mips;
|
|
48
|
+
return (
|
|
49
|
+
extent !== undefined &&
|
|
50
|
+
validDimensions(extent.width, extent.height) &&
|
|
51
|
+
mips !== undefined &&
|
|
52
|
+
(mips.kind === 'none' ||
|
|
53
|
+
mips.kind === 'generate' ||
|
|
54
|
+
(mips.kind === 'packed' && Number.isSafeInteger(mips.levelCount) && mips.levelCount > 0)) &&
|
|
55
|
+
typeof candidate.format === 'string' &&
|
|
56
|
+
imageBytes(candidate.data) !== undefined &&
|
|
57
|
+
(candidate.colorSpace === 'srgb' || candidate.colorSpace === 'linear')
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function validEquirectSurface(
|
|
62
|
+
value: unknown,
|
|
63
|
+
): value is Pick<EquirectAsset, 'width' | 'height' | 'format' | 'data' | 'colorSpace'> {
|
|
64
|
+
if (value === null || typeof value !== 'object') return false;
|
|
65
|
+
const candidate = value as Partial<EquirectAsset>;
|
|
45
66
|
return (
|
|
46
67
|
validDimensions(candidate.width ?? 0, candidate.height ?? 0) &&
|
|
47
68
|
typeof candidate.format === 'string' &&
|
|
@@ -96,17 +117,24 @@ async function readImageSurface<P extends TextureAsset | EquirectAsset>(
|
|
|
96
117
|
const mip = transcoded.value.mips[0];
|
|
97
118
|
if (mip === undefined) return invalid(envelope.guid, expected, 'codec:base-mip-missing');
|
|
98
119
|
data = mip.data;
|
|
99
|
-
return readDecodedSurface(
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
120
|
+
return readDecodedSurface(
|
|
121
|
+
envelope.guid,
|
|
122
|
+
expected,
|
|
123
|
+
kind,
|
|
124
|
+
kind === 'texture'
|
|
125
|
+
? {
|
|
126
|
+
...payload,
|
|
127
|
+
shape: { viewDimension: '2d', extent: { width: mip.width, height: mip.height } },
|
|
128
|
+
format: target,
|
|
129
|
+
data,
|
|
130
|
+
mips: { kind: 'none' },
|
|
131
|
+
}
|
|
132
|
+
: { ...payload, width: mip.width, height: mip.height, format: target, data },
|
|
133
|
+
);
|
|
106
134
|
}
|
|
107
135
|
}
|
|
108
136
|
|
|
109
|
-
return readDecodedSurface(envelope.guid, expected, {
|
|
137
|
+
return readDecodedSurface(envelope.guid, expected, kind, {
|
|
110
138
|
...payload,
|
|
111
139
|
...(data === undefined ? {} : { data }),
|
|
112
140
|
});
|
|
@@ -115,9 +143,10 @@ async function readImageSurface<P extends TextureAsset | EquirectAsset>(
|
|
|
115
143
|
function readDecodedSurface<P extends TextureAsset | EquirectAsset>(
|
|
116
144
|
guid: string,
|
|
117
145
|
expected: string,
|
|
146
|
+
kind: P['kind'],
|
|
118
147
|
candidate: unknown,
|
|
119
148
|
): ReturnType<typeof ok<P>> | ReturnType<typeof err<AssetLoadError>> {
|
|
120
|
-
if (!
|
|
149
|
+
if (kind === 'texture' ? !validTextureSurface(candidate) : !validEquirectSurface(candidate)) {
|
|
121
150
|
return invalid(guid, expected, 'image owner validation failed');
|
|
122
151
|
}
|
|
123
152
|
return ok(candidate as P);
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ImportContext,
|
|
3
|
+
ImportedAsset,
|
|
4
|
+
TextureAsset,
|
|
5
|
+
TextureError,
|
|
6
|
+
} from '@forgeax/engine-types';
|
|
7
|
+
import { deriveTextureLayout, err, ImportError, ok, type Result } from '@forgeax/engine-types';
|
|
8
|
+
import {
|
|
9
|
+
parseTextureSourceDescriptor,
|
|
10
|
+
type TextureSourceDescriptor,
|
|
11
|
+
type TextureSourceDescriptorError,
|
|
12
|
+
} from './source-descriptor.js';
|
|
13
|
+
|
|
14
|
+
export interface TextureSourceInput {
|
|
15
|
+
readonly descriptor: unknown;
|
|
16
|
+
readonly guid: string;
|
|
17
|
+
readonly sourceKey: string;
|
|
18
|
+
readSibling(
|
|
19
|
+
uri: string,
|
|
20
|
+
): Promise<
|
|
21
|
+
| { readonly ok: true; readonly value: Uint8Array }
|
|
22
|
+
| { readonly ok: false; readonly error: unknown }
|
|
23
|
+
>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface TextureSourceReadError {
|
|
27
|
+
readonly code: 'source-read-failed';
|
|
28
|
+
readonly expected: string;
|
|
29
|
+
readonly hint: string;
|
|
30
|
+
readonly detail: {
|
|
31
|
+
readonly sourceKey: string;
|
|
32
|
+
readonly sibling: string;
|
|
33
|
+
readonly reason: string;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type TextureSourceError =
|
|
38
|
+
| TextureSourceDescriptorError
|
|
39
|
+
| TextureSourceReadError
|
|
40
|
+
| TextureError;
|
|
41
|
+
|
|
42
|
+
export type TextureSourceResult = Result<ImportedAsset<TextureAsset>, TextureSourceError>;
|
|
43
|
+
|
|
44
|
+
function sourceReadError(input: TextureSourceInput, reason: unknown): TextureSourceReadError {
|
|
45
|
+
return {
|
|
46
|
+
code: 'source-read-failed',
|
|
47
|
+
expected: `readable raw sibling "${input.descriptor && typeof input.descriptor === 'object' && 'rawSibling' in input.descriptor ? input.descriptor.rawSibling : 'rawSibling'}"`,
|
|
48
|
+
hint: 'repair the raw sibling path or bytes and re-import the same texture GUID',
|
|
49
|
+
detail: {
|
|
50
|
+
sourceKey: input.sourceKey,
|
|
51
|
+
sibling:
|
|
52
|
+
input.descriptor && typeof input.descriptor === 'object' && 'rawSibling' in input.descriptor
|
|
53
|
+
? String(input.descriptor.rawSibling)
|
|
54
|
+
: 'rawSibling',
|
|
55
|
+
reason: reason instanceof Error ? reason.message : String(reason),
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function bodyMediaType(format: GPUTextureFormat): string {
|
|
61
|
+
return format === 'r8unorm' ? 'application/x-forgeax-r8' : `application/x-forgeax-${format}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Produce one canonical TextureAsset and body artifact from a descriptor sibling. */
|
|
65
|
+
export async function produceTextureSource(
|
|
66
|
+
input: TextureSourceInput,
|
|
67
|
+
): Promise<TextureSourceResult> {
|
|
68
|
+
const descriptorResult = parseTextureSourceDescriptor(input.descriptor);
|
|
69
|
+
if (!descriptorResult.ok) return descriptorResult;
|
|
70
|
+
const descriptor = descriptorResult.value;
|
|
71
|
+
const sibling = await input.readSibling(descriptor.rawSibling);
|
|
72
|
+
if (!sibling.ok) return err(sourceReadError(input, sibling.error));
|
|
73
|
+
|
|
74
|
+
const layout = deriveTextureLayout({
|
|
75
|
+
shape: descriptor.shape,
|
|
76
|
+
format: descriptor.format,
|
|
77
|
+
mips: descriptor.mips,
|
|
78
|
+
actualByteLength: sibling.value.byteLength,
|
|
79
|
+
order: 'mip-major,image-major,row-major',
|
|
80
|
+
});
|
|
81
|
+
if (!layout.ok) return layout;
|
|
82
|
+
|
|
83
|
+
const data = new Uint8Array(sibling.value);
|
|
84
|
+
return ok({
|
|
85
|
+
guid: input.guid,
|
|
86
|
+
kind: 'texture',
|
|
87
|
+
payload: {
|
|
88
|
+
kind: 'texture',
|
|
89
|
+
shape: descriptor.shape,
|
|
90
|
+
format: descriptor.format,
|
|
91
|
+
colorSpace: descriptor.colorSpace,
|
|
92
|
+
mips: descriptor.mips,
|
|
93
|
+
data,
|
|
94
|
+
},
|
|
95
|
+
refs: [],
|
|
96
|
+
artifacts: {
|
|
97
|
+
body: {
|
|
98
|
+
mediaType: bodyMediaType(descriptor.format),
|
|
99
|
+
assetCodec: { name: descriptor.format, version: '1' },
|
|
100
|
+
bytes: data,
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function sourceValidationError(ctx: ImportContext, error: TextureSourceError): ImportError {
|
|
107
|
+
const detail = 'detail' in error ? error.detail : { field: 'descriptor', actual: error };
|
|
108
|
+
return new ImportError({
|
|
109
|
+
code: 'source-validation-failed',
|
|
110
|
+
expected: error.expected,
|
|
111
|
+
hint: error.hint,
|
|
112
|
+
detail: {
|
|
113
|
+
diagnostics: [
|
|
114
|
+
{
|
|
115
|
+
code: `texture-source-${error.code}`,
|
|
116
|
+
severity: 'error',
|
|
117
|
+
sourcePath: `${ctx.source}#${'field' in detail ? detail.field : 'rawSibling'}`,
|
|
118
|
+
sourceRange: { start: 0, end: 0, line: 1, column: 1 },
|
|
119
|
+
rule: 'texture-source-descriptor',
|
|
120
|
+
expected: error.expected,
|
|
121
|
+
actual: JSON.stringify(detail),
|
|
122
|
+
hint: error.hint,
|
|
123
|
+
},
|
|
124
|
+
],
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Import a JSON descriptor whose canonical bytes live in one raw sibling. */
|
|
130
|
+
export async function importTextureSource(ctx: ImportContext): Promise<
|
|
131
|
+
| {
|
|
132
|
+
readonly ok: true;
|
|
133
|
+
readonly value: {
|
|
134
|
+
readonly assets: readonly ImportedAsset[];
|
|
135
|
+
readonly sourceDependencies: readonly string[];
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
| { readonly ok: false; readonly error: ImportError }
|
|
139
|
+
> {
|
|
140
|
+
const source = await ctx.readSource();
|
|
141
|
+
if (!source.ok) {
|
|
142
|
+
const reason = String(source.error);
|
|
143
|
+
return {
|
|
144
|
+
ok: false,
|
|
145
|
+
error: new ImportError({
|
|
146
|
+
code: 'source-read-failed',
|
|
147
|
+
expected: `readable texture descriptor at "${ctx.source}"`,
|
|
148
|
+
hint: 'repair the texture descriptor path and retry the import',
|
|
149
|
+
detail: { source: ctx.source, reason },
|
|
150
|
+
}),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let descriptor: unknown;
|
|
155
|
+
try {
|
|
156
|
+
descriptor = JSON.parse(new TextDecoder().decode(source.value));
|
|
157
|
+
} catch (error) {
|
|
158
|
+
return {
|
|
159
|
+
ok: false,
|
|
160
|
+
error: sourceValidationError(ctx, {
|
|
161
|
+
code: 'texture-source-descriptor-invalid',
|
|
162
|
+
expected: 'JSON texture source descriptor',
|
|
163
|
+
hint: 'repair the descriptor JSON and retry the import',
|
|
164
|
+
detail: {
|
|
165
|
+
field: 'descriptor',
|
|
166
|
+
actual: error instanceof Error ? error.message : String(error),
|
|
167
|
+
},
|
|
168
|
+
}),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
const subAsset = ctx.subAssets.length === 1 ? ctx.subAssets[0] : undefined;
|
|
172
|
+
if (subAsset === undefined || subAsset.kind !== 'texture' || subAsset.sourceIndex !== 0) {
|
|
173
|
+
return {
|
|
174
|
+
ok: false,
|
|
175
|
+
error: sourceValidationError(ctx, {
|
|
176
|
+
code: 'texture-source-descriptor-invalid',
|
|
177
|
+
expected: 'one texture subAsset at sourceIndex 0',
|
|
178
|
+
hint: 'repair Meta subAssets and retry the same texture GUID',
|
|
179
|
+
detail: { field: 'subAssets', actual: ctx.subAssets },
|
|
180
|
+
}),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
const produced = await produceTextureSource({
|
|
184
|
+
descriptor,
|
|
185
|
+
guid: subAsset.guid,
|
|
186
|
+
sourceKey: subAsset.sourceKey ?? `${ctx.source}:texture`,
|
|
187
|
+
readSibling: ctx.readSibling,
|
|
188
|
+
});
|
|
189
|
+
if (!produced.ok) return { ok: false, error: sourceValidationError(ctx, produced.error) };
|
|
190
|
+
const parsed = parseTextureSourceDescriptor(descriptor);
|
|
191
|
+
const sibling = parsed.ok ? parsed.value.rawSibling : ctx.source;
|
|
192
|
+
return {
|
|
193
|
+
ok: true,
|
|
194
|
+
value: { assets: [produced.value], sourceDependencies: [ctx.source, sibling] },
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export type { TextureSourceDescriptor };
|