lecodes-assets 0.2.0 → 2.4.10

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.
Files changed (128) hide show
  1. package/README.md +738 -449
  2. package/dist/cli.js +19760 -8612
  3. package/dist/index.js +19334 -8167
  4. package/package.json +15 -3
  5. package/src/bvh/fromDocument.ts +136 -0
  6. package/src/bvh/toDocument.ts +202 -0
  7. package/src/cli-kit/command.ts +100 -0
  8. package/src/cli-kit/help.ts +72 -0
  9. package/src/cli-kit/index.ts +4 -0
  10. package/src/cli-kit/parse.ts +72 -0
  11. package/src/cli-kit/run.ts +52 -0
  12. package/src/cli.ts +24 -0
  13. package/src/commands/avatar.ts +77 -0
  14. package/src/commands/bvh.ts +26 -0
  15. package/src/commands/convert.ts +156 -0
  16. package/src/commands/doctor.ts +67 -0
  17. package/src/commands/geometry.ts +45 -0
  18. package/src/commands/index.ts +24 -0
  19. package/src/commands/marks.ts +38 -0
  20. package/src/commands/mirror.ts +35 -0
  21. package/src/commands/probe.ts +43 -0
  22. package/src/commands/retarget/donor.ts +63 -0
  23. package/src/commands/retarget/index.ts +169 -0
  24. package/src/commands/retarget/map.ts +81 -0
  25. package/src/commands/retarget/report.ts +77 -0
  26. package/src/commands/scene.ts +103 -0
  27. package/src/commands/shared.ts +121 -0
  28. package/src/commands/sky.ts +35 -0
  29. package/src/commands/terrainPack.ts +40 -0
  30. package/src/commands/unpack.ts +43 -0
  31. package/src/commands/unreal.ts +109 -0
  32. package/src/commands/unwrap.ts +56 -0
  33. package/src/doctor/cardTrim.ts +286 -0
  34. package/src/doctor/doctor.ts +305 -0
  35. package/src/doctor/lod.ts +177 -0
  36. package/src/doctor/merge/accessors.ts +30 -0
  37. package/src/doctor/merge/atlasLayout.ts +94 -0
  38. package/src/doctor/merge/atlasPixels.ts +114 -0
  39. package/src/doctor/merge/bindSpace.ts +59 -0
  40. package/src/doctor/merge/classify.ts +136 -0
  41. package/src/doctor/merge/geometry.ts +167 -0
  42. package/src/doctor/merge/index.ts +98 -0
  43. package/src/doctor/merge/matrix.ts +60 -0
  44. package/src/doctor/merge/plan.ts +88 -0
  45. package/src/doctor/merge/textures.ts +60 -0
  46. package/src/doctor/merge/types.ts +77 -0
  47. package/src/doctor/normalizeScale.ts +220 -0
  48. package/src/doctor/reduceGlbBones.ts +218 -0
  49. package/src/doctor/shading.ts +95 -0
  50. package/src/doctor/transcodeKtx2.ts +275 -0
  51. package/src/doctor/transcodeTextures.ts +101 -0
  52. package/src/doctor/validateGlb.ts +264 -0
  53. package/src/doctor/vertexAo.ts +321 -0
  54. package/src/fbx/convert.ts +327 -0
  55. package/src/fbx/read.ts +111 -0
  56. package/src/fbx/toDocument.ts +227 -0
  57. package/src/filmstrip.ts +263 -0
  58. package/src/geometry.ts +222 -0
  59. package/src/index.ts +19 -0
  60. package/src/mergeSkins.ts +117 -0
  61. package/src/probe.ts +167 -0
  62. package/src/props.ts +137 -0
  63. package/src/retarget/automap.ts +276 -0
  64. package/src/retarget/ghost.ts +23 -0
  65. package/src/retarget/heading.ts +238 -0
  66. package/src/retarget/humanoid.ts +572 -0
  67. package/src/retarget/inPlace.ts +130 -0
  68. package/src/retarget/marks.ts +581 -0
  69. package/src/retarget/mirror.ts +0 -0
  70. package/src/retarget/retarget.ts +2375 -0
  71. package/src/retarget/rig.ts +545 -0
  72. package/src/retarget/unityAnim.ts +171 -0
  73. package/src/retarget/writeMarks.ts +134 -0
  74. package/src/sky/analyze.ts +136 -0
  75. package/src/sky/cmgen.ts +71 -0
  76. package/src/sky/index.ts +200 -0
  77. package/src/terrainPack.ts +208 -0
  78. package/src/textures/resolveImage.ts +202 -0
  79. package/src/unity/colliderFit.ts +287 -0
  80. package/src/unity/fbxModel.ts +194 -0
  81. package/src/unity/foliage.ts +63 -0
  82. package/src/unity/gameObjects.ts +81 -0
  83. package/src/unity/guid.ts +104 -0
  84. package/src/unity/image.ts +58 -0
  85. package/src/unity/kindGlb.ts +364 -0
  86. package/src/unity/lecodesLevel.ts +381 -0
  87. package/src/unity/levelTerrain.ts +116 -0
  88. package/src/unity/matFile.ts +179 -0
  89. package/src/unity/materialSlots.ts +53 -0
  90. package/src/unity/materialWriter.ts +306 -0
  91. package/src/unity/materials.ts +198 -0
  92. package/src/unity/math.ts +82 -0
  93. package/src/unity/png.ts +75 -0
  94. package/src/unity/prefabTree.ts +285 -0
  95. package/src/unity/prefabs.ts +54 -0
  96. package/src/unity/propertyPath.ts +43 -0
  97. package/src/unity/scene.ts +384 -0
  98. package/src/unity/serialized.ts +242 -0
  99. package/src/unity/terrain.ts +221 -0
  100. package/src/unity/triBudget.ts +77 -0
  101. package/src/unity/triplanar.ts +122 -0
  102. package/src/unity/unityMesh.ts +151 -0
  103. package/src/unity/unpack.ts +149 -0
  104. package/src/unity/yaml.ts +131 -0
  105. package/src/unreal/anim.ts +284 -0
  106. package/src/unreal/clipSource.ts +25 -0
  107. package/src/unreal/convert.ts +67 -0
  108. package/src/unreal/convertAsset.ts +81 -0
  109. package/src/unreal/index.ts +115 -0
  110. package/src/unreal/list.ts +113 -0
  111. package/src/unreal/materialGraph.ts +224 -0
  112. package/src/unreal/materials.ts +399 -0
  113. package/src/unreal/mesh.ts +205 -0
  114. package/src/unreal/oodle.ts +153 -0
  115. package/src/unreal/package.ts +677 -0
  116. package/src/unreal/probe.ts +144 -0
  117. package/src/unreal/resolve.ts +53 -0
  118. package/src/unreal/rig.ts +149 -0
  119. package/src/unreal/sequencer.ts +193 -0
  120. package/src/unreal/skeleton.ts +74 -0
  121. package/src/unreal/sound.ts +128 -0
  122. package/src/unreal/texture.ts +269 -0
  123. package/src/unreal/toDocument.ts +367 -0
  124. package/src/unreal/versions.ts +108 -0
  125. package/src/unwrap/index.ts +167 -0
  126. package/src/unwrap/lightmapUv.ts +761 -0
  127. package/src/wasm/fbxReader.js +14 -0
  128. package/src/wasm/lightmapUv.js +14 -0
