@forgeax/engine-image 0.1.28 → 0.1.29

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.
@@ -0,0 +1,84 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { type CubeLut, cubeLutBytes, parseCubeLut } from '../lut/cube-parser.js';
4
+
5
+ function cubeSource(
6
+ size: number,
7
+ options: { readonly extra?: string; readonly rows?: string[] } = {},
8
+ ) {
9
+ const rows =
10
+ options.rows ??
11
+ Array.from({ length: size ** 3 }, (_, index) => {
12
+ const value = index / Math.max(1, size ** 3 - 1);
13
+ return `${value.toFixed(8)} ${value.toFixed(8)} ${value.toFixed(8)}`;
14
+ });
15
+ return [
16
+ '# canonical test source',
17
+ `LUT_3D_SIZE ${size}`,
18
+ 'DOMAIN_MIN 0.0 0.0 0.0',
19
+ 'DOMAIN_MAX 1.0 1.0 1.0',
20
+ options.extra ?? '',
21
+ ...rows,
22
+ ].join('\n');
23
+ }
24
+
25
+ describe('parseCubeLut', () => {
26
+ it.each([16, 32, 64])('accepts canonical %s^3 data with R-fastest ordering', (size) => {
27
+ const parsed = parseCubeLut(cubeSource(size), `fixture/canonical-${size}.cube`);
28
+ expect(parsed.ok).toBe(true);
29
+ if (!parsed.ok) return;
30
+ expect(parsed.value).toMatchObject({
31
+ size,
32
+ domainMin: [0, 0, 0],
33
+ domainMax: [1, 1, 1],
34
+ });
35
+ expect(parsed.value.values).toHaveLength(size ** 3 * 3);
36
+ expect(parsed.value.values.slice(0, 3)).toEqual(new Float32Array([0, 0, 0]));
37
+ expect(parsed.value.values.slice(-3)).toEqual(new Float32Array([1, 1, 1]));
38
+ });
39
+
40
+ it('emits deterministic linear rgba16float bytes with alpha one', () => {
41
+ const parsed = parseCubeLut(cubeSource(16), 'fixture/deterministic.cube');
42
+ expect(parsed.ok).toBe(true);
43
+ if (!parsed.ok) return;
44
+ const first = cubeLutBytes(parsed.value);
45
+ const second = cubeLutBytes(parsed.value);
46
+ expect(first).toEqual(second);
47
+ expect(first.byteLength).toBe(16 ** 3 * 4 * 2);
48
+ expect(createHash('sha256').update(first).digest('hex')).toBe(
49
+ '38344bf4db2170fd88c41f0acda182470777eed00688bcf214f2127ce62896e9',
50
+ );
51
+ });
52
+
53
+ it.each([
54
+ ['missing size', '# no size\n0 0 0'],
55
+ ['non-cubic size', cubeSource(16, { extra: 'LUT_3D_SIZE 32' })],
56
+ ['short data', cubeSource(16, { rows: ['0 0 0'] })],
57
+ [
58
+ 'extra data',
59
+ cubeSource(16, { rows: [...Array.from({ length: 16 ** 3 }, () => '0 0 0'), '0 0 0'] }),
60
+ ],
61
+ [
62
+ 'non-finite data',
63
+ cubeSource(16, { rows: ['nan 0 0', ...Array.from({ length: 16 ** 3 - 1 }, () => '0 0 0')] }),
64
+ ],
65
+ [
66
+ 'out of domain',
67
+ cubeSource(16, { rows: ['2 0 0', ...Array.from({ length: 16 ** 3 - 1 }, () => '0 0 0')] }),
68
+ ],
69
+ ])('rejects %s with structured source diagnostics', (_name, source) => {
70
+ const parsed = parseCubeLut(source, 'fixture/invalid.cube');
71
+ expect(parsed.ok).toBe(false);
72
+ if (parsed.ok) return;
73
+ expect(parsed.error.code).toMatch(/^cube-/);
74
+ expect(parsed.error.expected).toContain('LUT_3D_SIZE');
75
+ expect(parsed.error.hint).toContain('sourceKey');
76
+ expect(parsed.error.detail).toMatchObject({ sourceKey: 'fixture/invalid.cube' });
77
+ });
78
+
79
+ it('keeps the parser type independent from TextureAsset runtime loading', () => {
80
+ const parsed = parseCubeLut(cubeSource(16), 'fixture/runtime-boundary.cube');
81
+ expect(parsed.ok).toBe(true);
82
+ expect(typeof ({} as CubeLut)).toBe('object');
83
+ });
84
+ });
@@ -0,0 +1,94 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { createHash } from 'node:crypto';
3
+ import {
4
+ finalizeImportProducts,
5
+ projectImportProductForBuild,
6
+ textureAssetOutputProducer,
7
+ } from '@forgeax/engine-import';
8
+ import { describe, expect, it } from 'vitest';
9
+ import { produceCubeTexture } from '../lut/cube-producer.js';
10
+
11
+ const GUID = '019ffa97-3000-7000-8000-000000000901';
12
+ const SOURCE_KEY = 'color-grading/neutral';
13
+
14
+ function cubeSource(size = 16): string {
15
+ const rows = Array.from({ length: size ** 3 }, () => '0 0.5 1');
16
+ return [
17
+ 'TITLE "ForgeaX neutral LUT"',
18
+ `LUT_3D_SIZE ${size}`,
19
+ 'DOMAIN_MIN 0 0 0',
20
+ 'DOMAIN_MAX 1 1 1',
21
+ ...rows,
22
+ '',
23
+ ].join('\n');
24
+ }
25
+
26
+ function digest(bytes: Uint8Array): string {
27
+ return `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
28
+ }
29
+
30
+ describe('cube texture producer identity', () => {
31
+ it('keeps ordinary texture shape, bytes, and Pack artifact identity closed', async () => {
32
+ const produced = produceCubeTexture(
33
+ { guid: GUID, source: 'assets/neutral.cube', sourceKey: SOURCE_KEY },
34
+ cubeSource(),
35
+ );
36
+ expect(produced.ok).toBe(true);
37
+ if (!produced.ok) return;
38
+
39
+ const asset = produced.value;
40
+ expect(asset).toMatchObject({ guid: GUID, kind: 'texture' });
41
+ expect(asset.payload.kind).toBe('texture');
42
+ expect(asset.payload.shape).toEqual({
43
+ viewDimension: '3d',
44
+ extent: { width: 16, height: 16, depth: 16 },
45
+ });
46
+ expect(asset.payload.format).toBe('rgba16float');
47
+ expect(asset.payload.colorSpace).toBe('linear');
48
+ expect(asset.payload.mips).toEqual({ kind: 'none' });
49
+
50
+ const standard = await textureAssetOutputProducer.produce({
51
+ guid: GUID,
52
+ sourceKey: SOURCE_KEY,
53
+ asset: asset.payload,
54
+ });
55
+ expect(standard.ok).toBe(true);
56
+ if (!standard.ok) return;
57
+ const producedBody = asset.artifacts.body;
58
+ const standardBody = standard.value.artifacts.body;
59
+ expect(producedBody).toBeDefined();
60
+ expect(standardBody).toBeDefined();
61
+ if (producedBody === undefined || standardBody === undefined) return;
62
+ expect(standardBody.mediaType).toBe(producedBody.mediaType);
63
+ expect(standardBody.assetCodec).toEqual(producedBody.assetCodec);
64
+ expect(Buffer.from(standardBody.bytes).equals(Buffer.from(producedBody.bytes))).toBe(true);
65
+
66
+ const pack = projectImportProductForBuild({
67
+ assets: [{ ...asset, artifacts: standard.value.artifacts }],
68
+ });
69
+ expect(pack.assets).toHaveLength(1);
70
+ expect(pack.assets[0]).toMatchObject({ guid: GUID, kind: 'texture' });
71
+ expect(pack.assets[0]?.payload.kind).toBe('texture');
72
+ expect(pack.assets[0]?.payload.shape).toEqual({
73
+ viewDimension: '3d',
74
+ extent: { width: 16, height: 16, depth: 16 },
75
+ });
76
+ expect(pack.assets[0]?.payload.format).toBe('rgba16float');
77
+ const products = await finalizeImportProducts(
78
+ {
79
+ assets: [{ ...asset, artifacts: standard.value.artifacts }],
80
+ sourceDependencies: ['assets/neutral.cube'],
81
+ },
82
+ 'sha256:source-neutral',
83
+ );
84
+ expect(products[0]).toMatchObject({
85
+ guid: GUID,
86
+ digest: expect.stringMatching(/^sha256:/),
87
+ receipt: { guid: GUID, inputFingerprint: 'sha256:source-neutral', status: 'succeeded' },
88
+ });
89
+ expect(products[0]?.artifacts.body?.integrity).toEqual({
90
+ algorithm: 'sha256',
91
+ digest: digest(asset.artifacts.body?.bytes ?? new Uint8Array()),
92
+ });
93
+ });
94
+ });
@@ -0,0 +1,52 @@
1
+ import type { ImportContext } from '@forgeax/engine-types';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { importCubeSource } from '../lut/cube-producer.js';
4
+
5
+ const GUID = '019ffa97-3000-7000-8000-000000000902';
6
+ const SOURCE_KEY = 'color-grading/recovery';
7
+
8
+ function context(source: string, bytes: Uint8Array): ImportContext {
9
+ return {
10
+ source,
11
+ subAssets: [{ guid: GUID, sourceIndex: 0, kind: 'texture' as const, sourceKey: SOURCE_KEY }],
12
+ importSettings: {},
13
+ readSource: async () => ({ ok: true as const, value: bytes }),
14
+ readSibling: async () => {
15
+ throw new Error('not used');
16
+ },
17
+ decodeImage: async () => {
18
+ throw new Error('not used');
19
+ },
20
+ };
21
+ }
22
+
23
+ function cubeSource(): string {
24
+ const rows = Array.from({ length: 16 ** 3 }, () => '0.5 0.5 0.5');
25
+ return ['LUT_3D_SIZE 16', 'DOMAIN_MIN 0 0 0', 'DOMAIN_MAX 1 1 1', ...rows, ''].join('\n');
26
+ }
27
+
28
+ describe('cube source recovery', () => {
29
+ it('rejects invalid source before Pack and retries the same identity after repair', async () => {
30
+ const source = 'assets/recovery.cube';
31
+ const invalid = await importCubeSource(
32
+ context(source, new TextEncoder().encode('LUT_3D_SIZE 16')),
33
+ );
34
+ expect(invalid.ok).toBe(false);
35
+ if (invalid.ok) return;
36
+ expect(invalid.error.code).toBe('source-validation-failed');
37
+ expect(JSON.stringify(invalid.error.detail)).toContain(`${source}#DOMAIN_MIN/MAX`);
38
+ expect(JSON.stringify(invalid.error.detail)).toContain('cube-source-parser');
39
+
40
+ const repaired = await importCubeSource(
41
+ context(source, new TextEncoder().encode(cubeSource())),
42
+ );
43
+ expect(repaired.ok).toBe(true);
44
+ if (!repaired.ok) return;
45
+ expect(repaired.value.sourceDependencies).toEqual([source]);
46
+ expect(repaired.value.assets[0]).toMatchObject({ guid: GUID, kind: 'texture' });
47
+ expect(repaired.value.assets[0]?.payload).toMatchObject({
48
+ shape: { viewDimension: '3d', extent: { width: 16, height: 16, depth: 16 } },
49
+ format: 'rgba16float',
50
+ });
51
+ });
52
+ });
@@ -26,6 +26,24 @@ function readSourceFor(bytes: Uint8Array): ImportContext['readSource'] {
26
26
  }
