@forgeax/engine-pack 0.0.0-dev.8d955ade1c79
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +429 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/ai-recovery.unit.test.d.ts +2 -0
- package/dist/__tests__/ai-recovery.unit.test.d.ts.map +1 -0
- package/dist/__tests__/artifact-path.test.d.ts +2 -0
- package/dist/__tests__/artifact-path.test.d.ts.map +1 -0
- package/dist/__tests__/asset-evidence-error-hints.test.d.ts +2 -0
- package/dist/__tests__/asset-evidence-error-hints.test.d.ts.map +1 -0
- package/dist/__tests__/asset-evidence-lifecycle.test.d.ts +2 -0
- package/dist/__tests__/asset-evidence-lifecycle.test.d.ts.map +1 -0
- package/dist/__tests__/asset-evidence-schema.test.d.ts +2 -0
- package/dist/__tests__/asset-evidence-schema.test.d.ts.map +1 -0
- package/dist/__tests__/atlas-cli-region-mismatch.test.d.ts +2 -0
- package/dist/__tests__/atlas-cli-region-mismatch.test.d.ts.map +1 -0
- package/dist/__tests__/atlas-error-code-owner.test-d.d.ts +2 -0
- package/dist/__tests__/atlas-error-code-owner.test-d.d.ts.map +1 -0
- package/dist/__tests__/cli-asset-evidence.integration.test.d.ts +2 -0
- package/dist/__tests__/cli-asset-evidence.integration.test.d.ts.map +1 -0
- package/dist/__tests__/cli.unit.test.d.ts +2 -0
- package/dist/__tests__/cli.unit.test.d.ts.map +1 -0
- package/dist/__tests__/evidence-source-inventory.unit.test.d.ts +2 -0
- package/dist/__tests__/evidence-source-inventory.unit.test.d.ts.map +1 -0
- package/dist/__tests__/guid-collision.unit.test.d.ts +2 -0
- package/dist/__tests__/guid-collision.unit.test.d.ts.map +1 -0
- package/dist/__tests__/guid-validator-surface.unit.test.d.ts +2 -0
- package/dist/__tests__/guid-validator-surface.unit.test.d.ts.map +1 -0
- package/dist/__tests__/load-asset-config.test.d.ts +2 -0
- package/dist/__tests__/load-asset-config.test.d.ts.map +1 -0
- package/dist/__tests__/material-cook-dependencies.unit.test.d.ts +2 -0
- package/dist/__tests__/material-cook-dependencies.unit.test.d.ts.map +1 -0
- package/dist/__tests__/material-cook-receipt.unit.test.d.ts +2 -0
- package/dist/__tests__/material-cook-receipt.unit.test.d.ts.map +1 -0
- package/dist/__tests__/material-cook-schema.unit.test.d.ts +2 -0
- package/dist/__tests__/material-cook-schema.unit.test.d.ts.map +1 -0
- package/dist/__tests__/mesh-bin-consumer-surface.unit.test.d.ts +2 -0
- package/dist/__tests__/mesh-bin-consumer-surface.unit.test.d.ts.map +1 -0
- package/dist/__tests__/mesh-bin-contract.unit.test.d.ts +2 -0
- package/dist/__tests__/mesh-bin-contract.unit.test.d.ts.map +1 -0
- package/dist/__tests__/meta-schema-optional.test.d.ts +2 -0
- package/dist/__tests__/meta-schema-optional.test.d.ts.map +1 -0
- package/dist/__tests__/meta-source-overrides.unit.test.d.ts +2 -0
- package/dist/__tests__/meta-source-overrides.unit.test.d.ts.map +1 -0
- package/dist/__tests__/native-cooker-registry.test.d.ts +2 -0
- package/dist/__tests__/native-cooker-registry.test.d.ts.map +1 -0
- package/dist/__tests__/offline-evidence-artifact-verification-owner.test-d.d.ts +2 -0
- package/dist/__tests__/offline-evidence-artifact-verification-owner.test-d.d.ts.map +1 -0
- package/dist/__tests__/offline-evidence.integration.test.d.ts +2 -0
- package/dist/__tests__/offline-evidence.integration.test.d.ts.map +1 -0
- package/dist/__tests__/owner-chain.integration.test.d.ts +2 -0
- package/dist/__tests__/owner-chain.integration.test.d.ts.map +1 -0
- package/dist/__tests__/pack-v2-fixture-migration.test.d.ts +2 -0
- package/dist/__tests__/pack-v2-fixture-migration.test.d.ts.map +1 -0
- package/dist/__tests__/pack-v2-schema.test.d.ts +2 -0
- package/dist/__tests__/pack-v2-schema.test.d.ts.map +1 -0
- package/dist/__tests__/pack.unit.test.d.ts +2 -0
- package/dist/__tests__/pack.unit.test.d.ts.map +1 -0
- package/dist/__tests__/package-finalizer.contract.test.d.ts +2 -0
- package/dist/__tests__/package-finalizer.contract.test.d.ts.map +1 -0
- package/dist/__tests__/producer-contract-schema.unit.test.d.ts +2 -0
- package/dist/__tests__/producer-contract-schema.unit.test.d.ts.map +1 -0
- package/dist/__tests__/resolve-asset-source.test.d.ts +2 -0
- package/dist/__tests__/resolve-asset-source.test.d.ts.map +1 -0
- package/dist/__tests__/runtime-projection.unit.test.d.ts +2 -0
- package/dist/__tests__/runtime-projection.unit.test.d.ts.map +1 -0
- package/dist/__tests__/runtime.browser.test.d.ts +2 -0
- package/dist/__tests__/runtime.browser.test.d.ts.map +1 -0
- package/dist/__tests__/scanner-inventory.contract.test.d.ts +2 -0
- package/dist/__tests__/scanner-inventory.contract.test.d.ts.map +1 -0
- package/dist/__tests__/scanner-inventory.test.d.ts +2 -0
- package/dist/__tests__/scanner-inventory.test.d.ts.map +1 -0
- package/dist/__tests__/scanner-mount-cycle.test.d.ts +2 -0
- package/dist/__tests__/scanner-mount-cycle.test.d.ts.map +1 -0
- package/dist/__tests__/scriptable-pack-cli.integration.test.d.ts +2 -0
- package/dist/__tests__/scriptable-pack-cli.integration.test.d.ts.map +1 -0
- package/dist/__tests__/scriptable-pack-ddc-key.unit.test.d.ts +2 -0
- package/dist/__tests__/scriptable-pack-ddc-key.unit.test.d.ts.map +1 -0
- package/dist/__tests__/scriptable-pack.test-d.d.ts +2 -0
- package/dist/__tests__/scriptable-pack.test-d.d.ts.map +1 -0
- package/dist/__tests__/scriptable-pack.unit.test.d.ts +2 -0
- package/dist/__tests__/scriptable-pack.unit.test.d.ts.map +1 -0
- package/dist/__tests__/topology-actions.unit.test.d.ts +2 -0
- package/dist/__tests__/topology-actions.unit.test.d.ts.map +1 -0
- package/dist/__tests__/topology.unit.test.d.ts +2 -0
- package/dist/__tests__/topology.unit.test.d.ts.map +1 -0
- package/dist/artifact-path.d.ts +9 -0
- package/dist/artifact-path.d.ts.map +1 -0
- package/dist/artifact-path.mjs +53 -0
- package/dist/artifact-path.mjs.map +1 -0
- package/dist/atlas/run-atlas.d.ts +8 -0
- package/dist/atlas/run-atlas.d.ts.map +1 -0
- package/dist/atlas/shelf-pack.d.ts +55 -0
- package/dist/atlas/shelf-pack.d.ts.map +1 -0
- package/dist/bridge.d.ts +2 -0
- package/dist/bridge.d.ts.map +1 -0
- package/dist/bridge.mjs +3 -0
- package/dist/bridge.mjs.map +1 -0
- package/dist/build.d.ts +19 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.mjs +10203 -0
- package/dist/build.mjs.map +1 -0
- package/dist/builtin.d.ts +44 -0
- package/dist/builtin.d.ts.map +1 -0
- package/dist/builtin.mjs +141 -0
- package/dist/builtin.mjs.map +1 -0
- package/dist/catalog-builder.d.ts +43 -0
- package/dist/catalog-builder.d.ts.map +1 -0
- package/dist/catalog-delta.d.ts +11 -0
- package/dist/catalog-delta.d.ts.map +1 -0
- package/dist/catalog-projection.d.ts +91 -0
- package/dist/catalog-projection.d.ts.map +1 -0
- package/dist/cli-asset.d.ts +22 -0
- package/dist/cli-asset.d.ts.map +1 -0
- package/dist/cli-asset.mjs +9697 -0
- package/dist/cli-asset.mjs.map +1 -0
- package/dist/config.d.ts +6 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.mjs +29 -0
- package/dist/config.mjs.map +1 -0
- package/dist/deriveAssetName.d.ts +15 -0
- package/dist/deriveAssetName.d.ts.map +1 -0
- package/dist/errors.d.ts +30 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.mjs +19 -0
- package/dist/errors.mjs.map +1 -0
- package/dist/evidence/material-cook.d.ts +92 -0
- package/dist/evidence/material-cook.d.ts.map +1 -0
- package/dist/evidence/offline-evidence.d.ts +25 -0
- package/dist/evidence/offline-evidence.d.ts.map +1 -0
- package/dist/evidence/source-inventory.d.ts +29 -0
- package/dist/evidence/source-inventory.d.ts.map +1 -0
- package/dist/guid.d.ts +45 -0
- package/dist/guid.d.ts.map +1 -0
- package/dist/guid.mjs +94 -0
- package/dist/guid.mjs.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +8612 -0
- package/dist/index.mjs.map +1 -0
- package/dist/material/artifact-writer.d.ts +14 -0
- package/dist/material/artifact-writer.d.ts.map +1 -0
- package/dist/material-cook.d.ts +2 -0
- package/dist/material-cook.d.ts.map +1 -0
- package/dist/material-cook.mjs +248 -0
- package/dist/material-cook.mjs.map +1 -0
- package/dist/mesh-bin-contract.d.ts +36 -0
- package/dist/mesh-bin-contract.d.ts.map +1 -0
- package/dist/mesh-bin-contract.mjs +96 -0
- package/dist/mesh-bin-contract.mjs.map +1 -0
- package/dist/name.mjs +22 -0
- package/dist/name.mjs.map +1 -0
- package/dist/native-cooker-registry.d.ts +47 -0
- package/dist/native-cooker-registry.d.ts.map +1 -0
- package/dist/native-cooker.d.ts +3 -0
- package/dist/native-cooker.d.ts.map +1 -0
- package/dist/native-cooker.mjs +142 -0
- package/dist/native-cooker.mjs.map +1 -0
- package/dist/package-finalizer.d.ts +121 -0
- package/dist/package-finalizer.d.ts.map +1 -0
- package/dist/producer-contract.d.ts +19 -0
- package/dist/producer-contract.d.ts.map +1 -0
- package/dist/resolve-asset-source.d.ts +4 -0
- package/dist/resolve-asset-source.d.ts.map +1 -0
- package/dist/resolve-asset-source.mjs +109 -0
- package/dist/resolve-asset-source.mjs.map +1 -0
- package/dist/runtime-projection.d.ts +14 -0
- package/dist/runtime-projection.d.ts.map +1 -0
- package/dist/runtime-publication.d.ts +44 -0
- package/dist/runtime-publication.d.ts.map +1 -0
- package/dist/runtime.d.ts +8 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.mjs +7261 -0
- package/dist/runtime.mjs.map +1 -0
- package/dist/scanner.d.ts +113 -0
- package/dist/scanner.d.ts.map +1 -0
- package/dist/scanner.mjs +8819 -0
- package/dist/scanner.mjs.map +1 -0
- package/dist/schema-compiled.d.ts +12 -0
- package/dist/schema-compiled.d.ts.map +1 -0
- package/dist/schema.mjs +7369 -0
- package/dist/schema.mjs.map +1 -0
- package/dist/scriptable-pack-node.d.ts +62 -0
- package/dist/scriptable-pack-node.d.ts.map +1 -0
- package/dist/scriptable-pack-node.mjs +1674 -0
- package/dist/scriptable-pack-node.mjs.map +1 -0
- package/dist/scriptable-pack-worker.d.ts +2 -0
- package/dist/scriptable-pack-worker.d.ts.map +1 -0
- package/dist/scriptable-pack-worker.mjs +224 -0
- package/dist/scriptable-pack-worker.mjs.map +1 -0
- package/dist/scriptable-pack.d.ts +558 -0
- package/dist/scriptable-pack.d.ts.map +1 -0
- package/dist/scriptable-pack.mjs +672 -0
- package/dist/scriptable-pack.mjs.map +1 -0
- package/dist/topology.d.ts +10 -0
- package/dist/topology.d.ts.map +1 -0
- package/package.json +154 -0
- package/schema/meta.schema.json +173 -0
- package/schema/pack.schema.json +232 -0
- package/src/__tests__/ai-recovery.unit.test.ts +114 -0
- package/src/__tests__/artifact-path.test.ts +41 -0
- package/src/__tests__/asset-evidence-error-hints.test.ts +46 -0
- package/src/__tests__/asset-evidence-lifecycle.test.ts +104 -0
- package/src/__tests__/asset-evidence-schema.test.ts +86 -0
- package/src/__tests__/atlas-cli-region-mismatch.test.ts +134 -0
- package/src/__tests__/atlas-error-code-owner.test-d.ts +23 -0
- package/src/__tests__/cli-asset-evidence.integration.test.ts +172 -0
- package/src/__tests__/cli.unit.test.ts +448 -0
- package/src/__tests__/evidence-source-inventory.unit.test.ts +37 -0
- package/src/__tests__/guid-collision.unit.test.ts +112 -0
- package/src/__tests__/guid-validator-surface.unit.test.ts +20 -0
- package/src/__tests__/load-asset-config.test.ts +121 -0
- package/src/__tests__/material-cook-dependencies.unit.test.ts +112 -0
- package/src/__tests__/material-cook-receipt.unit.test.ts +148 -0
- package/src/__tests__/material-cook-schema.unit.test.ts +152 -0
- package/src/__tests__/mesh-bin-consumer-surface.unit.test.ts +22 -0
- package/src/__tests__/mesh-bin-contract.unit.test.ts +32 -0
- package/src/__tests__/meta-schema-optional.test.ts +116 -0
- package/src/__tests__/meta-source-overrides.unit.test.ts +71 -0
- package/src/__tests__/native-cooker-registry.test.ts +64 -0
- package/src/__tests__/offline-evidence-artifact-verification-owner.test-d.ts +64 -0
- package/src/__tests__/offline-evidence.integration.test.ts +106 -0
- package/src/__tests__/owner-chain.integration.test.ts +78 -0
- package/src/__tests__/pack-v2-fixture-migration.test.ts +87 -0
- package/src/__tests__/pack-v2-schema.test.ts +142 -0
- package/src/__tests__/pack.unit.test.ts +2218 -0
- package/src/__tests__/package-finalizer.contract.test.ts +105 -0
- package/src/__tests__/producer-contract-schema.unit.test.ts +92 -0
- package/src/__tests__/resolve-asset-source.test.ts +121 -0
- package/src/__tests__/runtime-projection.unit.test.ts +87 -0
- package/src/__tests__/runtime.browser.test.ts +15 -0
- package/src/__tests__/scanner-inventory.contract.test.ts +167 -0
- package/src/__tests__/scanner-inventory.test.ts +76 -0
- package/src/__tests__/scanner-mount-cycle.test.ts +232 -0
- package/src/__tests__/scriptable-pack-cli.integration.test.ts +47 -0
- package/src/__tests__/scriptable-pack-ddc-key.unit.test.ts +63 -0
- package/src/__tests__/scriptable-pack.test-d.ts +131 -0
- package/src/__tests__/scriptable-pack.unit.test.ts +789 -0
- package/src/__tests__/topology-actions.unit.test.ts +94 -0
- package/src/__tests__/topology.unit.test.ts +85 -0
- package/src/artifact-path.ts +69 -0
- package/src/atlas/run-atlas.ts +358 -0
- package/src/atlas/shelf-pack.ts +172 -0
- package/src/atlas/upng-ambient.d.ts +13 -0
- package/src/bridge.ts +2 -0
- package/src/build.ts +139 -0
- package/src/builtin.ts +117 -0
- package/src/catalog-builder.ts +313 -0
- package/src/catalog-delta.ts +189 -0
- package/src/catalog-projection.ts +344 -0
- package/src/cli-asset.ts +586 -0
- package/src/config.ts +36 -0
- package/src/deriveAssetName.ts +42 -0
- package/src/errors.ts +47 -0
- package/src/evidence/material-cook.ts +411 -0
- package/src/evidence/offline-evidence.ts +90 -0
- package/src/evidence/source-inventory.ts +193 -0
- package/src/guid.ts +99 -0
- package/src/index.ts +108 -0
- package/src/material/artifact-writer.ts +26 -0
- package/src/material-cook.ts +15 -0
- package/src/mesh-bin-contract.ts +150 -0
- package/src/native-cooker-registry.ts +235 -0
- package/src/native-cooker.ts +2 -0
- package/src/package-finalizer.ts +394 -0
- package/src/producer-contract.ts +277 -0
- package/src/resolve-asset-source.ts +88 -0
- package/src/runtime-projection.ts +100 -0
- package/src/runtime-publication.ts +190 -0
- package/src/runtime.ts +23 -0
- package/src/scanner.ts +715 -0
- package/src/schema/material-cook.schema.json +119 -0
- package/src/schema-compiled.ts +238 -0
- package/src/scriptable-pack-node.ts +1394 -0
- package/src/scriptable-pack-worker.ts +308 -0
- package/src/scriptable-pack.ts +1006 -0
- package/src/topology.ts +157 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { validateProducerOutputs } from '../producer-contract.js';
|
|
3
|
+
import { diffTopology } from '../topology.js';
|
|
4
|
+
|
|
5
|
+
describe('topology actions', () => {
|
|
6
|
+
it('rejects sourceIndex-only override declarations', () => {
|
|
7
|
+
const result = validateProducerOutputs([
|
|
8
|
+
{ guid: 'old-a', sourceIndex: 0, kind: 'mesh' },
|
|
9
|
+
{ guid: 'old-b', sourceIndex: 1, kind: 'mesh' },
|
|
10
|
+
]);
|
|
11
|
+
expect(result.ok).toBe(false);
|
|
12
|
+
if (!result.ok) expect(result.error.code).toBe('source-index-ambiguous');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('reports add, remove, and reorder as source-key sets', () => {
|
|
16
|
+
const previous = [
|
|
17
|
+
{ guid: 'old-hero', sourceKey: 'node/hero', sourceIndex: 0, kind: 'mesh' },
|
|
18
|
+
{ guid: 'old-body', sourceKey: 'node/body', sourceIndex: 1, kind: 'mesh' },
|
|
19
|
+
{ guid: 'old-deleted', sourceKey: 'node/deleted', sourceIndex: 2, kind: 'mesh' },
|
|
20
|
+
];
|
|
21
|
+
const next = [
|
|
22
|
+
{ guid: 'new-body', sourceKey: 'node/body', sourceIndex: 0, kind: 'mesh' },
|
|
23
|
+
{ guid: 'new-added', sourceKey: 'node/added', sourceIndex: 1, kind: 'mesh' },
|
|
24
|
+
{ guid: 'new-hero', sourceKey: 'node/hero', sourceIndex: 2, kind: 'mesh' },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const result = diffTopology(previous, next);
|
|
28
|
+
|
|
29
|
+
expect(result.preserved).toEqual([
|
|
30
|
+
{ guid: 'old-hero', oldKey: 'node/hero', newKey: 'node/hero' },
|
|
31
|
+
{ guid: 'old-body', oldKey: 'node/body', newKey: 'node/body' },
|
|
32
|
+
]);
|
|
33
|
+
expect(result.added).toEqual([
|
|
34
|
+
{ guid: 'new-added', sourceKey: 'node/added', sourceIndex: 1, kind: 'mesh' },
|
|
35
|
+
]);
|
|
36
|
+
expect(result.removed).toEqual([
|
|
37
|
+
{ guid: 'old-deleted', sourceKey: 'node/deleted', sourceIndex: 2, kind: 'mesh' },
|
|
38
|
+
]);
|
|
39
|
+
expect(result.changedKind).toEqual([]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('uses remove-add for an incompatible kind change', () => {
|
|
43
|
+
const result = diffTopology(
|
|
44
|
+
[{ guid: 'old', sourceKey: 'output/main', sourceIndex: 0, kind: 'mesh' }],
|
|
45
|
+
[{ guid: 'new', sourceKey: 'output/main', sourceIndex: 0, kind: 'scene' }],
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
expect(result.preserved).toEqual([]);
|
|
49
|
+
expect(result.added).toEqual([
|
|
50
|
+
{ guid: 'new', sourceKey: 'output/main', sourceIndex: 0, kind: 'scene' },
|
|
51
|
+
]);
|
|
52
|
+
expect(result.removed).toEqual([
|
|
53
|
+
{ guid: 'old', sourceKey: 'output/main', sourceIndex: 0, kind: 'mesh' },
|
|
54
|
+
]);
|
|
55
|
+
expect(result.changedKind).toEqual([
|
|
56
|
+
{
|
|
57
|
+
guid: 'old',
|
|
58
|
+
oldKind: 'mesh',
|
|
59
|
+
newKind: 'scene',
|
|
60
|
+
sourceKey: 'output/main',
|
|
61
|
+
action: 'remove-add',
|
|
62
|
+
},
|
|
63
|
+
]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('preserves the GUID only for producer-declared compatible kinds', () => {
|
|
67
|
+
const compatibleNext = {
|
|
68
|
+
guid: 'new',
|
|
69
|
+
sourceKey: 'output/main',
|
|
70
|
+
sourceIndex: 0,
|
|
71
|
+
kind: 'scene',
|
|
72
|
+
compatiblePreviousKinds: ['mesh'],
|
|
73
|
+
};
|
|
74
|
+
const result = diffTopology(
|
|
75
|
+
[{ guid: 'old', sourceKey: 'output/main', sourceIndex: 0, kind: 'mesh' }],
|
|
76
|
+
[compatibleNext],
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
expect(result.preserved).toEqual([
|
|
80
|
+
{ guid: 'old', oldKey: 'output/main', newKey: 'output/main' },
|
|
81
|
+
]);
|
|
82
|
+
expect(result.added).toEqual([]);
|
|
83
|
+
expect(result.removed).toEqual([]);
|
|
84
|
+
expect(result.changedKind).toEqual([
|
|
85
|
+
{
|
|
86
|
+
guid: 'old',
|
|
87
|
+
oldKind: 'mesh',
|
|
88
|
+
newKind: 'scene',
|
|
89
|
+
sourceKey: 'output/main',
|
|
90
|
+
action: 'preserve-guid',
|
|
91
|
+
},
|
|
92
|
+
]);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { diffTopology } from '../topology.js';
|
|
3
|
+
|
|
4
|
+
describe('diffTopology', () => {
|
|
5
|
+
it('preserves GUID identity when keyed outputs reorder', () => {
|
|
6
|
+
const previous = [
|
|
7
|
+
{ guid: 'old-hero', sourceKey: 'node/hero', sourceIndex: 0, kind: 'mesh' },
|
|
8
|
+
{ guid: 'old-body', sourceKey: 'node/body', sourceIndex: 1, kind: 'mesh' },
|
|
9
|
+
];
|
|
10
|
+
const next = [
|
|
11
|
+
{ guid: 'new-body', sourceKey: 'node/body', sourceIndex: 0, kind: 'mesh' },
|
|
12
|
+
{ guid: 'new-hero', sourceKey: 'node/hero', sourceIndex: 1, kind: 'mesh' },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
expect(diffTopology(previous, next)).toEqual({
|
|
16
|
+
preserved: [
|
|
17
|
+
{ guid: 'old-hero', oldKey: 'node/hero', newKey: 'node/hero' },
|
|
18
|
+
{ guid: 'old-body', oldKey: 'node/body', newKey: 'node/body' },
|
|
19
|
+
],
|
|
20
|
+
added: [],
|
|
21
|
+
removed: [],
|
|
22
|
+
changedKind: [],
|
|
23
|
+
ambiguous: [],
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('reports add/remove and kind changes without inventing identity', () => {
|
|
28
|
+
const result = diffTopology(
|
|
29
|
+
[{ guid: 'old', sourceKey: 'node/old', sourceIndex: 0, kind: 'mesh' }],
|
|
30
|
+
[
|
|
31
|
+
{ guid: 'new', sourceKey: 'node/new', sourceIndex: 0, kind: 'mesh' },
|
|
32
|
+
{ guid: 'same', sourceKey: 'node/old', sourceIndex: 1, kind: 'scene' },
|
|
33
|
+
],
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
expect(result.preserved).toEqual([]);
|
|
37
|
+
expect(result.added).toEqual([
|
|
38
|
+
{ guid: 'same', sourceKey: 'node/old', sourceIndex: 1, kind: 'scene' },
|
|
39
|
+
{ guid: 'new', sourceKey: 'node/new', sourceIndex: 0, kind: 'mesh' },
|
|
40
|
+
]);
|
|
41
|
+
expect(result.removed).toEqual([
|
|
42
|
+
{ guid: 'old', sourceKey: 'node/old', sourceIndex: 0, kind: 'mesh' },
|
|
43
|
+
]);
|
|
44
|
+
expect(result.changedKind).toEqual([
|
|
45
|
+
{
|
|
46
|
+
guid: 'old',
|
|
47
|
+
oldKind: 'mesh',
|
|
48
|
+
newKind: 'scene',
|
|
49
|
+
sourceKey: 'node/old',
|
|
50
|
+
action: 'remove-add',
|
|
51
|
+
},
|
|
52
|
+
]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('marks multi-output source-index-only matching as ambiguous', () => {
|
|
56
|
+
const result = diffTopology(
|
|
57
|
+
[
|
|
58
|
+
{ guid: 'old-a', sourceIndex: 0, kind: 'mesh' },
|
|
59
|
+
{ guid: 'old-b', sourceIndex: 1, kind: 'mesh' },
|
|
60
|
+
],
|
|
61
|
+
[
|
|
62
|
+
{ guid: 'new-a', sourceIndex: 0, kind: 'mesh' },
|
|
63
|
+
{ guid: 'new-b', sourceIndex: 1, kind: 'mesh' },
|
|
64
|
+
],
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
expect(result.preserved).toEqual([]);
|
|
68
|
+
expect(result.added).toEqual([]);
|
|
69
|
+
expect(result.removed).toEqual([]);
|
|
70
|
+
expect(result.ambiguous).toMatchObject([{ reason: 'source-index-ambiguous' }]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('does not preserve a GUID when a producer key is duplicated', () => {
|
|
74
|
+
const result = diffTopology(
|
|
75
|
+
[
|
|
76
|
+
{ guid: 'old-a', sourceKey: 'node/body', sourceIndex: 0, kind: 'mesh' },
|
|
77
|
+
{ guid: 'old-b', sourceKey: 'node/body', sourceIndex: 1, kind: 'mesh' },
|
|
78
|
+
],
|
|
79
|
+
[{ guid: 'new-body', sourceKey: 'node/body', sourceIndex: 0, kind: 'mesh' }],
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
expect(result.preserved).toEqual([]);
|
|
83
|
+
expect(result.ambiguous).toMatchObject([{ reason: 'duplicate-source-key' }]);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { AssetArtifactError } from '@forgeax/engine-types';
|
|
2
|
+
import { err, ok, type Result } from '@forgeax/engine-types';
|
|
3
|
+
|
|
4
|
+
export interface ArtifactPathContext {
|
|
5
|
+
readonly packageRoot: string;
|
|
6
|
+
readonly guid: string;
|
|
7
|
+
readonly artifactKey: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const PATH_HINT =
|
|
11
|
+
'use a normalized package-relative artifact path without traversal, absolute prefixes, or remote URLs';
|
|
12
|
+
|
|
13
|
+
export function validateArtifactPath(
|
|
14
|
+
locator: string,
|
|
15
|
+
context: ArtifactPathContext,
|
|
16
|
+
): Result<string, AssetArtifactError> {
|
|
17
|
+
const decoded = decodeLocator(locator);
|
|
18
|
+
if (!decoded.ok) return invalidPath(locator, context);
|
|
19
|
+
|
|
20
|
+
const normalized = normalizeRelativePath(decoded.value);
|
|
21
|
+
if (!normalized) return invalidPath(locator, context);
|
|
22
|
+
|
|
23
|
+
return ok(normalized);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function decodeLocator(locator: string): Result<string, null> {
|
|
27
|
+
let decoded = locator;
|
|
28
|
+
try {
|
|
29
|
+
for (let pass = 0; pass < 3; pass += 1) {
|
|
30
|
+
const next = decodeURIComponent(decoded);
|
|
31
|
+
if (next === decoded) break;
|
|
32
|
+
decoded = next;
|
|
33
|
+
}
|
|
34
|
+
} catch {
|
|
35
|
+
return err(null);
|
|
36
|
+
}
|
|
37
|
+
return ok(decoded);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function normalizeRelativePath(locator: string): string | null {
|
|
41
|
+
if (locator.length === 0 || locator.includes('://')) return null;
|
|
42
|
+
if (locator.startsWith('/') || /^[A-Za-z]:[\\/]/.test(locator)) return null;
|
|
43
|
+
|
|
44
|
+
const segments = locator.replaceAll('\\', '/').split('/');
|
|
45
|
+
const normalized: string[] = [];
|
|
46
|
+
for (const segment of segments) {
|
|
47
|
+
if (segment === '..') return null;
|
|
48
|
+
if (segment === '' || segment === '.') continue;
|
|
49
|
+
normalized.push(segment);
|
|
50
|
+
}
|
|
51
|
+
return normalized.length > 0 ? normalized.join('/') : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function invalidPath(
|
|
55
|
+
locator: string,
|
|
56
|
+
context: ArtifactPathContext,
|
|
57
|
+
): Result<never, AssetArtifactError> {
|
|
58
|
+
return err({
|
|
59
|
+
code: 'asset-artifact-path-invalid',
|
|
60
|
+
expected: 'a normalized package-relative artifact path',
|
|
61
|
+
hint: PATH_HINT,
|
|
62
|
+
detail: {
|
|
63
|
+
guid: context.guid,
|
|
64
|
+
artifactKey: context.artifactKey,
|
|
65
|
+
observed: locator,
|
|
66
|
+
expected: context.packageRoot,
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
}
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
// `forgeax-engine-remote-asset atlas` subcommand backend
|
|
2
|
+
// (feat-20260521-sprite-atlas-animation M5' T-32). Replaces the v1 vite-
|
|
3
|
+
// plugin idiom (PR #190 deleted `@forgeax/engine-vite-plugin-image`) with
|
|
4
|
+
// a one-shot Node CLI that reads a glob of PNGs, runs the pure shelf
|
|
5
|
+
// packer, blits the decoded RGBA into a single atlas buffer and writes
|
|
6
|
+
// `<output>/<name>.atlas.png` + `<output>/<name>.atlas.meta.json` sidecar.
|
|
7
|
+
//
|
|
8
|
+
// Why CLI not Vite plugin (PR #190 architectural pivot):
|
|
9
|
+
// - asset importing is producer-side disk work; v2 routes the producer
|
|
10
|
+
// through the kubectl-style `forgeax-engine-remote-` plugin bin so the
|
|
11
|
+
// same surface composes with `scan` / `lookup` / `verify` (charter P5
|
|
12
|
+
// producer/consumer split).
|
|
13
|
+
// - vite plugins reach for runtime-coupled hooks (buildStart / emitFile);
|
|
14
|
+
// a one-shot CLI keeps the producer free of bundler lifecycle deps and
|
|
15
|
+
// can be invoked from any build system (vite / rolldown / esbuild /
|
|
16
|
+
// plain pnpm script) without rewiring.
|
|
17
|
+
//
|
|
18
|
+
// Error taxonomy (plan-strategy section 2 D-2 + ImageErrorCode union SSOT
|
|
19
|
+
// in `@forgeax/engine-types`):
|
|
20
|
+
// - 'atlas-empty-input' -> { receivedCount }
|
|
21
|
+
// - 'atlas-size-exceeded' -> { name, width, height, maxAtlasSize }
|
|
22
|
+
// - 'atlas-region-mismatch'-> { name, regionsTotalPixels, atlasPixels }
|
|
23
|
+
//
|
|
24
|
+
// We compose the structured error envelope locally (3 expected literals
|
|
25
|
+
// hard-coded) instead of reaching for `ImageErrorImpl` from
|
|
26
|
+
// `@forgeax/engine-image` because that package already depends on
|
|
27
|
+
// `@forgeax/engine-pack` (creating a circular dep would block both
|
|
28
|
+
// builds). The hint copy comes from `IMAGE_ERROR_HINTS` in
|
|
29
|
+
// `@forgeax/engine-types` so the AI-user copy stays single-sourced.
|
|
30
|
+
|
|
31
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
32
|
+
import { basename, extname, isAbsolute, resolve, sep } from 'node:path';
|
|
33
|
+
import { parseArgs } from 'node:util';
|
|
34
|
+
import { IMAGE_ERROR_HINTS } from '@forgeax/engine-types';
|
|
35
|
+
import {
|
|
36
|
+
type AtlasImageInput,
|
|
37
|
+
type AtlasRegion,
|
|
38
|
+
type ShelfPackOutcome,
|
|
39
|
+
shelfPack,
|
|
40
|
+
} from './shelf-pack.js';
|
|
41
|
+
|
|
42
|
+
interface AssetCtx {
|
|
43
|
+
readonly stdoutWrite: (line: string) => void;
|
|
44
|
+
readonly stderrWrite: (line: string) => void;
|
|
45
|
+
readonly cwd?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const ATLAS_EXPECTED = {
|
|
49
|
+
'atlas-empty-input': 'images.length >= 1',
|
|
50
|
+
'atlas-size-exceeded':
|
|
51
|
+
'image width x height <= maxAtlasSize^2 and each image fits in the atlas footprint',
|
|
52
|
+
'atlas-region-mismatch': 'sum(regions[i].w x regions[i].h) <= atlasWidth x atlasHeight',
|
|
53
|
+
} as const;
|
|
54
|
+
|
|
55
|
+
interface ErrorEnvelope {
|
|
56
|
+
readonly code: keyof typeof ATLAS_EXPECTED;
|
|
57
|
+
readonly expected: string;
|
|
58
|
+
readonly hint: string;
|
|
59
|
+
readonly detail: Record<string, unknown>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function emitError(ctx: AssetCtx, env: ErrorEnvelope): number {
|
|
63
|
+
ctx.stderrWrite(
|
|
64
|
+
JSON.stringify({
|
|
65
|
+
code: env.code,
|
|
66
|
+
expected: env.expected,
|
|
67
|
+
hint: env.hint,
|
|
68
|
+
detail: env.detail,
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
return 1;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface UpngDecoded {
|
|
75
|
+
width: number;
|
|
76
|
+
height: number;
|
|
77
|
+
depth: number;
|
|
78
|
+
ctype: number;
|
|
79
|
+
data: Uint8Array;
|
|
80
|
+
frames?: unknown[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface UpngModule {
|
|
84
|
+
decode: (bytes: Uint8Array | ArrayBuffer) => UpngDecoded;
|
|
85
|
+
toRGBA8: (img: UpngDecoded) => ArrayBuffer[];
|
|
86
|
+
encode: (imgs: ArrayBuffer[], w: number, h: number, cnum: number) => ArrayBuffer;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
type FastGlobFn = (
|
|
90
|
+
patterns: string | readonly string[],
|
|
91
|
+
options?: { absolute?: boolean; cwd?: string },
|
|
92
|
+
) => Promise<string[]>;
|
|
93
|
+
|
|
94
|
+
async function loadUpng(): Promise<UpngModule> {
|
|
95
|
+
const mod = (await import('upng-js')) as { default?: UpngModule } & UpngModule;
|
|
96
|
+
return (mod.default ?? mod) as UpngModule;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function loadFastGlob(): Promise<FastGlobFn> {
|
|
100
|
+
const mod = (await import('fast-glob')) as unknown as { default: FastGlobFn };
|
|
101
|
+
return mod.default;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface AtlasArgs {
|
|
105
|
+
readonly input: string;
|
|
106
|
+
readonly name: string;
|
|
107
|
+
readonly output: string;
|
|
108
|
+
readonly maxAtlasSize: number;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function parseAtlasArgs(
|
|
112
|
+
rest: string[],
|
|
113
|
+
cwd: string,
|
|
114
|
+
):
|
|
115
|
+
| { ok: true; value: AtlasArgs }
|
|
116
|
+
| {
|
|
117
|
+
ok: false;
|
|
118
|
+
message: string;
|
|
119
|
+
} {
|
|
120
|
+
let parsed:
|
|
121
|
+
| {
|
|
122
|
+
values: {
|
|
123
|
+
input?: string;
|
|
124
|
+
name?: string;
|
|
125
|
+
output?: string;
|
|
126
|
+
'max-atlas-size'?: string;
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
| undefined;
|
|
130
|
+
try {
|
|
131
|
+
parsed = parseArgs({
|
|
132
|
+
args: rest,
|
|
133
|
+
allowPositionals: false,
|
|
134
|
+
strict: true,
|
|
135
|
+
options: {
|
|
136
|
+
input: { type: 'string' },
|
|
137
|
+
name: { type: 'string' },
|
|
138
|
+
output: { type: 'string' },
|
|
139
|
+
'max-atlas-size': { type: 'string' },
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
} catch (e) {
|
|
143
|
+
return { ok: false, message: e instanceof Error ? e.message : String(e) };
|
|
144
|
+
}
|
|
145
|
+
const input = parsed.values.input;
|
|
146
|
+
const name = parsed.values.name;
|
|
147
|
+
if (typeof input !== 'string' || input.length === 0) {
|
|
148
|
+
return { ok: false, message: 'missing required --input <glob>' };
|
|
149
|
+
}
|
|
150
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
151
|
+
return { ok: false, message: 'missing required --name <prefix>' };
|
|
152
|
+
}
|
|
153
|
+
const output = typeof parsed.values.output === 'string' ? parsed.values.output : cwd;
|
|
154
|
+
const maxRaw = parsed.values['max-atlas-size'];
|
|
155
|
+
const maxAtlasSize = typeof maxRaw === 'string' ? Number.parseInt(maxRaw, 10) : 4096;
|
|
156
|
+
if (!Number.isFinite(maxAtlasSize) || maxAtlasSize <= 0) {
|
|
157
|
+
return { ok: false, message: '--max-atlas-size must be a positive integer' };
|
|
158
|
+
}
|
|
159
|
+
return { ok: true, value: { input, name, output, maxAtlasSize } };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function nameFromPath(path: string): string {
|
|
163
|
+
const base = basename(path);
|
|
164
|
+
const ext = extname(base);
|
|
165
|
+
return ext.length > 0 ? base.slice(0, -ext.length) : base;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function decodePngs(paths: readonly string[], upng: UpngModule): Promise<AtlasImageInput[]> {
|
|
169
|
+
const out: AtlasImageInput[] = [];
|
|
170
|
+
for (const p of paths) {
|
|
171
|
+
const bytes = await readFile(p);
|
|
172
|
+
const decoded = upng.decode(bytes);
|
|
173
|
+
const rgbaFrames = upng.toRGBA8(decoded);
|
|
174
|
+
const first = rgbaFrames[0];
|
|
175
|
+
if (first === undefined) {
|
|
176
|
+
throw new Error(`upng-js produced no RGBA frame for ${p}`);
|
|
177
|
+
}
|
|
178
|
+
out.push({
|
|
179
|
+
name: nameFromPath(p),
|
|
180
|
+
width: decoded.width,
|
|
181
|
+
height: decoded.height,
|
|
182
|
+
pixels: new Uint8Array(first),
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function blitAtlas(
|
|
189
|
+
atlasWidth: number,
|
|
190
|
+
atlasHeight: number,
|
|
191
|
+
images: ReadonlyMap<string, AtlasImageInput>,
|
|
192
|
+
regions: ReadonlyArray<AtlasRegion>,
|
|
193
|
+
): Uint8Array {
|
|
194
|
+
const out = new Uint8Array(atlasWidth * atlasHeight * 4);
|
|
195
|
+
for (const r of regions) {
|
|
196
|
+
const src = images.get(r.name);
|
|
197
|
+
if (src === undefined) continue;
|
|
198
|
+
for (let row = 0; row < r.h; row++) {
|
|
199
|
+
const srcOff = row * src.width * 4;
|
|
200
|
+
const dstOff = ((r.y + row) * atlasWidth + r.x) * 4;
|
|
201
|
+
out.set(src.pixels.subarray(srcOff, srcOff + r.w * 4), dstOff);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return out;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function emitSizeExceeded(
|
|
208
|
+
ctx: AssetCtx,
|
|
209
|
+
detail: { name: string; width: number; height: number; maxAtlasSize: number },
|
|
210
|
+
): number {
|
|
211
|
+
return emitError(ctx, {
|
|
212
|
+
code: 'atlas-size-exceeded',
|
|
213
|
+
expected: ATLAS_EXPECTED['atlas-size-exceeded'],
|
|
214
|
+
hint: IMAGE_ERROR_HINTS['atlas-size-exceeded'],
|
|
215
|
+
detail,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function emitEmptyInput(ctx: AssetCtx, receivedCount: number): number {
|
|
220
|
+
return emitError(ctx, {
|
|
221
|
+
code: 'atlas-empty-input',
|
|
222
|
+
expected: ATLAS_EXPECTED['atlas-empty-input'],
|
|
223
|
+
hint: IMAGE_ERROR_HINTS['atlas-empty-input'],
|
|
224
|
+
detail: { receivedCount },
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function enforceMaxSize(
|
|
229
|
+
images: ReadonlyArray<AtlasImageInput>,
|
|
230
|
+
maxAtlasSize: number,
|
|
231
|
+
ctx: AssetCtx,
|
|
232
|
+
): number | null {
|
|
233
|
+
for (const img of images) {
|
|
234
|
+
if (img.width > maxAtlasSize || img.height > maxAtlasSize) {
|
|
235
|
+
return emitSizeExceeded(ctx, {
|
|
236
|
+
name: img.name,
|
|
237
|
+
width: img.width,
|
|
238
|
+
height: img.height,
|
|
239
|
+
maxAtlasSize,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
type ShelfPackErrorValue = Extract<ShelfPackOutcome, { ok: false }>['error'];
|
|
247
|
+
|
|
248
|
+
function emitFromShelfPackError(
|
|
249
|
+
error: ShelfPackErrorValue,
|
|
250
|
+
fallbackMax: number,
|
|
251
|
+
ctx: AssetCtx,
|
|
252
|
+
): number {
|
|
253
|
+
if (error.code === 'atlas-empty-input') {
|
|
254
|
+
return emitEmptyInput(ctx, error.detail.receivedCount ?? 0);
|
|
255
|
+
}
|
|
256
|
+
return emitSizeExceeded(ctx, {
|
|
257
|
+
name: error.detail.name ?? '',
|
|
258
|
+
width: error.detail.width ?? 0,
|
|
259
|
+
height: error.detail.height ?? 0,
|
|
260
|
+
maxAtlasSize: error.detail.maxAtlasSize ?? fallbackMax,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function enforceRegionsFit(
|
|
265
|
+
regions: ReadonlyArray<AtlasRegion>,
|
|
266
|
+
atlasWidth: number,
|
|
267
|
+
atlasHeight: number,
|
|
268
|
+
fallbackName: string,
|
|
269
|
+
ctx: AssetCtx,
|
|
270
|
+
): number | null {
|
|
271
|
+
let regionsTotalPixels = 0;
|
|
272
|
+
for (const r of regions) regionsTotalPixels += r.w * r.h;
|
|
273
|
+
const atlasPixels = atlasWidth * atlasHeight;
|
|
274
|
+
if (regionsTotalPixels <= atlasPixels) return null;
|
|
275
|
+
const offender =
|
|
276
|
+
regions.find((r) => r.x + r.w > atlasWidth || r.y + r.h > atlasHeight) ?? regions[0];
|
|
277
|
+
return emitError(ctx, {
|
|
278
|
+
code: 'atlas-region-mismatch',
|
|
279
|
+
expected: ATLAS_EXPECTED['atlas-region-mismatch'],
|
|
280
|
+
hint: IMAGE_ERROR_HINTS['atlas-region-mismatch'],
|
|
281
|
+
detail: {
|
|
282
|
+
name: offender?.name ?? fallbackName,
|
|
283
|
+
regionsTotalPixels,
|
|
284
|
+
atlasPixels,
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function writeAtlasArtifacts(
|
|
290
|
+
args: AtlasArgs,
|
|
291
|
+
cwd: string,
|
|
292
|
+
images: ReadonlyArray<AtlasImageInput>,
|
|
293
|
+
upng: UpngModule,
|
|
294
|
+
packed: { atlasWidth: number; atlasHeight: number; regions: ReadonlyArray<AtlasRegion> },
|
|
295
|
+
): Promise<void> {
|
|
296
|
+
const { atlasWidth, atlasHeight, regions } = packed;
|
|
297
|
+
const imgByName = new Map<string, AtlasImageInput>();
|
|
298
|
+
for (const img of images) imgByName.set(img.name, img);
|
|
299
|
+
const atlasRgba = blitAtlas(atlasWidth, atlasHeight, imgByName, regions);
|
|
300
|
+
const atlasPng = upng.encode([atlasRgba.buffer as ArrayBuffer], atlasWidth, atlasHeight, 0);
|
|
301
|
+
const outputAbs = isAbsolute(args.output) ? args.output : resolve(cwd, args.output);
|
|
302
|
+
await mkdir(outputAbs, { recursive: true });
|
|
303
|
+
await writeFile(resolve(outputAbs, `${args.name}.atlas.png`), Buffer.from(atlasPng));
|
|
304
|
+
const sidecar = {
|
|
305
|
+
name: args.name,
|
|
306
|
+
atlasWidth,
|
|
307
|
+
atlasHeight,
|
|
308
|
+
regions: regions.map((r) => ({
|
|
309
|
+
name: r.name,
|
|
310
|
+
uMin: r.x / atlasWidth,
|
|
311
|
+
vMin: r.y / atlasHeight,
|
|
312
|
+
uW: r.w / atlasWidth,
|
|
313
|
+
vH: r.h / atlasHeight,
|
|
314
|
+
})),
|
|
315
|
+
};
|
|
316
|
+
await writeFile(
|
|
317
|
+
resolve(outputAbs, `${args.name}.atlas.meta.json`),
|
|
318
|
+
`${JSON.stringify(sidecar, null, 2)}\n`,
|
|
319
|
+
'utf-8',
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export async function runAtlas(rest: string[], ctx: AssetCtx): Promise<number> {
|
|
324
|
+
const cwd = ctx.cwd ?? process.cwd();
|
|
325
|
+
const argsOutcome = parseAtlasArgs(rest, cwd);
|
|
326
|
+
if (!argsOutcome.ok) {
|
|
327
|
+
return emitError(ctx, {
|
|
328
|
+
code: 'atlas-empty-input',
|
|
329
|
+
expected:
|
|
330
|
+
'forgeax-engine-remote-asset atlas --input <glob> --name <prefix> [--output <dir>] [--max-atlas-size <n>]',
|
|
331
|
+
hint: argsOutcome.message,
|
|
332
|
+
detail: { receivedCount: 0 },
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
const args = argsOutcome.value;
|
|
336
|
+
|
|
337
|
+
const fastGlob = await loadFastGlob();
|
|
338
|
+
const inputAbs = isAbsolute(args.input) ? args.input : resolve(cwd, args.input);
|
|
339
|
+
// Normalize to forward slashes for fast-glob (it requires POSIX-style paths).
|
|
340
|
+
const inputGlob = sep === '/' ? inputAbs : inputAbs.replaceAll(sep, '/');
|
|
341
|
+
const matched = await fastGlob(inputGlob, { absolute: true });
|
|
342
|
+
if (matched.length === 0) return emitEmptyInput(ctx, 0);
|
|
343
|
+
|
|
344
|
+
const upng = await loadUpng();
|
|
345
|
+
const images = await decodePngs(matched, upng);
|
|
346
|
+
const sizeErr = enforceMaxSize(images, args.maxAtlasSize, ctx);
|
|
347
|
+
if (sizeErr !== null) return sizeErr;
|
|
348
|
+
|
|
349
|
+
const outcome = shelfPack(images, { maxAtlasSize: args.maxAtlasSize });
|
|
350
|
+
if (!outcome.ok) return emitFromShelfPackError(outcome.error, args.maxAtlasSize, ctx);
|
|
351
|
+
|
|
352
|
+
const { atlasWidth, atlasHeight, regions } = outcome.value;
|
|
353
|
+
const regionErr = enforceRegionsFit(regions, atlasWidth, atlasHeight, args.name, ctx);
|
|
354
|
+
if (regionErr !== null) return regionErr;
|
|
355
|
+
|
|
356
|
+
await writeAtlasArtifacts(args, cwd, images, upng, { atlasWidth, atlasHeight, regions });
|
|
357
|
+
return 0;
|
|
358
|
+
}
|