@@ -0,0 +1,200 @@
1
+ // Equirectangular HDR (.exr / .hdr) → the two cubemaps a LeCodes scene needs.
2
+ //
3
+ // The thing everyone gets wrong, so it is worth stating plainly: an environment is TWO files and
4
+ // they are NOT interchangeable.
5
+ //
6
+ // sky.ktx — sharp, single mip. What is DRAWN behind the scene (`skybox: { texture }`).
7
+ // ibl.ktx — prefiltered per roughness + spherical harmonics. What LIGHTS the scene.
8
+ //
9
+ // Handing the IBL to the skybox is the tempting shortcut (one file, and it "works") but that file
10
+ // has been blurred on purpose, so the sky comes out visibly soft. cmgen emits both for this reason;
11
+ // this command just makes the pair reproducible and names them the way the runtime expects.
12
+ //
13
+ // The IBL is deliberately smaller: it is only ever sampled as low-frequency lighting, so 256 is
14
+ // plenty, while the sky is looked at directly and wants 512+.
15
+ import { spawnSync } from "node:child_process"
16
+ import { existsSync, mkdirSync, mkdtempSync, copyFileSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs"
17
+ import { tmpdir } from "node:os"
18
+ import { basename, extname, join, resolve } from "node:path"
19
+ import { findCmgen, checkCmgen, CMGEN_HELP } from "./cmgen"
20
+ import { analyzeSkyKtx, type SkyAnalysis } from "./analyze"
21
+
22
+ export type SkyOptions = {
23
+ /** Where the pair lands (default: beside the input). */
24
+ outDir?: string
25
+ /** Cubemap edge for the drawn sky (default 512). */
26
+ size?: number
27
+ /** Cubemap edge for the lighting probe (default 256). */
28
+ iblSize?: number
29
+ /** File names, without extension (default "sky" and "ibl" — what the runtime looks for). */
30
+ skyName?: string
31
+ iblName?: string
32
+ /** Skip the sky, emit only the lighting probe. */
33
+ iblOnly?: boolean
34
+ /** Take the SUN out of the lighting probe (cmgen --clamp-sun, the LeCodes fork's): for a level that has a directional
35
+ * sun. The sun in the probe is the same light a second time - from everywhere, without shadows: it flattens every
36
+ * object and halves the contrast of the baked shade. The drawn sky keeps its sun. `true` = 8 degrees. */
37
+ noSun?: boolean | number
38
+ log?: (msg: string) => void
39
+ }
40
+
41
+ /** What `noSun` measured: the disc taken out of the probe, and what the scene should be given for it. */
42
+ export type SunCalibration = {
43
+ /** The disc's illuminance on a surface facing it, in the picture's own units. */
44
+ discUnits: number
45
+ /** Degrees above the horizon (from the unclamped picture). */
46
+ elevation: number
47
+ /** A clear day's direct sun at that elevation, lux: 127 500 x exp(-0.21 / sin(elevation)). */
48
+ sunLux: number
49
+ /** environmentIntensity that makes the picture's own disc that bright: sunLux / discUnits - pixel 1.0 in nits. */
50
+ environmentIntensity: number
51
+ }
52
+
53
+ export type SkyResult = {
54
+ sun?: SunCalibration
55
+ skyPath: string | null
56
+ iblPath: string | null
57
+ analysis: SkyAnalysis | null
58
+ }
59
+
60
+ const runCmgen = (cmgen: string, args: string[]): string => {
61
+ const p = spawnSync(cmgen, args, { encoding: "utf8" })
62
+ if (p.error) throw new Error(`cmgen failed to start: ${p.error.message}`)
63
+ if (p.status !== 0) {
64
+ const out = `${p.stdout ?? ""}${p.stderr ?? ""}`.trim()
65
+ throw new Error(`cmgen exited with ${p.status}${out ? `:\n${out}` : ""}`)
66
+ }
67
+ return `${p.stdout ?? ""}${p.stderr ?? ""}`
68
+ }
69
+
70
+ /** The file cmgen's --deploy wrote for a given role, whatever it chose to call the folder. */
71
+ const deployed = (dir: string, suffix: string): string | null => {
72
+ if (!existsSync(dir)) return null
73
+ const hit = readdirSync(dir).find((f) => f.endsWith(`${suffix}.ktx`))
74
+ return hit ? join(dir, hit) : null
75
+ }
76
+
77
+ export const buildSky = async (input: string, opts: SkyOptions = {}): Promise<SkyResult> => {
78
+ const log = opts.log ?? (() => {})
79
+ if (!existsSync(input)) throw new Error(`not found: ${input}`)
80
+
81
+ const ext = extname(input).toLowerCase()
82
+ if (![".exr", ".hdr", ".png", ".psd"].includes(ext)) {
83
+ throw new Error(`unsupported input "${ext}" — cmgen reads .exr, .hdr, .png or .psd (equirectangular).`)
84
+ }
85
+
86
+ const cmgen = findCmgen()
87
+ if (!checkCmgen(cmgen)) throw new Error(CMGEN_HELP)
88
+
89
+ const outDir = resolve(opts.outDir ?? resolve(input, ".."))
90
+ mkdirSync(outDir, { recursive: true })
91
+ const size = opts.size ?? 512
92
+ const iblSize = opts.iblSize ?? 256
93
+ const skyName = opts.skyName ?? "sky"
94
+ const iblName = opts.iblName ?? "ibl"
95
+
96
+ const stage = mkdtempSync(join(tmpdir(), "lecodes-sky-"))
97
+ const result: SkyResult = { skyPath: null, iblPath: null, analysis: null }
98
+ try {
99
+ // Two passes: the sky and the probe want different resolutions, and --deploy sizes both at once.
100
+ if (!opts.iblOnly) {
101
+ log(` cubemap ${size}… (sky)`)
102
+ const d = join(stage, "sky")
103
+ runCmgen(cmgen, ["-q", "--format=ktx", `--size=${size}`, `--deploy=${d}`, input])
104
+ const src = deployed(d, "_skybox")
105
+ if (!src) throw new Error("cmgen produced no _skybox.ktx")
106
+ result.skyPath = join(outDir, `${skyName}.ktx`)
107
+ copyFileSync(src, result.skyPath)
108
+ }
109
+
110
+ log(` cubemap ${iblSize}… (lighting probe + SH)`)
111
+ const d2 = join(stage, "ibl")
112
+ let discUnits = 0
113
+ const sunArgs = opts.noSun ? [typeof opts.noSun === "number" ? `--clamp-sun=${opts.noSun}` : "--clamp-sun"] : []
114
+ const iblOut = runCmgen(cmgen, ["-q", "--format=ktx", `--size=${iblSize}`, ...sunArgs, `--deploy=${d2}`, input])
115
+ if (opts.noSun) {
116
+ const line = /Sun clamped:[^\r\n]*/.exec(iblOut)
117
+ if (!line) throw new Error("this cmgen has no --clamp-sun (it is the LeCodes filament fork's): point LECODES_CMGEN at hosts/desktop/build/creator-gl/filament/tools/cmgen")
118
+ log(` ${line[0]}`)
119
+ const disc = /disc's illuminance ([0-9.eE+-]+)/.exec(iblOut)
120
+ if (disc) discUnits = parseFloat(disc[1])
121
+ }
122
+ const iblSrc = deployed(d2, "_ibl")
123
+ if (!iblSrc) throw new Error("cmgen produced no _ibl.ktx")
124
+ result.iblPath = join(outDir, `${iblName}.ktx`)
125
+ copyFileSync(iblSrc, result.iblPath)
126
+
127
+ // Analyse whichever cubemap is sharpest — the sky when we made one, else the probe. A probe WITHOUT its sun says
128
+ // nothing about where the sun is: with `noSun` and no drawn sky, one more small pass keeps the disc for the reading.
129
+ let toScan = result.skyPath ?? result.iblPath
130
+ if (opts.noSun && !result.skyPath) {
131
+ const d3 = join(stage, "scan")
132
+ runCmgen(cmgen, ["-q", "--format=ktx", `--size=${iblSize}`, `--deploy=${d3}`, input])
133
+ toScan = deployed(d3, "_skybox") ?? deployed(d3, "_ibl") ?? toScan
134
+ }
135
+ try {
136
+ result.analysis = analyzeSkyKtx(new Uint8Array(readFileSync(toScan)))
137
+ } catch (e) {
138
+ log(` (could not analyse the environment: ${(e as Error).message})`)
139
+ }
140
+ // THE CALIBRATION: the picture's units are relative, the sun's illuminance is not. A clear day's direct sun at
141
+ // elevation h is 127 500 x exp(-0.21 / sin h) lux (the extraterrestrial constant through a clear atmosphere's
142
+ // optical depth); environmentIntensity = that / the disc's illuminance in the picture's units - pixel 1.0 in nits.
143
+ const elevation = result.analysis?.elevation ?? 0
144
+ if (discUnits > 0 && elevation > 2 && (result.analysis?.peakLuminance ?? 0) >= 100) {
145
+ const sunLux = 127500 * Math.exp(-0.21 / Math.sin(elevation * Math.PI / 180))
146
+ result.sun = { discUnits, elevation, sunLux, environmentIntensity: sunLux / discUnits }
147
+ }
148
+ } finally {
149
+ rmSync(stage, { recursive: true, force: true })
150
+ }
151
+ return result
152
+ }
153
+
154
+ /** The report + the scene snippet to paste. The analysis is the point of the command: it turns
155
+ * "aim the sun by eye until the shadows look right" into a number read off the environment. */
156
+ export const formatSkyReport = (r: SkyResult, name = "SKY"): string => {
157
+ const out: string[] = []
158
+ const mb = (p: string): string => `${(statSync(p).size / 1048576).toFixed(1)} MB`
159
+ if (r.skyPath) out.push(`💾 ${r.skyPath} (${mb(r.skyPath)}) — drawn as the sky`)
160
+ if (r.iblPath) out.push(`💾 ${r.iblPath} (${mb(r.iblPath)}) — lights the scene`)
161
+ const a = r.analysis
162
+ if (!a) return out.join("\n")
163
+
164
+ const [dx, dy, dz] = a.lightDirection
165
+ out.push("")
166
+ out.push(`🔆 sun at ${a.elevation}° above the horizon · tint ${a.sunTint} · peak ${a.peakLuminance}`)
167
+ if (a.peakLuminance < 100) {
168
+ out.push(
169
+ ` NOTE: that peak is low — this environment has no sun DISC (overcast, or the sun is below\n` +
170
+ ` the horizon). The direction below is just the brightest part of the sky glow, and a strong\n` +
171
+ ` white directional light under it will read as wrong as one pointing the wrong way.`,
172
+ )
173
+ }
174
+ // Sunlight is never blue. A blue-dominant core means the disc is clipped in this HDRI and the
175
+ // reading has picked up the intensely blue sky glow around it — common in the 1k/2k versions.
176
+ // The DIRECTION is still trustworthy; the colour is not, so say so instead of handing over a
177
+ // number that would light the level with blue "sunlight".
178
+ const blueSun = parseInt(a.sunTint.slice(5, 7), 16) > parseInt(a.sunTint.slice(1, 3), 16) + 8
179
+ if (blueSun && a.peakLuminance >= 100) {
180
+ out.push(
181
+ ` NOTE: that tint is BLUE-dominant, which no sun is — the disc is clipped in this file and the\n` +
182
+ ` reading caught the sky glow around it. Trust the direction, not the colour: use a neutral\n` +
183
+ ` warm white (#fff2e2 or so) and tint to taste.`,
184
+ )
185
+ }
186
+ const round2 = (v: number): number => { const m = Math.pow(10, Math.max(0, Math.floor(Math.log10(v)) - 1)); return Math.round(v / m) * m }
187
+ const c = r.sun
188
+ if (c) {
189
+ out.push(` CALIBRATED BY THE SUN: its disc is ${c.discUnits.toFixed(2)} of the picture's units of illuminance; a clear day's sun`)
190
+ out.push(` at ${c.elevation}° is ~${round2(c.sunLux)} lux, so pixel 1.0 = ${round2(c.environmentIntensity)} nits. The probe has the disc taken out:`)
191
+ out.push(` the directional light below IS the sun - give it that illuminance, do not tune the two by eye.`)
192
+ } else {
193
+ out.push(` mean luminance ${a.meanLuminance} (upper hemisphere ${a.upperMeanLuminance}) — the brighter this is,`)
194
+ out.push(` the lower environmentIntensity wants to be.${a.peakLuminance >= 100 ? " --no-sun measures the disc and gives the number." : ""}`)
195
+ }
196
+ out.push("")
197
+ out.push(` env: { skybox: { texture: ${name} }, environmentIntensity: ${c ? round2(c.environmentIntensity) : 16000} }`)
198
+ out.push(` light: { kind: 'sun', direction: [${dx}, ${dy}, ${dz}], color: '${blueSun ? "#fff2e2" : a.sunTint}'${c ? `, intensity: ${round2(c.sunLux)}` : ""} }`)
199
+ return out.join("\n")
200
+ }
@@ -0,0 +1,208 @@
1
+ // `lecodes assets terrain-pack` — a terrain's splat layers (up to four) → what `Terrain.create`'s
2
+ // `layers`/`normals` read (docs/terrain-plan.md §1.5):
3
+ //
4
+ // <name>.ktx2 one per layer: the albedo, sRGB, square, mipmapped — with the layer's ROUGHNESS in its ALPHA when
5
+ // the layer has a roughness source (terrain.mat: roughness = the layer's factor x albedo alpha; the
6
+ // one channel the sampler budget leaves free). No source = no alpha = the factor alone.
7
+ // normals01.ktx2 layer 0's normal XY in RG + layer 1's in BA (linear)
8
+ // normals23.ktx2 layers 2 + 3 likewise (only when there are more than two layers)
9
+ //
10
+ // The packing exists because terrain.mat runs at Filament feature level 1 (nine samplers): four
11
+ // albedos + four separate normal maps + the control map + the lightmap would be ten. Z is
12
+ // reconstructed in the shader, so only XY ship. A layer without a normal map gets a flat (128,128).
13
+ //
14
+ // Inputs: an albedo file per layer (PNG/JPEG/WebP/TIFF via sharp, TGA/BMP decoded here) with the
15
+ // normal map given by `--normals`, guessed from the albedo's name (`<x>_Albedo.png` → `<x>_Normal.png`),
16
+ // or — the Unity-pack shape — a FOLDER per layer holding `*_Albedo.*` + `*_Normal.*`.
17
+ // Roughness: `--roughness` per layer, guessed from the name (`<x>_Roughness.*`), or a folder's `*Roughness*` /
18
+ // `*AlbedoSmoothness*` (Unity: smoothness in the ALPHA, inverted here). A roughness image is read from its GREEN channel:
19
+ // a grey map has it in every channel, a glTF metallicRoughness / ORM map has roughness there.
20
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync } from "node:fs"
21
+ import { basename, extname, join, resolve } from "node:path"
22
+ import { spawnSync } from "node:child_process"
23
+ import { decodeBmp, decodeTga, encodePng, sniffMime } from "./textures/resolveImage"
24
+ import { findKtxEncoder, ktxEncodeArgs, KTX_ENCODER_MISSING, type KtxEncoder } from "./doctor/transcodeKtx2"
25
+
26
+ export type TerrainPackLayer = {
27
+ name: string, albedo: string, normal?: string
28
+ /** a roughness image (read from its green channel) … */
29
+ roughness?: string
30
+ /** … or a Unity `*_AlbedoSmoothness` image: roughness = 1 − its alpha */
31
+ smoothnessAlpha?: string
32
+ /** A per-channel scale on the albedo, applied in LINEAR light (Unity's TerrainLayer `m_DiffuseRemapMax`: a tint
33
+ * the artist dialled on the layer, which the packed texture must carry since a layer has no tint of its own). */
34
+ albedoScale?: [number, number, number]
35
+ }
36
+
37
+ export type TerrainPackOptions = {
38
+ /** Where the files land. */
39
+ outDir: string
40
+ /** Output edge in pixels — every texture is resampled square (default 1024). */
41
+ size?: number
42
+ /** Write PNGs instead of KTX2 (no KTX-Software needed; larger, uncompressed on the GPU). */
43
+ png?: boolean
44
+ /** Invert the normal maps' green channel (DirectX-style sources; Unity's are OpenGL-style, the default). */
45
+ flipY?: boolean
46
+ /** UASTC quality 0–4 (default 1). */
47
+ quality?: number
48
+ log?: (msg: string) => void
49
+ }
50
+
51
+ export type TerrainPackResult = {
52
+ layers: { name: string, file: string, albedo: string, normal: string | null, roughness: string | null, sourceSize: [number, number] }[]
53
+ normals: string[]
54
+ /** The matching `Terrain.create` fragment. */
55
+ snippet: string
56
+ }
57
+
58
+ const IMAGE_EXT = /\.(png|jpe?g|webp|tiff?|tga|bmp)$/i
59
+
60
+ /** A layer from a file (+ optional normal) or a folder (`*Albedo*` + `*Normal*` inside). */
61
+ export const resolveTerrainLayer = (input: string, normal?: string, name?: string, roughness?: string): TerrainPackLayer => {
62
+ const p = resolve(input)
63
+ if (!existsSync(p)) throw new Error(`not found: ${input}`)
64
+ if (statSync(p).isDirectory()) {
65
+ const files = readdirSync(p).filter((f) => IMAGE_EXT.test(f))
66
+ // plain "_Albedo" over "_AlbedoSmoothness" (Unity packs ship both; the smoothness alpha is dropped anyway)
67
+ const albedo = files.find((f) => /albedo/i.test(f) && !/smooth/i.test(f)) ?? files.find((f) => /albedo|basecolor|base_color|diffuse|_d\./i.test(f))
68
+ if (!albedo) throw new Error(`${input}: no albedo image (a file named *Albedo* / *BaseColor* / *Diffuse*)`)
69
+ const nrm = normal ?? files.find((f) => /normal|_n\./i.test(f) && !/normalmap_?dx/i.test(f))
70
+ const rough = roughness ? resolve(roughness) : files.find((f) => /rough/i.test(f))
71
+ const smooth = rough ? undefined : files.find((f) => /albedo.*smooth|smooth.*albedo/i.test(f))
72
+ return {
73
+ name: name ?? cleanName(basename(p)), albedo: join(p, albedo), normal: nrm ? (normal ? resolve(normal) : join(p, nrm)) : undefined,
74
+ roughness: rough ? (roughness ? rough : join(p, rough)) : undefined, smoothnessAlpha: smooth ? join(p, smooth) : undefined,
75
+ }
76
+ }
77
+ if (!IMAGE_EXT.test(p)) throw new Error(`${input}: not an image (png/jpg/webp/tiff/tga/bmp)`)
78
+ let nrm: string | undefined = normal ? resolve(normal) : undefined
79
+ if (!nrm) {
80
+ const stem = p.slice(0, -extname(p).length)
81
+ for (const cand of [ stem.replace(/albedo/i, "Normal"), stem.replace(/albedo/i, "normal"), `${stem}_Normal`, `${stem}_normal`, stem.replace(/_?(basecolor|diffuse|_d)$/i, "_Normal") ]) {
82
+ if (cand === stem) continue
83
+ const hit = [ ".png", ".jpg", ".jpeg", ".tga", ".webp", ".tif", ".tiff", ".bmp" ].map((e) => cand + e).find((f) => existsSync(f))
84
+ if (hit) { nrm = hit; break }
85
+ }
86
+ }
87
+ let rough: string | undefined = roughness ? resolve(roughness) : undefined
88
+ if (!rough) {
89
+ const stem = p.slice(0, -extname(p).length)
90
+ for (const cand of [ stem.replace(/albedo/i, "Roughness"), stem.replace(/albedo/i, "roughness"), `${stem}_Roughness`, `${stem}_roughness`, stem.replace(/_?(basecolor|diffuse|_d)$/i, "_Roughness") ]) {
91
+ if (cand === stem) continue
92
+ const hit = [ ".png", ".jpg", ".jpeg", ".tga", ".webp", ".tif", ".tiff", ".bmp" ].map((e) => cand + e).find((f) => existsSync(f))
93
+ if (hit) { rough = hit; break }
94
+ }
95
+ }
96
+ return { name: name ?? cleanName(basename(p, extname(p)).replace(/[_-]?(albedo|basecolor|base_color|diffuse)$/i, "")), albedo: p, normal: nrm, roughness: rough }
97
+ }
98
+
99
+ const cleanName = (s: string): string => s.trim().replace(/\s+/g, "_").replace(/[^A-Za-z0-9_-]/g, "").toLowerCase() || "layer"
100
+
101
+ type Raw = { data: Buffer, width: number, height: number }
102
+
103
+ /** Any supported image → RGBA at `size`² (TGA/BMP through the in-house decoders, the rest via sharp). */
104
+ const loadRgba = async (path: string, size: number): Promise<Raw & { sourceSize: [number, number] }> => {
105
+ const sharp = (await import("sharp")).default
106
+ let bytes: Uint8Array = new Uint8Array(readFileSync(path))
107
+ const mime = sniffMime(bytes, path)
108
+ if (mime === "image/x-tga" || mime === "image/bmp") {
109
+ const rgba = mime === "image/x-tga" ? decodeTga(bytes) : decodeBmp(bytes)
110
+ if (!rgba) throw new Error(`${path}: could not decode the ${mime === "image/bmp" ? "BMP" : "TGA"}`)
111
+ bytes = encodePng(rgba)
112
+ }
113
+ const img = sharp(Buffer.from(bytes))
114
+ const meta = await img.metadata()
115
+ const { data, info } = await img.resize(size, size, { fit: "fill", kernel: "lanczos3" }).ensureAlpha().raw().toBuffer({ resolveWithObject: true })
116
+ return { data, width: info.width, height: info.height, sourceSize: [ meta.width ?? 0, meta.height ?? 0 ] }
117
+ }
118
+
119
+ const encodeKtx2 = (enc: KtxEncoder, png: string, ktx2: string, channels: 3 | 4, srgb: boolean, quality: number): void => {
120
+ const args = ktxEncodeArgs(enc, { input: png, output: ktx2, codec: "uastc", oetf: srgb ? "srgb" : "linear", channels, uastcQuality: quality, zstd: 18 })
121
+ const p = spawnSync(enc.path, args, { encoding: "utf8" })
122
+ if (p.status !== 0) throw new Error(`${enc.kind === "ktx" ? "ktx create" : "toktx"} failed (${p.status ?? p.error?.message ?? "?"}) on ${basename(png)}:\n${(p.stderr || p.stdout || "").trim()}`)
123
+ }
124
+
125
+ export const packTerrainTextures = async (layers: TerrainPackLayer[], opts: TerrainPackOptions): Promise<TerrainPackResult> => {
126
+ if (layers.length === 0) throw new Error("no layers")
127
+ if (layers.length > 4) throw new Error(`${layers.length} layers — a terrain material takes at most four`)
128
+ const size = opts.size ?? 1024
129
+ if (!(size >= 16 && size <= 8192) || (size & (size - 1)) !== 0) throw new Error(`--size wants a power of two in 16..8192, got ${size}`)
130
+ const log = opts.log ?? (() => {})
131
+ const sharp = (await import("sharp")).default
132
+ const enc = opts.png ? null : findKtxEncoder()
133
+ if (!opts.png && !enc) throw new Error(`${KTX_ENCODER_MISSING}\n Or pass --png.`)
134
+ const seen = new Set<string>()
135
+ for (const l of layers) {
136
+ if (seen.has(l.name)) throw new Error(`two layers named "${l.name}" — pass --names`)
137
+ seen.add(l.name)
138
+ }
139
+ mkdirSync(opts.outDir, { recursive: true })
140
+ const ext = opts.png ? ".png" : ".ktx2"
141
+
142
+ const writeImage = async (raw: Raw, channels: 3 | 4, out: string, srgb: boolean): Promise<void> => {
143
+ let pipe = sharp(raw.data, { raw: { width: raw.width, height: raw.height, channels: 4 } })
144
+ if (channels === 3) pipe = pipe.removeAlpha()
145
+ const png = opts.png ? out : out.slice(0, -ext.length) + ".png"
146
+ await pipe.png({ compressionLevel: opts.png ? 9 : 1 }).toFile(png)
147
+ if (enc) {
148
+ encodeKtx2(enc, png, out, channels, srgb, opts.quality ?? 1)
149
+ unlinkSync(png)
150
+ }
151
+ }
152
+
153
+ const result: TerrainPackResult = { layers: [], normals: [], snippet: "" }
154
+ const normalRaws: (Raw | null)[] = []
155
+ for (const l of layers) {
156
+ const alb = await loadRgba(l.albedo, size)
157
+ if (l.albedoScale && l.albedoScale.some((s) => Math.abs(s - 1) > 1e-3)) {
158
+ const toLin = (v: number): number => (v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4))
159
+ const toSrgb = (v: number): number => (v <= 0.0031308 ? v * 12.92 : 1.055 * Math.pow(v, 1 / 2.4) - 0.055)
160
+ const lut = [ 0, 1, 2 ].map((c) => { const t = new Uint8Array(256); for (let i = 0; i < 256; i++) t[i] = Math.round(Math.min(1, toSrgb(toLin(i / 255) * l.albedoScale![c])) * 255); return t })
161
+ for (let i = 0; i < size * size; i++) for (let c = 0; c < 3; c++) alb.data[i * 4 + c] = lut[c][alb.data[i * 4 + c]]
162
+ }
163
+ const file = join(opts.outDir, l.name + ext)
164
+ // the layer's roughness rides the albedo's alpha (the header); without a source the albedo ships opaque
165
+ const roughSrc = l.roughness ?? l.smoothnessAlpha
166
+ if (roughSrc) {
167
+ if (!existsSync(roughSrc)) throw new Error(`${l.name}: roughness map not found: ${roughSrc}`)
168
+ const r = await loadRgba(roughSrc, size)
169
+ for (let i = 0; i < size * size; i++) alb.data[i * 4 + 3] = l.roughness ? r.data[i * 4 + 1] : 255 - r.data[i * 4 + 3]
170
+ }
171
+ await writeImage(alb, roughSrc ? 4 : 3, file, true)
172
+ let nrm: (Raw & { sourceSize: [number, number] }) | null = null
173
+ if (l.normal) {
174
+ if (!existsSync(l.normal)) throw new Error(`${l.name}: normal map not found: ${l.normal}`)
175
+ nrm = await loadRgba(l.normal, size)
176
+ }
177
+ normalRaws.push(nrm)
178
+ result.layers.push({ name: l.name, file, albedo: l.albedo, normal: l.normal ?? null, roughness: roughSrc ?? null, sourceSize: alb.sourceSize })
179
+ log(` ${l.name.padEnd(16)} ${alb.sourceSize[0]}×${alb.sourceSize[1]} → ${size}² ${nrm ? `+ normal ${nrm.sourceSize[0]}×${nrm.sourceSize[1]}` : "(flat normal)"}${roughSrc ? ` + roughness in the alpha (${l.roughness ? basename(roughSrc) : "1 − " + basename(roughSrc) + "'s alpha"})` : ""}`)
180
+ }
181
+
182
+ // the packs: RG = layer 2k's XY, BA = layer 2k+1's; a missing map is flat
183
+ const packCount = layers.length > 2 ? 2 : 1
184
+ for (let k = 0; k < packCount; k++) {
185
+ const a = normalRaws[k * 2] ?? null, b = normalRaws[k * 2 + 1] ?? null
186
+ const n = size * size
187
+ const out = Buffer.alloc(n * 4)
188
+ for (let i = 0; i < n; i++) {
189
+ const o = i * 4
190
+ out[o] = a ? a.data[o] : 128
191
+ out[o + 1] = a ? (opts.flipY ? 255 - a.data[o + 1] : a.data[o + 1]) : 128
192
+ out[o + 2] = b ? b.data[o] : 128
193
+ out[o + 3] = b ? (opts.flipY ? 255 - b.data[o + 1] : b.data[o + 1]) : 128
194
+ }
195
+ const file = join(opts.outDir, `normals${k * 2}${k * 2 + 1}${ext}`)
196
+ await writeImage({ data: out, width: size, height: size }, 4, file, false)
197
+ result.normals.push(file)
198
+ log(` normals${k * 2}${k * 2 + 1}${"".padEnd(7)} ${a ? layers[k * 2].name : "flat"} + ${b ? layers[k * 2 + 1].name : layers[k * 2 + 1] ? "flat" : "—"}`)
199
+ }
200
+
201
+ const rel = (f: string): string => "./" + basename(f)
202
+ result.snippet = [
203
+ "layers: [",
204
+ ...result.layers.map((l, i) => ` { albedo: asset('${rel(l.file)}'), tiling: 4${normalRaws[i] ? ", normalScale: 1" : ""} },`),
205
+ `], normals: [ ${result.normals.map((f) => `asset('${rel(f)}')`).join(", ")} ]`,
206
+ ].join("\n")
207
+ return result
208
+ }
@@ -0,0 +1,202 @@
1
+ // Turn an FBX texture reference (embedded bytes or a filename) into GLB-embeddable image bytes.
2
+ // PNG/JPEG pass through; TGA/BMP are decoded here and re-encoded as PNG (no native deps); anything
3
+ // else goes through `sharp` when it's installed, otherwise the texture is skipped with a warning.
4
+
5
+ import { existsSync, readFileSync } from "node:fs"
6
+ import { basename, dirname, join, isAbsolute } from "node:path"
7
+ import { deflateSync } from "node:zlib"
8
+ import { createHash } from "node:crypto"
9
+ import type { FbxTexture } from "../fbx/read"
10
+
11
+ export type ResolvedImage = { data: Uint8Array, mimeType: "image/png" | "image/jpeg", hash: string, converted: boolean, source: string }
12
+ type Warn = (w: { level: "warn" | "info", message: string }) => void
13
+
14
+ export const sniffMime = (b: Uint8Array, hintName = ""): string => {
15
+ if (b.length >= 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return "image/png"
16
+ if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return "image/jpeg"
17
+ if (b.length >= 12 && b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 && b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50) return "image/webp"
18
+ if (b.length >= 2 && b[0] === 0x42 && b[1] === 0x4d) return "image/bmp"
19
+ if (b.length >= 4 && b[0] === 0x44 && b[1] === 0x44 && b[2] === 0x53 && b[3] === 0x20) return "image/vnd-ms.dds"
20
+ if (b.length >= 4 && b[0] === 0x38 && b[1] === 0x42 && b[2] === 0x50 && b[3] === 0x53) return "image/vnd.adobe.photoshop"
21
+ if (b.length >= 4 && ((b[0] === 0x49 && b[1] === 0x49 && b[2] === 0x2a) || (b[0] === 0x4d && b[1] === 0x4d && b[2] === 0x00))) return "image/tiff"
22
+ if (b.length >= 12 && b[0] === 0xab && b[1] === 0x4b && b[2] === 0x54 && b[3] === 0x58) return "image/ktx2"
23
+ const ext = hintName.toLowerCase().split(".").pop() ?? ""
24
+ if (ext === "tga" || ext === "targa") return "image/x-tga"
25
+ // TGA has no magic; accept the footer or a plausible header when the extension says so
26
+ if (b.length > 26) {
27
+ const foot = new TextDecoder().decode(b.subarray(b.length - 18, b.length - 2))
28
+ if (foot === "TRUEVISION-XFILE") return "image/x-tga"
29
+ }
30
+ return "application/octet-stream"
31
+ }
32
+
33
+ const hashOf = (b: Uint8Array): string => createHash("sha1").update(b).digest("hex")
34
+
35
+ /** Resolve an FBX texture to PNG/JPEG bytes (null = unusable, warned). */
36
+ export const resolveTextureImage = async (t: FbxTexture, embedded: Uint8Array | null, baseDir: string | undefined, warn: Warn): Promise<ResolvedImage | null> => {
37
+ let data: Uint8Array | null = embedded && embedded.length > 0 ? embedded : null
38
+ let source = "embedded"
39
+ const hint = t.relative || t.filename || t.absolute || t.name
40
+ if (!data) {
41
+ const found = findExternal(t, baseDir)
42
+ if (!found) { warn({ level: "warn", message: `texture '${hint}': not embedded and not found next to the FBX — skipped` }); return null }
43
+ data = new Uint8Array(readFileSync(found))
44
+ source = found
45
+ }
46
+ const mime = sniffMime(data, hint)
47
+ if (mime === "image/png" || mime === "image/jpeg") return { data, mimeType: mime, hash: hashOf(data), converted: false, source }
48
+ if (mime === "image/x-tga") {
49
+ const rgba = decodeTga(data)
50
+ if (rgba) { const png = encodePng(rgba); return { data: png, mimeType: "image/png", hash: hashOf(png), converted: true, source } }
51
+ }
52
+ if (mime === "image/bmp") {
53
+ const rgba = decodeBmp(data)
54
+ if (rgba) { const png = encodePng(rgba); return { data: png, mimeType: "image/png", hash: hashOf(png), converted: true, source } }
55
+ }
56
+ // last resort: sharp (optional dependency)
57
+ try {
58
+ const sharp = (await import("sharp")).default
59
+ const png = new Uint8Array(await sharp(Buffer.from(data)).png().toBuffer())
60
+ return { data: png, mimeType: "image/png", hash: hashOf(png), converted: true, source }
61
+ } catch (e) {
62
+ warn({ level: "warn", message: `texture '${hint}': ${mime} is not supported without 'sharp' (bun add sharp) — skipped` })
63
+ return null
64
+ }
65
+ }
66
+
67
+ const findExternal = (t: FbxTexture, baseDir: string | undefined): string | null => {
68
+ const candidates: string[] = []
69
+ const names = [ t.absolute, t.filename, t.relative ].filter(Boolean)
70
+ for (const n of names) {
71
+ if (isAbsolute(n) && existsSync(n)) return n
72
+ if (baseDir) {
73
+ candidates.push(join(baseDir, n))
74
+ const b = basename(n.replace(/\\/g, "/"))
75
+ candidates.push(join(baseDir, b), join(baseDir, "textures", b), join(baseDir, "Textures", b))
76
+ }
77
+ }
78
+ for (const c of candidates) if (existsSync(c)) return c
79
+ return null
80
+ }
81
+
82
+ // ---- TGA --------------------------------------------------------------------------------------
83
+
84
+ export type Rgba = { width: number, height: number, data: Uint8Array }
85
+
86
+ export const decodeTga = (b: Uint8Array): Rgba | null => {
87
+ if (b.length < 18) return null
88
+ const idLen = b[0], cmapType = b[1], imgType = b[2]
89
+ const cmapStart = b[3] | (b[4] << 8), cmapLen = b[5] | (b[6] << 8), cmapBits = b[7]
90
+ const width = b[12] | (b[13] << 8), height = b[14] | (b[15] << 8)
91
+ const bpp = b[16], desc = b[17]
92
+ const topDown = (desc & 0x20) !== 0
93
+ const rle = imgType === 9 || imgType === 10 || imgType === 11
94
+ const kind = imgType & 7 // 1 = colormapped, 2 = truecolor, 3 = grayscale
95
+ if (width === 0 || height === 0 || ![ 1, 2, 3 ].includes(kind)) return null
96
+ let p = 18 + idLen
97
+ let cmap: Uint8Array | null = null
98
+ const cmapBytes = (cmapBits + 7) >> 3
99
+ if (cmapType === 1) { cmap = b.subarray(p, p + cmapLen * cmapBytes); p += cmapLen * cmapBytes }
100
+ const bytesPP = bpp >> 3
101
+ const out = new Uint8Array(width * height * 4)
102
+ const px = new Uint8Array(4)
103
+ const readPixel = (at: number): void => {
104
+ if (kind === 3) { const g = b[at]; px[0] = g; px[1] = g; px[2] = g; px[3] = bytesPP > 1 ? b[at + 1] : 255; return }
105
+ if (kind === 1 && cmap) {
106
+ const idx = (bytesPP === 2 ? b[at] | (b[at + 1] << 8) : b[at]) - cmapStart
107
+ const c = idx * cmapBytes
108
+ if (cmapBytes >= 3) { px[0] = cmap[c + 2]; px[1] = cmap[c + 1]; px[2] = cmap[c]; px[3] = cmapBytes === 4 ? cmap[c + 3] : 255 }
109
+ else { const v = cmap[c] | (cmap[c + 1] << 8); px[0] = ((v >> 10) & 31) * 8; px[1] = ((v >> 5) & 31) * 8; px[2] = (v & 31) * 8; px[3] = 255 }
110
+ return
111
+ }
112
+ if (bytesPP === 4) { px[0] = b[at + 2]; px[1] = b[at + 1]; px[2] = b[at]; px[3] = b[at + 3]; return }
113
+ if (bytesPP === 3) { px[0] = b[at + 2]; px[1] = b[at + 1]; px[2] = b[at]; px[3] = 255; return }
114
+ if (bytesPP === 2) { const v = b[at] | (b[at + 1] << 8); px[0] = ((v >> 10) & 31) * 8; px[1] = ((v >> 5) & 31) * 8; px[2] = (v & 31) * 8; px[3] = 255; return }
115
+ px[0] = px[1] = px[2] = b[at]; px[3] = 255
116
+ }
117
+ const total = width * height
118
+ let i = 0
119
+ const put = (): void => {
120
+ const y = topDown ? Math.floor(i / width) : height - 1 - Math.floor(i / width)
121
+ const o = (y * width + (i % width)) * 4
122
+ out[o] = px[0]; out[o + 1] = px[1]; out[o + 2] = px[2]; out[o + 3] = px[3]
123
+ i++
124
+ }
125
+ if (!rle) {
126
+ for (; i < total && p + bytesPP <= b.length;) { readPixel(p); p += bytesPP; put() }
127
+ } else {
128
+ while (i < total && p < b.length) {
129
+ const head = b[p++]
130
+ const count = (head & 0x7f) + 1
131
+ if (head & 0x80) { readPixel(p); p += bytesPP; for (let k = 0; k < count && i < total; k++) put() }
132
+ else for (let k = 0; k < count && i < total && p + bytesPP <= b.length; k++) { readPixel(p); p += bytesPP; put() }
133
+ }
134
+ }
135
+ return { width, height, data: out }
136
+ }
137
+
138
+ // ---- BMP (uncompressed 24/32-bit) --------------------------------------------------------------
139
+
140
+ export const decodeBmp = (b: Uint8Array): Rgba | null => {
141
+ if (b.length < 54 || b[0] !== 0x42 || b[1] !== 0x4d) return null
142
+ const dv = new DataView(b.buffer, b.byteOffset, b.byteLength)
143
+ const offset = dv.getUint32(10, true)
144
+ const width = dv.getInt32(18, true)
145
+ const rawHeight = dv.getInt32(22, true)
146
+ const bpp = dv.getUint16(28, true)
147
+ const compression = dv.getUint32(30, true)
148
+ if ((bpp !== 24 && bpp !== 32) || (compression !== 0 && compression !== 3)) return null
149
+ const height = Math.abs(rawHeight)
150
+ const topDown = rawHeight < 0
151
+ const stride = ((width * bpp / 8) + 3) & ~3
152
+ const out = new Uint8Array(width * height * 4)
153
+ for (let y = 0; y < height; y++) {
154
+ const srcY = topDown ? y : height - 1 - y
155
+ let s = offset + srcY * stride
156
+ for (let x = 0; x < width; x++) {
157
+ const o = (y * width + x) * 4
158
+ out[o] = b[s + 2]; out[o + 1] = b[s + 1]; out[o + 2] = b[s]; out[o + 3] = bpp === 32 ? b[s + 3] : 255
159
+ s += bpp / 8
160
+ }
161
+ }
162
+ return { width, height, data: out }
163
+ }
164
+
165
+ // ---- PNG encoder (RGBA8, filter 0, zlib) -----------------------------------------------------------
166
+
167
+ const CRC_TABLE = (() => {
168
+ const t = new Uint32Array(256)
169
+ for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; t[n] = c >>> 0 }
170
+ return t
171
+ })()
172
+ const crc32 = (parts: Uint8Array[]): number => {
173
+ let c = 0xffffffff
174
+ for (const p of parts) for (let i = 0; i < p.length; i++) c = CRC_TABLE[(c ^ p[i]) & 0xff] ^ (c >>> 8)
175
+ return (c ^ 0xffffffff) >>> 0
176
+ }
177
+ const chunk = (type: string, data: Uint8Array): Uint8Array => {
178
+ const t = new TextEncoder().encode(type)
179
+ const out = new Uint8Array(12 + data.length)
180
+ const dv = new DataView(out.buffer)
181
+ dv.setUint32(0, data.length)
182
+ out.set(t, 4); out.set(data, 8)
183
+ dv.setUint32(8 + data.length, crc32([ t, data ]))
184
+ return out
185
+ }
186
+
187
+ export const encodePng = (img: Rgba): Uint8Array => {
188
+ const { width, height, data } = img
189
+ const raw = new Uint8Array((width * 4 + 1) * height)
190
+ for (let y = 0; y < height; y++) { raw[y * (width * 4 + 1)] = 0; raw.set(data.subarray(y * width * 4, (y + 1) * width * 4), y * (width * 4 + 1) + 1) }
191
+ const ihdr = new Uint8Array(13)
192
+ const dv = new DataView(ihdr.buffer)
193
+ dv.setUint32(0, width); dv.setUint32(4, height)
194
+ ihdr[8] = 8; ihdr[9] = 6; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0
195
+ const idat = new Uint8Array(deflateSync(raw, { level: 6 }))
196
+ const sig = new Uint8Array([ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a ])
197
+ const parts = [ sig, chunk("IHDR", ihdr), chunk("IDAT", idat), chunk("IEND", new Uint8Array(0)) ]
198
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0))
199
+ let o = 0
200
+ for (const p of parts) { out.set(p, o); o += p.length }
201
+ return out
202
+ }