27
27
 
28
28
  describe('image importer required output topology', () => {
29
+ it('reserves .cube sources for the ordinary texture producer topology', async () => {
30
+ const registry = imageRegistry();
31
+ const result = await runImport(
32
+ imageMeta('grading.cube', [
33
+ { guid: PNG_GUID, sourceIndex: 0, sourceKey: 'grading/lut', kind: 'texture' },
34
+ ]),
35
+ registry,
36
+ { readSource: readSourceFor(new TextEncoder().encode(cubeFixture(16))) },
37
+ );
38
+ expect(result.ok).toBe(true);
39
+ if (!result.ok || 'skipped' in result.value) return;
40
+ expect(result.value.product.assets[0]).toMatchObject({
41
+ guid: PNG_GUID,
42
+ kind: 'texture',
43
+ payload: { kind: 'texture', shape: { viewDimension: '3d' } },
44
+ });
45
+ });
46
+
29
47
  it.each([
30
48
  { source: 'texture.png', label: 'PNG' },
31
49
  { source: 'texture.jpeg', label: 'JPEG' },
@@ -207,3 +225,12 @@ describe('image importer required output topology', () => {
207
225
  );
208
226
  });
209
227
  });
228
+
229
+ function cubeFixture(size: number): string {
230
+ return [
231
+ `LUT_3D_SIZE ${size}`,
232
+ 'DOMAIN_MIN 0 0 0',
233
+ 'DOMAIN_MAX 1 1 1',
234
+ ...Array.from({ length: size ** 3 }, () => '0 0 0'),
235
+ ].join('\n');
236
+ }
package/src/errors.ts CHANGED
@@ -60,3 +60,38 @@ export function imageError<C extends ImageErrorCode>(
60
60
  }
61
61
 
62
62
  export { IMAGE_ERROR_EXPECTED };
63
+
64
+ export type CubeParserErrorCode =
65
+ | 'cube-header-invalid'
66
+ | 'cube-size-invalid'
67
+ | 'cube-domain-invalid'
68
+ | 'cube-row-invalid'
69
+ | 'cube-data-count-invalid';
70
+
71
+ export interface CubeParserError {
72
+ readonly code: CubeParserErrorCode;
73
+ readonly expected: string;
74
+ readonly hint: string;
75
+ readonly detail: {
76
+ readonly sourceKey: string;
77
+ readonly line?: number;
78
+ readonly field: string;
79
+ readonly actual: unknown;
80
+ };
81
+ }
82
+
83
+ export function cubeParserError(
84
+ code: CubeParserErrorCode,
85
+ sourceKey: string,
86
+ field: string,
87
+ actual: unknown,
88
+ line?: number,
89
+ ): CubeParserError {
90
+ const lineDetail = line === undefined ? '' : ` at line ${line}`;
91
+ return {
92
+ code,
93
+ expected: `valid .cube source with LUT_3D_SIZE, DOMAIN_MIN/MAX, and exactly N^3 RGB rows${lineDetail}`,
94
+ hint: `repair field ${field} in sourceKey ${sourceKey} and re-run the Node image producer`,
95
+ detail: { sourceKey, field, actual, ...(line === undefined ? {} : { line }) },
96
+ };
97
+ }
@@ -51,6 +51,7 @@ import type {
51
51
  import { IMPORT_ERROR_HINTS, ImportError } from '@forgeax/engine-types';
52
52
  import type { CompressionMode } from './ktx2-encode.js';
53
53
  import { encodeTextureToKtx2, resolveEncodeMode } from './ktx2-encode.js';
54
+ import { importCubeSource } from './lut/cube-producer.js';
54
55
  import { parseImage } from './parse-image.js';
55
56
  import { importTextureSource } from './texture/importer.js';
56
57
 
@@ -67,7 +68,12 @@ type RequiredImageOutputKind = 'texture' | 'equirect';
67
68
  function requiredImageOutputKind(source: string): RequiredImageOutputKind | undefined {
68
69
  const lower = source.toLowerCase();
69
70
  if (lower.endsWith('.hdr')) return 'equirect';
70
- if (mimeFromSource(source) !== undefined || lower.endsWith('.basis') || lower.endsWith('.ktx2')) {
71
+ if (
72
+ mimeFromSource(source) !== undefined ||
73
+ lower.endsWith('.basis') ||
74
+ lower.endsWith('.ktx2') ||
75
+ lower.endsWith('.cube')
76
+ ) {
71
77
  return 'texture';
72
78
  }
73
79
  return undefined;
@@ -590,6 +596,9 @@ async function importImage(ctx: ImportContext): Promise<ImportResult> {
590
596
  if (ctx.source.toLowerCase().endsWith('.texture.json')) {
591
597
  return importTextureSource(ctx);
592
598
  }
599
+ if (ctx.source.toLowerCase().endsWith('.cube')) {
600
+ return importCubeSource(ctx);
601
+ }
593
602
  const requiredKind = requiredImageOutputKind(ctx.source);
594
603
  if (requiredKind !== undefined) {
595
604
  const topologyError = validateImageOutputTopology(ctx, requiredKind);
@@ -870,3 +879,6 @@ export const imageImporter: Importer = {
870
879
  import: importImage,
871
880
  capabilities: { decodeImage: decodeImageForImport },
872
881
  };
882
+
883
+ export { cubeLutBytes, parseCubeLut } from './lut/cube-parser.js';
884
+ export { importCubeSource, produceCubeTexture } from './lut/cube-producer.js';
@@ -0,0 +1,185 @@
1
+ import type { CubeParserError } from '../errors.js';
2
+ import { cubeParserError } from '../errors.js';
3
+
4
+ export interface CubeLut {
5
+ readonly size: 16 | 32 | 64;
6
+ readonly domainMin: readonly [number, number, number];
7
+ readonly domainMax: readonly [number, number, number];
8
+ readonly values: Float32Array;
9
+ readonly sourceKey: string;
10
+ }
11
+
12
+ export type CubeLutResult =
13
+ | { readonly ok: true; readonly value: CubeLut }
14
+ | { readonly ok: false; readonly error: CubeParserError };
15
+
16
+ const ALLOWED_SIZES = new Set([16, 32, 64]);
17
+
18
+ function finite(value: number): boolean {
19
+ return Number.isFinite(value);
20
+ }
21
+
22
+ function parseTriple(tokens: readonly string[]): readonly [number, number, number] | undefined {
23
+ if (tokens.length !== 3) return undefined;
24
+ const values = tokens.map(Number);
25
+ if (!values.every(finite)) return undefined;
26
+ const [red, green, blue] = values;
27
+ if (red === undefined || green === undefined || blue === undefined) return undefined;
28
+ return [red, green, blue];
29
+ }
30
+
31
+ function float32ToFloat16(value: number): number {
32
+ const input = new Float32Array([value]);
33
+ const bits = new Uint32Array(input.buffer).at(0) ?? 0;
34
+ const sign = (bits >>> 16) & 0x8000;
35
+ const exponent = (bits >>> 23) & 0xff;
36
+ const fraction = bits & 0x7fffff;
37
+ if (exponent === 0xff) return sign | (fraction === 0 ? 0x7c00 : 0x7e00);
38
+ const halfExponent = exponent - 127 + 15;
39
+ if (halfExponent >= 0x1f) return sign | 0x7c00;
40
+ if (halfExponent <= 0) {
41
+ if (halfExponent < -10) return sign;
42
+ const mantissa = (fraction | 0x800000) >>> (1 - halfExponent);
43
+ return sign | ((mantissa + 0x1000) >>> 13);
44
+ }
45
+ return sign | (halfExponent << 10) | ((fraction + 0x1000) >>> 13);
46
+ }
47
+
48
+ export function parseCubeLut(source: string, sourceKey = '<inline .cube>'): CubeLutResult {
49
+ let size: number | undefined;
50
+ let domainMin: readonly [number, number, number] | undefined;
51
+ let domainMax: readonly [number, number, number] | undefined;
52
+ const values: number[] = [];
53
+ const lines = source.split(/\r?\n/);
54
+
55
+ for (const [index, rawLine] of lines.entries()) {
56
+ const line = rawLine.replace(/#.*/, '').trim();
57
+ const lineNumber = index + 1;
58
+ if (line.length === 0) continue;
59
+ const tokens = line.split(/\s+/);
60
+ const directive = tokens.shift();
61
+ if (directive === undefined) continue;
62
+ if (directive === 'TITLE') continue;
63
+ if (directive === 'LUT_3D_SIZE') {
64
+ const sizeToken = tokens[0];
65
+ if (
66
+ size !== undefined ||
67
+ tokens.length !== 1 ||
68
+ sizeToken === undefined ||
69
+ !/^\d+$/.test(sizeToken)
70
+ ) {
71
+ return {
72
+ ok: false,
73
+ error: cubeParserError('cube-size-invalid', sourceKey, 'LUT_3D_SIZE', line, lineNumber),
74
+ };
75
+ }
76
+ size = Number(sizeToken);
77
+ if (!ALLOWED_SIZES.has(size)) {
78
+ return {
79
+ ok: false,
80
+ error: cubeParserError('cube-size-invalid', sourceKey, 'LUT_3D_SIZE', size, lineNumber),
81
+ };
82
+ }
83
+ continue;
84
+ }
85
+ if (directive === 'DOMAIN_MIN' || directive === 'DOMAIN_MAX') {
86
+ const triple = parseTriple(tokens);
87
+ if (triple === undefined) {
88
+ return {
89
+ ok: false,
90
+ error: cubeParserError('cube-domain-invalid', sourceKey, directive, line, lineNumber),
91
+ };
92
+ }
93
+ if (directive === 'DOMAIN_MIN') {
94
+ if (domainMin !== undefined)
95
+ return {
96
+ ok: false,
97
+ error: cubeParserError('cube-domain-invalid', sourceKey, directive, line, lineNumber),
98
+ };
99
+ domainMin = triple;
100
+ } else {
101
+ if (domainMax !== undefined)
102
+ return {
103
+ ok: false,
104
+ error: cubeParserError('cube-domain-invalid', sourceKey, directive, line, lineNumber),
105
+ };
106
+ domainMax = triple;
107
+ }
108
+ continue;
109
+ }
110
+ if (directive === 'LUT_1D_SIZE') {
111
+ return {
112
+ ok: false,
113
+ error: cubeParserError('cube-header-invalid', sourceKey, directive, line, lineNumber),
114
+ };
115
+ }
116
+ const row = parseTriple([directive, ...tokens]);
117
+ if (row === undefined || row.some((value) => value < 0 || value > 1)) {
118
+ return {
119
+ ok: false,
120
+ error: cubeParserError('cube-row-invalid', sourceKey, 'RGB row', line, lineNumber),
121
+ };
122
+ }
123
+ values.push(...row);
124
+ }
125
+
126
+ if (size === undefined)
127
+ return {
128
+ ok: false,
129
+ error: cubeParserError('cube-header-invalid', sourceKey, 'LUT_3D_SIZE', 'missing'),
130
+ };
131
+ if (domainMin === undefined || domainMax === undefined) {
132
+ return {
133
+ ok: false,
134
+ error: cubeParserError('cube-domain-invalid', sourceKey, 'DOMAIN_MIN/MAX', 'missing'),
135
+ };
136
+ }
137
+ if (
138
+ domainMin.some((value, index) => {
139
+ const maxValue = domainMax[index];
140
+ return maxValue !== undefined && value >= maxValue;
141
+ })
142
+ ) {
143
+ return {
144
+ ok: false,
145
+ error: cubeParserError('cube-domain-invalid', sourceKey, 'DOMAIN_MIN/MAX', {
146
+ domainMin,
147
+ domainMax,
148
+ }),
149
+ };
150
+ }
151
+ const expectedValues = size ** 3 * 3;
152
+ if (values.length !== expectedValues) {
153
+ return {
154
+ ok: false,
155
+ error: cubeParserError('cube-data-count-invalid', sourceKey, 'RGB rows', {
156
+ expected: expectedValues / 3,
157
+ actual: values.length / 3,
158
+ }),
159
+ };
160
+ }
161
+ return {
162
+ ok: true,
163
+ value: {
164
+ size: size as 16 | 32 | 64,
165
+ domainMin,
166
+ domainMax,
167
+ values: new Float32Array(values),
168
+ sourceKey,
169
+ },
170
+ };
171
+ }
172
+
173
+ export function cubeLutBytes(lut: CubeLut): Uint8Array {
174
+ const bytes = new Uint8Array(lut.size ** 3 * 4 * 2);
175
+ const view = new DataView(bytes.buffer);
176
+ for (let index = 0; index < lut.size ** 3; index += 1) {
177
+ const valueOffset = index * 3;
178
+ const byteOffset = index * 8;
179
+ view.setUint16(byteOffset, float32ToFloat16(lut.values[valueOffset] ?? 0), true);
180
+ view.setUint16(byteOffset + 2, float32ToFloat16(lut.values[valueOffset + 1] ?? 0), true);
181
+ view.setUint16(byteOffset + 4, float32ToFloat16(lut.values[valueOffset + 2] ?? 0), true);
182
+ view.setUint16(byteOffset + 6, 0x3c00, true);
183
+ }
184
+ return bytes;
185
+ }
@@ -0,0 +1,135 @@
1
+ import type {
2
+ ImportContext,
3
+ ImportedAsset,
4
+ ImportResult,
5
+ TextureAsset,
6
+ } from '@forgeax/engine-types';
7
+ import { IMPORT_ERROR_HINTS, ImportError } from '@forgeax/engine-types';
8
+ import type { CubeParserError } from '../errors.js';
9
+ import { cubeLutBytes, parseCubeLut } from './cube-parser.js';
10
+
11
+ export interface CubeTextureProducerInput {
12
+ readonly source: string;
13
+ readonly sourceKey: string;
14
+ readonly guid: string;
15
+ }
16
+
17
+ export type CubeTextureProducerResult =
18
+ | { readonly ok: true; readonly value: ImportedAsset<TextureAsset> }
19
+ | { readonly ok: false; readonly error: CubeParserError };
20
+
21
+ export function produceCubeTexture(
22
+ input: CubeTextureProducerInput,
23
+ source: string,
24
+ ): CubeTextureProducerResult {
25
+ const parsed = parseCubeLut(source, input.sourceKey);
26
+ if (!parsed.ok) return parsed;
27
+ const bytes = cubeLutBytes(parsed.value);
28
+ return {
29
+ ok: true,
30
+ value: {
31
+ guid: input.guid,
32
+ kind: 'texture',
33
+ payload: {
34
+ kind: 'texture',
35
+ shape: {
36
+ viewDimension: '3d',
37
+ extent: { width: parsed.value.size, height: parsed.value.size, depth: parsed.value.size },
38
+ },
39
+ format: 'rgba16float',
40
+ data: bytes,
41
+ colorSpace: 'linear',
42
+ mips: { kind: 'none' },
43
+ },
44
+ refs: [],
45
+ artifacts: {
46
+ body: {
47
+ mediaType: 'application/x-forgeax-rgba16float',
48
+ assetCodec: { name: 'rgba16float', version: '1' },
49
+ bytes,
50
+ },
51
+ },
52
+ },
53
+ };
54
+ }
55
+
56
+ export async function importCubeSource(ctx: ImportContext): Promise<ImportResult> {
57
+ const subAsset = ctx.subAssets.length === 1 ? ctx.subAssets[0] : undefined;
58
+ if (subAsset === undefined || subAsset.kind !== 'texture' || subAsset.sourceIndex !== 0) {
59
+ return {
60
+ ok: false,
61
+ error: new ImportError({
62
+ code: 'source-validation-failed',
63
+ expected: 'one texture subAsset at sourceIndex 0',
64
+ actual: JSON.stringify(ctx.subAssets),
65
+ hint: IMPORT_ERROR_HINTS['source-validation-failed'],
66
+ detail: {
67
+ diagnostics: [
68
+ {
69
+ code: 'cube-subasset-topology',
70
+ severity: 'error',
71
+ sourcePath: `${ctx.source}#subAssets`,
72
+ sourceRange: { start: 0, end: 0, line: 1, column: 1 },
73
+ rule: 'cube-required-single-texture',
74
+ expected: 'one texture subAsset at sourceIndex 0',
75
+ actual: JSON.stringify(ctx.subAssets),
76
+ hint: 'declare one ordinary texture subAsset and retry the same sourceKey',
77
+ },
78
+ ],
79
+ },
80
+ }),
81
+ };
82
+ }
83
+ const source = await ctx.readSource();
84
+ if (!source.ok) {
85
+ return {
86
+ ok: false,
87
+ error: new ImportError({
88
+ code: 'source-read-failed',
89
+ expected: `readable .cube source at "${ctx.source}"`,
90
+ actual: String(source.error),
91
+ hint: IMPORT_ERROR_HINTS['source-read-failed'],
92
+ detail: { source: ctx.source, reason: String(source.error) },
93
+ }),
94
+ };
95
+ }
96
+ const produced = produceCubeTexture(
97
+ {
98
+ source: ctx.source,
99
+ sourceKey: subAsset.sourceKey ?? `${ctx.source}:texture`,
100
+ guid: subAsset.guid,
101
+ },
102
+ new TextDecoder().decode(source.value),
103
+ );
104
+ if (!produced.ok) {
105
+ return {
106
+ ok: false,
107
+ error: new ImportError({
108
+ code: 'source-validation-failed',
109
+ expected: produced.error.expected,
110
+ actual: JSON.stringify(produced.error.detail),
111
+ hint: produced.error.hint,
112
+ detail: {
113
+ diagnostics: [
114
+ {
115
+ code: produced.error.code,
116
+ severity: 'error',
117
+ sourcePath: `${ctx.source}#${produced.error.detail.field}`,
118
+ sourceRange: {
119
+ start: 0,
120
+ end: 0,
121
+ line: produced.error.detail.line ?? 1,
122
+ column: 1,
123
+ },
124
+ rule: 'cube-source-parser',
125
+ expected: produced.error.expected,
126
+ actual: JSON.stringify(produced.error.detail.actual),
127
+ hint: produced.error.hint,
128
+ },
129
+ ],
130
+ },
131
+ }),
132
+ };
133
+ }
134
+ return { ok: true, value: { assets: [produced.value], sourceDependencies: [ctx.source] } };
135
+ }