@forgeax/engine-image 0.1.20 → 0.1.23

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.
@@ -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();
@@ -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
+ });
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. */
@@ -32,7 +32,13 @@
32
32
  // (a single 2D rgba16float image); the cube-to-cube IBL projection is a runtime
33
33
  // GPU pass, not a build-time fold (feat-20260630).
34
34
 
35
- import { type BasisSourceInspection, ktx2ColorSpace, parseKtx2 } from '@forgeax/engine-codec';
35
+ import {
36
+ type BasisSourceInspection,
37
+ initBasisTranscoder,
38
+ inspectBasisSource,
39
+ ktx2ColorSpace,
40
+ parseKtx2,
41
+ } from '@forgeax/engine-codec';
36
42
  import type {
37
43
  EquirectAsset,
38
44
  ImageColorSpace,
@@ -219,7 +225,6 @@ async function inspectKtx2Source(
219
225
  're-encode the source as one 2D Basis texture',
220
226
  );
221
227
  }
222
- const { initBasisTranscoder } = await import('@forgeax/engine-codec');
223
228
  const module = await initBasisTranscoder();
224
229
  let file: InstanceType<typeof module.KTX2File>;
225
230
  try {
@@ -371,7 +376,6 @@ function basisMetaColorSpace(ctx: ImportContext): ImageColorSpace | ImportError
371
376
  async function importBasisSource(ctx: ImportContext, bytes: Uint8Array): Promise<ImportResult> {
372
377
  const colorSpace = basisMetaColorSpace(ctx);
373
378
  if (colorSpace instanceof ImportError) return { ok: false, error: colorSpace };
374
- const { initBasisTranscoder, inspectBasisSource } = await import('@forgeax/engine-codec');
375
379
  const module = await initBasisTranscoder();
376
380
  if (module.BasisFile === undefined) {
377
381
  throw new Error('basis-source-inspection-unavailable: transcoder lacks BasisFile');
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,
@@ -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
+ }