lecodes-assets 0.2.1 → 2.5.0

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 (99) hide show
  1. package/README.md +203 -26
  2. package/dist/cli.js +18157 -11441
  3. package/dist/index.js +16603 -9878
  4. package/package.json +3 -1
  5. package/src/cli-kit/command.ts +100 -0
  6. package/src/cli-kit/help.ts +72 -0
  7. package/src/cli-kit/index.ts +4 -0
  8. package/src/cli-kit/parse.ts +72 -0
  9. package/src/cli-kit/run.ts +52 -0
  10. package/src/cli.ts +24 -1039
  11. package/src/commands/avatar.ts +77 -0
  12. package/src/commands/bvh.ts +26 -0
  13. package/src/commands/convert.ts +156 -0
  14. package/src/commands/doctor.ts +79 -0
  15. package/src/commands/geometry.ts +45 -0
  16. package/src/commands/index.ts +24 -0
  17. package/src/commands/marks.ts +38 -0
  18. package/src/commands/mirror.ts +35 -0
  19. package/src/commands/probe.ts +43 -0
  20. package/src/commands/retarget/donor.ts +63 -0
  21. package/src/commands/retarget/index.ts +169 -0
  22. package/src/commands/retarget/map.ts +81 -0
  23. package/src/commands/retarget/report.ts +77 -0
  24. package/src/commands/scene.ts +108 -0
  25. package/src/commands/shared.ts +140 -0
  26. package/src/commands/sky.ts +35 -0
  27. package/src/commands/terrainPack.ts +40 -0
  28. package/src/commands/unpack.ts +43 -0
  29. package/src/commands/unreal.ts +109 -0
  30. package/src/commands/unwrap.ts +56 -0
  31. package/src/doctor/cardTrim.ts +288 -0
  32. package/src/doctor/doctor.ts +94 -30
  33. package/src/doctor/impostor.ts +614 -0
  34. package/src/doctor/merge/accessors.ts +30 -0
  35. package/src/doctor/merge/atlasLayout.ts +94 -0
  36. package/src/doctor/merge/atlasPixels.ts +114 -0
  37. package/src/doctor/merge/bindSpace.ts +59 -0
  38. package/src/doctor/merge/classify.ts +136 -0
  39. package/src/doctor/merge/geometry.ts +167 -0
  40. package/src/doctor/merge/index.ts +98 -0
  41. package/src/doctor/merge/matrix.ts +60 -0
  42. package/src/doctor/merge/plan.ts +88 -0
  43. package/src/doctor/merge/textures.ts +60 -0
  44. package/src/doctor/merge/types.ts +77 -0
  45. package/src/doctor/transcodeKtx2.ts +186 -31
  46. package/src/doctor/transcodeTextures.ts +6 -1
  47. package/src/doctor/validateGlb.ts +2 -2
  48. package/src/doctor/vertexAo.ts +333 -0
  49. package/src/fbx/read.ts +2 -0
  50. package/src/fbx/toDocument.ts +227 -209
  51. package/src/geometry.ts +222 -0
  52. package/src/index.ts +2 -0
  53. package/src/retarget/heading.ts +238 -171
  54. package/src/retarget/humanoid.ts +572 -561
  55. package/src/retarget/retarget.ts +2375 -2272
  56. package/src/retarget/rig.ts +4 -1
  57. package/src/retarget/writeMarks.ts +134 -132
  58. package/src/sky/cmgen.ts +3 -3
  59. package/src/sky/index.ts +57 -8
  60. package/src/terrainPack.ts +61 -22
  61. package/src/unity/colliderFit.ts +287 -0
  62. package/src/unity/fbxModel.ts +194 -0
  63. package/src/unity/foliage.ts +63 -0
  64. package/src/unity/gameObjects.ts +90 -0
  65. package/src/unity/guid.ts +104 -0
  66. package/src/unity/image.ts +58 -0
  67. package/src/unity/kindGlb.ts +364 -0
  68. package/src/unity/lecodesLevel.ts +414 -0
  69. package/src/unity/levelTerrain.ts +125 -0
  70. package/src/unity/matFile.ts +179 -0
  71. package/src/unity/materialSlots.ts +53 -0
  72. package/src/unity/materialWriter.ts +316 -0
  73. package/src/unity/materials.ts +34 -429
  74. package/src/unity/math.ts +82 -0
  75. package/src/unity/prefabTree.ts +285 -0
  76. package/src/unity/prefabs.ts +10 -36
  77. package/src/unity/propertyPath.ts +43 -0
  78. package/src/unity/scene.ts +339 -304
  79. package/src/unity/serialized.ts +242 -0
  80. package/src/unity/terrain.ts +221 -0
  81. package/src/unity/triBudget.ts +77 -0
  82. package/src/unity/triplanar.ts +122 -0
  83. package/src/unity/unityMesh.ts +151 -0
  84. package/src/unity/unpack.ts +24 -5
  85. package/src/unity/yaml.ts +131 -0
  86. package/src/unreal/index.ts +6 -5
  87. package/src/unreal/materialGraph.ts +224 -0
  88. package/src/unreal/materials.ts +117 -9
  89. package/src/unreal/package.ts +30 -12
  90. package/src/unreal/resolve.ts +6 -4
  91. package/src/unreal/sound.ts +128 -0
  92. package/src/unreal/texture.ts +70 -3
  93. package/src/unreal/toDocument.ts +7 -0
  94. package/src/unreal/versions.ts +8 -0
  95. package/src/unwrap/index.ts +167 -0
  96. package/src/unwrap/lightmapUv.ts +761 -0
  97. package/src/wasm/fbxReader.js +1 -1
  98. package/src/wasm/lightmapUv.js +1 -1
  99. package/src/doctor/lightmapUv.ts +0 -452
@@ -3,40 +3,106 @@
3
3
  //
4
4
  // Codec by texture role:
5
5
  // - colour (base/emissive) -> ETC1S, sRGB (small, lossy)
6
- // - data (normal/metalRough/occ.) -> UASTC + zstd, linear (precise)
6
+ // - data (normal/metalRough/occ.) -> UASTC + zstd, linear (precise; ETC1S steps on a smooth channel)
7
+ // - an impostor atlas (either map) -> UASTC + zstd (its colour is the far crown's hue; ETC1S shifts it)
7
8
  // …unless `codecPolicy: 'uastc'` asks for UASTC everywhere. That is for HERO assets — a
8
9
  // first-person weapon or gloves fill a third of the screen at 30 cm, where ETC1S's block artifacts
9
10
  // on the base colour are plainly visible. World props stay on the default: ETC1S is the right
10
11
  // trade there and the size difference is roughly 8x.
11
12
  // Mipmaps are generated; alpha is preserved (ETC1S/UASTC both encode it).
13
+ import { createHash } from 'node:crypto'
12
14
  import sharp from 'sharp'
13
- import { spawnSync } from 'node:child_process'
15
+ import { spawn, spawnSync } from 'node:child_process' // spawnSync only for the one-shot --version probe
14
16
  import { existsSync, mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs'
15
- import { tmpdir } from 'node:os'
17
+ import { cpus, tmpdir } from 'node:os'
16
18
  import { join } from 'node:path'
17
19
  import type { Root, Texture } from '@gltf-transform/core'
18
- import { getTextureRoles, DEFAULT_MAX_SIZE } from './transcodeTextures'
20
+ import { getTextureRoles, isImpostorAtlas, DEFAULT_MAX_SIZE } from './transcodeTextures'
19
21
 
20
- /** Locate the toktx binary: $KTX_TOKTX → %LOCALAPPDATA%\Programs\KTX-Software → PATH. */
21
- export function findToktx(): string {
22
- const env = process.env.KTX_TOKTX
23
- if (env && existsSync(env)) return env
24
- const la = process.env.LOCALAPPDATA
25
- if (la) {
26
- const guess = join(la, 'Programs', 'KTX-Software', 'bin', 'toktx.exe')
27
- if (existsSync(guess)) return guess
22
+ /**
23
+ * The KTX2 encoder: KTX-Software's legacy `toktx`, or — when there is none — its unified `ktx`
24
+ * tool's `create` subcommand. KTX-Software 5 dropped `toktx` (the macOS installer ships only `ktx`),
25
+ * 4.4 still has both; the two write the same Basis files from the same inputs, only the flags differ
26
+ * (`ktxEncodeArgs`).
27
+ */
28
+ export interface KtxEncoder { kind: 'toktx' | 'ktx'; path: string; version: string }
29
+
30
+ export const KTX_ENCODER_MISSING =
31
+ 'KTX-Software not found — neither `toktx` nor `ktx` (looked at $KTX_TOKTX, $KTX_TOOL, the installer\'s folders, PATH).\n' +
32
+ ' Install it from https://github.com/KhronosGroup/KTX-Software/releases, or set KTX_TOKTX / KTX_TOOL=<path to the binary>.'
33
+
34
+ /** Where KTX-Software's installers put their binaries, per platform. */
35
+ function installDirs(): string[] {
36
+ if (process.platform === 'win32') {
37
+ return [ process.env.LOCALAPPDATA && join(process.env.LOCALAPPDATA, 'Programs', 'KTX-Software', 'bin'),
38
+ process.env.ProgramFiles && join(process.env.ProgramFiles, 'KTX-Software', 'bin') ].filter((d): d is string => !!d)
28
39
  }
29
- return 'toktx' // assume on PATH
40
+ return [ '/usr/local/bin', '/opt/homebrew/bin' ]
30
41
  }
31
42
 
32
- export function checkToktx(toktx: string): string | null {
43
+ /** `name` resolved: the env override, then the installer's folders, then PATH (the bare name). */
44
+ function locate(name: 'toktx' | 'ktx', env: string | undefined): string {
45
+ if (env && existsSync(env)) return env
46
+ const exe = process.platform === 'win32' ? `${name}.exe` : name
47
+ return installDirs().map((d) => join(d, exe)).find((p) => existsSync(p)) ?? exe
48
+ }
49
+
50
+ /** Locate the toktx binary: $KTX_TOKTX → the installer's folders → PATH. */
51
+ export const findToktx = (): string => locate('toktx', process.env.KTX_TOKTX)
52
+
53
+ /** Locate KTX-Software's unified `ktx` tool: $KTX_TOOL → next to $KTX_TOKTX → the installer's folders → PATH. */
54
+ export const findKtxCli = (): string =>
55
+ locate('ktx', process.env.KTX_TOOL ?? process.env.KTX_TOKTX?.replace(/toktx(\.exe)?$/i, process.platform === 'win32' ? 'ktx.exe' : 'ktx'))
56
+
57
+ /** The binary's `--version` line, or null when it does not run. */
58
+ export function checkToktx(tool: string): string | null {
33
59
  try {
34
- const p = spawnSync(toktx, ['--version'], { encoding: 'utf8' })
35
- if (p.status === 0) return (p.stdout ?? '').toString().trim() || 'toktx'
60
+ const p = spawnSync(tool, ['--version'], { encoding: 'utf8' })
61
+ if (p.status === 0) return (p.stdout ?? '').toString().trim() || tool
36
62
  } catch { /* not found */ }
37
63
  return null
38
64
  }
39
65
 
66
+ /** toktx when it runs, else `ktx` (create), else null. */
67
+ export function findKtxEncoder(): KtxEncoder | null {
68
+ const toktx = findToktx()
69
+ const tv = checkToktx(toktx)
70
+ if (tv) return { kind: 'toktx', path: toktx, version: tv }
71
+ const ktx = findKtxCli()
72
+ const kv = checkToktx(ktx)
73
+ return kv ? { kind: 'ktx', path: ktx, version: kv } : null
74
+ }
75
+
76
+ export interface KtxEncodeJob {
77
+ input: string
78
+ output: string
79
+ codec: 'etc1s' | 'uastc'
80
+ oetf: 'srgb' | 'linear'
81
+ /** Channels of the input PNG — `ktx create` wants the format spelled out. */
82
+ channels: 3 | 4
83
+ /** UASTC quality 0–4. */
84
+ uastcQuality: number
85
+ /** Zstandard level for UASTC (ETC1S is Basis-LZ, never supercompressed on top). */
86
+ zstd: number
87
+ }
88
+
89
+ /** The encoder's command line for one PNG → KTX2 (always 2D, mipmapped, the transfer function assigned). */
90
+ export function ktxEncodeArgs(enc: KtxEncoder, j: KtxEncodeJob): string[] {
91
+ if (enc.kind === 'toktx') {
92
+ return [ '--t2', ...(j.codec === 'etc1s'
93
+ ? [ '--encode', 'etc1s', '--clevel', '2', '--qlevel', '128' ]
94
+ : [ '--encode', 'uastc', '--uastc_quality', String(j.uastcQuality), '--zcmp', String(j.zstd) ]),
95
+ '--genmipmap', '--assign_oetf', j.oetf, j.output, j.input ]
96
+ }
97
+ const format = `${j.channels === 4 ? 'R8G8B8A8' : 'R8G8B8'}_${j.oetf === 'srgb' ? 'SRGB' : 'UNORM'}`
98
+ return [ 'create', '--format', format, ...(j.codec === 'etc1s'
99
+ ? [ '--encode', 'basis-lz', '--clevel', '2', '--qlevel', '128' ]
100
+ : [ '--encode', 'uastc', '--uastc-quality', String(j.uastcQuality), '--zstd', String(j.zstd) ]),
101
+ // --assign-tf: sharp's PNGs carry no colour chunk, which `ktx` reads as sRGB and then refuses to
102
+ // put into a *_UNORM format without a conversion — the data maps ARE linear, so say so.
103
+ '--generate-mipmap', '--assign-tf', j.oetf, j.input, j.output ]
104
+ }
105
+
40
106
  export interface Ktx2Plan {
41
107
  tex: Texture
42
108
  name: string
@@ -73,15 +139,20 @@ export async function planKtx2(tex: Texture, root: Root, maxSize = DEFAULT_MAX_S
73
139
  return null
74
140
  }
75
141
 
76
- // Normally only normal maps justify UASTC (high quality, ~8bpp): colour and metalRough/occlusion
77
- // compress far smaller with ETC1S at acceptable quality. 'uastc' overrides that for hero assets.
142
+ // DATA maps (normal, metal / rough, occlusion) always take UASTC: ETC1S is a palette codec whose blocks show
143
+ // as steps on a smooth metallic or roughness channel (the range's metal props read pixelated under the
144
+ // reflection probes, 2026-09-17) and it wrecks normals; colour stays on ETC1S (small, its artefacts hide in the
145
+ // detail) unless 'uastc' asks for UASTC everywhere (hero assets). 'hq' is the same as the default now.
78
146
  // oetf: linear for any non-colour data.
147
+ // An impostor atlas (both maps) takes UASTC too: at the distance the quad shows, one texel is the whole screen pixel
148
+ // of a crown and ETC1S's palette quantisation shifts the crown's hue against the cards it replaces.
79
149
  const codec: 'etc1s' | 'uastc' =
80
150
  policy === 'uastc' ? 'uastc'
81
- : isNormal ? (policy === 'hq' ? 'uastc' : 'etc1s')
151
+ : isNormal || isData || isImpostorAtlas(tex) ? 'uastc'
82
152
  : 'etc1s'
83
153
  const oetf: 'srgb' | 'linear' = isNormal || isData ? 'linear' : 'srgb'
84
- const resize = Math.max(width, height) > maxSize
154
+ // an impostor atlas keeps its size whatever the cap (its frames are fixed pixel rects); still ×4 and mipmapped
155
+ const resize = !isImpostorAtlas(tex) && Math.max(width, height) > maxSize
85
156
  return {
86
157
  tex, name: tex.getName() || '?', roles, fromFmt, codec, oetf, resize, width, height, hasAlpha,
87
158
  beforeBytes: img.length, category: 'lossy',
@@ -89,7 +160,90 @@ export async function planKtx2(tex: Texture, root: Root, maxSize = DEFAULT_MAX_S
89
160
  }
90
161
  }
91
162
 
92
- export async function applyKtx2(plan: Ktx2Plan, toktx: string, maxSize = DEFAULT_MAX_SIZE): Promise<void> {
163
+ /**
164
+ * ENCODED KTX2 BY CONTENT, for the life of the process.
165
+ *
166
+ * Encoding is pure: the same pixels at the same cap, codec and transfer function give the same file.
167
+ * And a LEVEL import asks for the same pixels over and over — every kind is its own self-contained
168
+ * GLB, so a wall texture shared by a hundred buildings is embedded, and therefore encoded, a hundred
169
+ * times. Measured on the Chernobyl import: **3554 embedded textures of which 314 are distinct**, i.e.
170
+ * 11.3 encodes of each on average and 144 of the most-shared ones. Each of those is a temp dir, a
171
+ * sharp decode + PNG re-encode, and a SYNCHRONOUS `toktx` process — which is also why the importer
172
+ * sat at 1.4 of this machine's 24 cores while it ran.
173
+ *
174
+ * Keyed on everything that changes the output and nothing else.
175
+ */
176
+ const encoded = new Map<string, Uint8Array>()
177
+ let hits = 0
178
+ /** Encodes RUNNING right now, by cache key: a second caller for the same image waits on the first
179
+ * instead of starting its own toktx. Without it the concurrency below would turn the cache's whole
180
+ * point inside out — a texture on a hundred props would encode a hundred times, in parallel. */
181
+ const inFlight = new Map<string, Promise<Uint8Array>>()
182
+
183
+ /**
184
+ * How many toktx processes may run at once. The encode is an external process per texture, so the
185
+ * work is ENTIRELY outside this thread and the only reason to cap it is not to swamp the machine;
186
+ * one per core, minus one for us, is the usual shape. `LECODES_KTX_JOBS` overrides it (1 = the old
187
+ * strictly-sequential behaviour, for a bisect).
188
+ */
189
+ const JOBS = Math.max(1, Number(process.env.LECODES_KTX_JOBS) || Math.max(1, cpus().length - 1))
190
+ let running = 0
191
+ const waiting: (() => void)[] = []
192
+ const acquire = async (): Promise<void> => {
193
+ if (running < JOBS) { running++; return }
194
+ await new Promise<void>((res) => waiting.push(res))
195
+ running++
196
+ }
197
+ const release = (): void => { running--; waiting.shift()?.() }
198
+
199
+ export const ktx2Jobs = (): number => JOBS
200
+
201
+ /** The encoder as a promise. `spawnSync` blocked the event loop for the whole encode, which made every
202
+ * texture in a project strictly sequential no matter how many cores were idle. */
203
+ const runEncoder = (enc: KtxEncoder, args: string[], name: string): Promise<void> =>
204
+ new Promise((resolve, reject) => {
205
+ const proc = spawn(enc.path, args, { windowsHide: true })
206
+ let err = ''
207
+ proc.stderr?.on('data', (d) => { err += String(d) })
208
+ proc.on('error', reject)
209
+ proc.on('close', (code) => code === 0
210
+ ? resolve()
211
+ : reject(new Error(`${enc.kind === 'ktx' ? 'ktx create' : 'toktx'} failed (exit ${code}) for "${name}": ${err.slice(0, 300)}`)))
212
+ })
213
+ /** How many encodes this process served from the cache (for a caller that wants to report it). */
214
+ export const ktx2CacheHits = (): number => hits
215
+
216
+ export async function applyKtx2(plan: Ktx2Plan, enc: KtxEncoder, maxSize = DEFAULT_MAX_SIZE): Promise<void> {
217
+ const src = plan.tex.getImage()
218
+ const key = src
219
+ ? `${createHash('sha1').update(src).digest('hex')}:${maxSize}:${plan.codec}:${plan.oetf}:${plan.hasAlpha ? 1 : 0}`
220
+ : null
221
+ if (key) {
222
+ const hit = encoded.get(key)
223
+ if (hit) { hits++; plan.tex.setImage(hit).setMimeType('image/ktx2'); plan.afterBytes = hit.length; return }
224
+ // the same image, already being encoded by another caller: wait on that one
225
+ const busy = inFlight.get(key)
226
+ if (busy) {
227
+ hits++
228
+ const bytes = await busy
229
+ plan.tex.setImage(bytes).setMimeType('image/ktx2')
230
+ plan.afterBytes = bytes.length
231
+ return
232
+ }
233
+ }
234
+ const work = encodeOne(plan, enc, maxSize)
235
+ if (key) {
236
+ inFlight.set(key, work)
237
+ try { encoded.set(key, await work) } finally { inFlight.delete(key) }
238
+ }
239
+ const bytes = await work
240
+ plan.tex.setImage(bytes).setMimeType('image/ktx2')
241
+ plan.afterBytes = bytes.length
242
+ }
243
+
244
+ /** One texture through sharp + the encoder, returning the encoded bytes. Holds a pool slot only around
245
+ * the external process, so the sharp resize of the next texture overlaps with this one's encode. */
246
+ async function encodeOne(plan: Ktx2Plan, enc: KtxEncoder, maxSize: number): Promise<Uint8Array> {
93
247
  const dir = mkdtempSync(join(tmpdir(), 'glbktx-'))
94
248
  const inPng = join(dir, 'in.png')
95
249
  const outKtx = join(dir, 'out.ktx2')
@@ -100,24 +254,25 @@ export async function applyKtx2(plan: Ktx2Plan, toktx: string, maxSize = DEFAULT
100
254
  const snap4 = (n: number) => Math.max(4, Math.round(n / 4) * 4)
101
255
  let tw = plan.width, th = plan.height
102
256
  const m = Math.max(tw, th)
103
- if (m > maxSize) { const s = maxSize / m; tw = tw * s; th = th * s }
257
+ if (plan.resize && m > maxSize) { const s = maxSize / m; tw = tw * s; th = th * s }
104
258
  tw = snap4(tw); th = snap4(th)
105
259
 
106
260
  let pipe = sharp(Buffer.from(plan.tex.getImage()!))
107
261
  if (tw !== plan.width || th !== plan.height) pipe = pipe.resize({ width: tw, height: th, fit: 'fill' })
108
- if (!plan.hasAlpha) pipe = pipe.removeAlpha() // RGB → no wasted alpha slice
262
+ // Always RGB / RGBA (a grey or palette source too): `ktx create` declares the channel count up front.
263
+ pipe = pipe.toColourspace('srgb')
264
+ pipe = plan.hasAlpha ? pipe.ensureAlpha() : pipe.removeAlpha() // RGB → no wasted alpha slice
109
265
  writeFileSync(inPng, await pipe.png().toBuffer())
110
266
 
111
- const args = plan.codec === 'etc1s'
112
- ? ['--encode', 'etc1s', '--clevel', '2', '--qlevel', '128', '--genmipmap', '--assign_oetf', plan.oetf, outKtx, inPng]
113
- : ['--encode', 'uastc', '--uastc_quality', '2', '--zcmp', '18', '--genmipmap', '--assign_oetf', plan.oetf, outKtx, inPng]
267
+ const args = ktxEncodeArgs(enc, {
268
+ input: inPng, output: outKtx, codec: plan.codec, oetf: plan.oetf,
269
+ channels: plan.hasAlpha ? 4 : 3, uastcQuality: 2, zstd: 18,
270
+ })
114
271
 
115
- const proc = spawnSync(toktx, args, { encoding: 'utf8' })
116
- if (proc.status !== 0) throw new Error(`toktx failed (exit ${proc.status}) for "${plan.name}": ${(proc.stderr ?? '').toString().slice(0, 300)}`)
272
+ await acquire()
273
+ try { await runEncoder(enc, args, plan.name) } finally { release() }
117
274
 
118
- const ktx = readFileSync(outKtx)
119
- plan.tex.setImage(new Uint8Array(ktx)).setMimeType('image/ktx2')
120
- plan.afterBytes = ktx.length
275
+ return new Uint8Array(readFileSync(outKtx))
121
276
  } finally {
122
277
  rmSync(dir, { recursive: true, force: true })
123
278
  }
@@ -15,6 +15,11 @@ import type { Root, Texture } from '@gltf-transform/core'
15
15
  export const DEFAULT_MAX_SIZE = 2048
16
16
  const HEAVY_PNG_MIN_DIM = 1024
17
17
 
18
+ /** An IMPOSTOR ATLAS (doctor/impostor.ts tags both with extras `lecodes.impostor`): never resized by the size cap — its
19
+ * frames sit at fixed pixel rects, and a 12 × 256 = 3072 px strip squashed under a 2048 cap is a different layout. */
20
+ export const isImpostorAtlas = (tex: Texture): boolean =>
21
+ !!(tex.getExtras() as { lecodes?: { impostor?: unknown } } | undefined)?.lecodes?.impostor
22
+
18
23
  export interface TexturePlan {
19
24
  tex: Texture
20
25
  name: string
@@ -63,7 +68,7 @@ export async function planTexture(tex: Texture, root: Root, maxSize = DEFAULT_MA
63
68
  return null // undecodable by sharp — leave it
64
69
  }
65
70
 
66
- const resize = Math.max(width, height) > maxSize
71
+ const resize = !isImpostorAtlas(tex) && Math.max(width, height) > maxSize
67
72
  let desired: 'jpeg' | 'png' = forceFmt ?? (hasAlpha ? 'png' : 'jpeg') // PNG only when alpha is needed
68
73
  if (desired === 'jpeg' && hasAlpha) desired = 'png' // never drop a real alpha channel
69
74
 
@@ -1,4 +1,4 @@
1
- // MIRROR of packages/viewer/src/viewer/validateGlb.ts (kept in sync by hand — the viewer copy is the source of truth;
1
+ // MIRROR of runtime/web/src/viewer/validateGlb.ts (kept in sync by hand — the viewer copy is the source of truth;
2
2
  // duplicated so lecodes-assets stays publishable without a cross-package import).
3
3
  // Pre-flight GLB validator — runs in the browser and in Bun (no Node/DOM APIs, no deps).
4
4
  //
@@ -17,7 +17,7 @@
17
17
  export const MAX_BONE_COUNT = 256
18
18
  export const MAX_MORPH_TARGET_COUNT = 256
19
19
 
20
- // Texture formats with a provider registered in creator-gl (see packages/creator-gl/src/glb.cpp).
20
+ // Texture formats with a provider registered in creator-gl (see engines/gl/src/glb.cpp).
21
21
  export const SUPPORTED_IMAGE_FORMATS = ['png', 'jpeg', 'ktx2'] as const
22
22
  export type ImageFormat = (typeof SUPPORTED_IMAGE_FORMATS)[number] | 'webp' | 'unknown'
23
23
 
@@ -0,0 +1,333 @@
1
+ // VERTEX AMBIENT OCCLUSION FOR VEGETATION — the sky's visibility from every vertex of a plant, baked into
2
+ // COLOR_0.r at import (what SpeedTree bakes for its trees, and the one thing that makes a crown read as
3
+ // a volume instead of a heap of green cards: the leaves deep inside take less sky than the ones on the
4
+ // outside, and the trunk under the crown stands in its shade).
5
+ //
6
+ // The convention the vegetation tier reads (creator-gl materials/src/foliage.mat):
7
+ // COLOR_0 = (ao, cavity, 1, wind) ao = sky visibility 0..1 (cosine-weighted upper hemisphere, WORLD up)
8
+ // cavity = the bark's short-range occlusion 0..1 (the hemisphere of its own normal,
9
+ // hits within `cavity` metres: limb joints, the trunk under a limb); cards 1
10
+ // wind = the per-vertex wind weight — the pack's own (COLOR_0.a as it came) when
11
+ // its material carries a wind profile (`lecodes.wind`), else the height
12
+ // over the plant squared
13
+ // An exact white (1, 1, 1, 1) is what gltfio hands a mesh WITHOUT a colour set, and the shader takes that
14
+ // as "no data" (ao 1, height weight) — a vertex whose ao AND wind both round to 1 lands there too, and it
15
+ // is exactly the vertex nothing changes for.
16
+ //
17
+ // Occluders = every triangle of the asset's level-0 meshes (`_LOD<n>` n ≥ 1 are copies of the same
18
+ // plant), cards counted opaque — a coarse hit for an alpha-cutout leaf, which over-darkens a little and
19
+ // is what the shader's strength knob is for. A BVH over the triangles, `rays` cosine-weighted rays per
20
+ // vertex from a fixed sequence rotated per vertex, rays start `bias` metres off the surface.
21
+
22
+ import type { Document, Primitive } from "@gltf-transform/core"
23
+
24
+ type Tri = { ax: number, ay: number, az: number, bx: number, by: number, bz: number, cx: number, cy: number, cz: number }
25
+ type BvhNode = { min: [number, number, number], max: [number, number, number], left: number, right: number, first: number, count: number }
26
+
27
+ const buildBvh = (tris: Tri[]): { nodes: BvhNode[], order: number[] } => {
28
+ const order = tris.map((_, i) => i)
29
+ const nodes: BvhNode[] = []
30
+ const cx = (t: Tri): number => (t.ax + t.bx + t.cx) / 3, cy = (t: Tri): number => (t.ay + t.by + t.cy) / 3, cz = (t: Tri): number => (t.az + t.bz + t.cz) / 3
31
+ const build = (first: number, count: number): number => {
32
+ const min: [number, number, number] = [ Infinity, Infinity, Infinity ], max: [number, number, number] = [ -Infinity, -Infinity, -Infinity ]
33
+ for (let i = first; i < first + count; i++) {
34
+ const t = tris[order[i]]
35
+ min[0] = Math.min(min[0], t.ax, t.bx, t.cx); min[1] = Math.min(min[1], t.ay, t.by, t.cy); min[2] = Math.min(min[2], t.az, t.bz, t.cz)
36
+ max[0] = Math.max(max[0], t.ax, t.bx, t.cx); max[1] = Math.max(max[1], t.ay, t.by, t.cy); max[2] = Math.max(max[2], t.az, t.bz, t.cz)
37
+ }
38
+ const id = nodes.length
39
+ nodes.push({ min, max, left: -1, right: -1, first, count })
40
+ if (count <= 8) return id
41
+ const ext = [ max[0] - min[0], max[1] - min[1], max[2] - min[2] ]
42
+ const axis = ext[0] >= ext[1] && ext[0] >= ext[2] ? 0 : ext[1] >= ext[2] ? 1 : 2
43
+ const key = axis === 0 ? cx : axis === 1 ? cy : cz
44
+ const slice = order.slice(first, first + count).sort((a, b) => key(tris[a]) - key(tris[b]))
45
+ for (let i = 0; i < count; i++) order[first + i] = slice[i]
46
+ const half = count >> 1
47
+ const n = nodes[id]
48
+ n.count = 0
49
+ n.left = build(first, half)
50
+ n.right = build(first + half, count - half)
51
+ return id
52
+ }
53
+ if (tris.length) build(0, tris.length)
54
+ return { nodes, order }
55
+ }
56
+
57
+ /** Möller–Trumbore, both sides: the hit distance in (tmin, tmax), or -1. */
58
+ const triDist = (t: Tri, ox: number, oy: number, oz: number, dx: number, dy: number, dz: number, tmin: number, tmax: number): number => {
59
+ const e1x = t.bx - t.ax, e1y = t.by - t.ay, e1z = t.bz - t.az
60
+ const e2x = t.cx - t.ax, e2y = t.cy - t.ay, e2z = t.cz - t.az
61
+ const px = dy * e2z - dz * e2y, py = dz * e2x - dx * e2z, pz = dx * e2y - dy * e2x
62
+ const det = e1x * px + e1y * py + e1z * pz
63
+ if (Math.abs(det) < 1e-9) return -1
64
+ const inv = 1 / det
65
+ const tx = ox - t.ax, ty = oy - t.ay, tz = oz - t.az
66
+ const u = (tx * px + ty * py + tz * pz) * inv
67
+ if (u < 0 || u > 1) return -1
68
+ const qx = ty * e1z - tz * e1y, qy = tz * e1x - tx * e1z, qz = tx * e1y - ty * e1x
69
+ const v = (dx * qx + dy * qy + dz * qz) * inv
70
+ if (v < 0 || u + v > 1) return -1
71
+ const d = (e2x * qx + e2y * qy + e2z * qz) * inv
72
+ return d > tmin && d < tmax ? d : -1
73
+ }
74
+ const hitTri = (t: Tri, ox: number, oy: number, oz: number, dx: number, dy: number, dz: number, tmin: number, tmax: number): boolean =>
75
+ triDist(t, ox, oy, oz, dx, dy, dz, tmin, tmax) >= 0
76
+
77
+ const hitBox = (n: BvhNode, ox: number, oy: number, oz: number, ix: number, iy: number, iz: number, tmax: number): boolean => {
78
+ let t0 = 0, t1 = tmax
79
+ const a = (n.min[0] - ox) * ix, b = (n.max[0] - ox) * ix; t0 = Math.max(t0, Math.min(a, b)); t1 = Math.min(t1, Math.max(a, b))
80
+ const c = (n.min[1] - oy) * iy, d = (n.max[1] - oy) * iy; t0 = Math.max(t0, Math.min(c, d)); t1 = Math.min(t1, Math.max(c, d))
81
+ const e = (n.min[2] - oz) * iz, f = (n.max[2] - oz) * iz; t0 = Math.max(t0, Math.min(e, f)); t1 = Math.min(t1, Math.max(e, f))
82
+ return t1 >= t0
83
+ }
84
+
85
+ const occluded = (bvh: { nodes: BvhNode[], order: number[] }, tris: Tri[], ox: number, oy: number, oz: number, dx: number, dy: number, dz: number, tmin: number, tmax: number): boolean => {
86
+ if (!bvh.nodes.length) return false
87
+ const ix = 1 / (dx || 1e-12), iy = 1 / (dy || 1e-12), iz = 1 / (dz || 1e-12)
88
+ const stack = [ 0 ]
89
+ while (stack.length) {
90
+ const n = bvh.nodes[stack.pop()!]
91
+ if (!hitBox(n, ox, oy, oz, ix, iy, iz, tmax)) continue
92
+ if (n.left < 0) {
93
+ for (let i = n.first; i < n.first + n.count; i++) if (hitTri(tris[bvh.order[i]], ox, oy, oz, dx, dy, dz, tmin, tmax)) return true
94
+ } else { stack.push(n.left, n.right) }
95
+ }
96
+ return false
97
+ }
98
+
99
+ /** The nearest hit along the ray in (tmin, tmax), or Infinity. */
100
+ const nearestHit = (bvh: { nodes: BvhNode[], order: number[] }, tris: Tri[], ox: number, oy: number, oz: number, dx: number, dy: number, dz: number, tmin: number, tmax: number): number => {
101
+ let best = Infinity
102
+ if (!bvh.nodes.length) return best
103
+ const ix = 1 / (dx || 1e-12), iy = 1 / (dy || 1e-12), iz = 1 / (dz || 1e-12)
104
+ const stack = [ 0 ]
105
+ while (stack.length) {
106
+ const n = bvh.nodes[stack.pop()!]
107
+ if (!hitBox(n, ox, oy, oz, ix, iy, iz, Math.min(tmax, best))) continue
108
+ if (n.left < 0) {
109
+ for (let i = n.first; i < n.first + n.count; i++) {
110
+ const d = triDist(tris[bvh.order[i]], ox, oy, oz, dx, dy, dz, tmin, Math.min(tmax, best))
111
+ if (d >= 0 && d < best) best = d
112
+ }
113
+ } else { stack.push(n.left, n.right) }
114
+ }
115
+ return best
116
+ }
117
+
118
+ export type VertexAoOptions = {
119
+ /** Rays per vertex (default 48). */
120
+ rays?: number
121
+ /** Metres a ray starts off its vertex (default 0.02). */
122
+ bias?: number
123
+ /** Metres a ray travels (default: the asset's extent). */
124
+ maxDistance?: number
125
+ /** CAVITY (junction) occlusion for the OPAQUE primitives — the bark: `cavityRays` cosine-weighted rays in the
126
+ * hemisphere of the vertex' own normal, a hit within `cavity` metres counts by (1 - distance / cavity) → COLOR_0.g,
127
+ * 1 = open. The base of a limb and the trunk around it darken, the joint reads as grown, not laid on. Leaf cards
128
+ * keep 1 (their own neighbours would darken every crown). Default 0.8 m, 0 = off. */
129
+ cavity?: number
130
+ /** Rays per vertex for the cavity term (default 24). */
131
+ cavityRays?: number
132
+ /** CANOPY NORMALS: how far the normals of the alpha-MASK primitives (the leaf cards) are bent toward the
133
+ * direction from the crown's centre — 0 keeps the cards' own (each card lit as a flat plane: a heap of
134
+ * cut-outs), 1 makes the crown shade like one sphere. Default 0.6 — what a tree tool bakes. */
135
+ canopy?: number
136
+ }
137
+
138
+ const LOD_RE = /_LOD([1-9]\d*)$/i
139
+
140
+ /** True when the primitive's material carries the pack's wind profile (glTF extras `lecodes.wind`, kindGlb.ts
141
+ * windProfile): the one case a vertex alpha is an authored wind weight. */
142
+ const windProfiled = (p: Primitive): boolean => {
143
+ const extras = p.getMaterial()?.getExtras() as { lecodes?: { wind?: unknown } } | null | undefined
144
+ return Array.isArray(extras?.lecodes?.wind)
145
+ }
146
+
147
+ /** World-space triangles of every level-0 mesh node. */
148
+ const collectOccluders = (doc: Document): { tris: Tri[], min: [number, number, number], max: [number, number, number] } => {
149
+ const tris: Tri[] = []
150
+ const min: [number, number, number] = [ Infinity, Infinity, Infinity ], max: [number, number, number] = [ -Infinity, -Infinity, -Infinity ]
151
+ for (const node of doc.getRoot().listNodes()) {
152
+ const mesh = node.getMesh()
153
+ if (!mesh || LOD_RE.test(node.getName())) continue
154
+ const m = node.getWorldMatrix()
155
+ for (const p of mesh.listPrimitives()) {
156
+ const pos = p.getAttribute("POSITION"), idx = p.getIndices()
157
+ if (!pos || p.getMode() !== 4) continue
158
+ const n = idx ? idx.getCount() : pos.getCount()
159
+ const v: number[] = []
160
+ const at = (k: number): [number, number, number] => {
161
+ pos.getElement(idx ? idx.getScalar(k) : k, v)
162
+ const x = m[0] * v[0] + m[4] * v[1] + m[8] * v[2] + m[12], y = m[1] * v[0] + m[5] * v[1] + m[9] * v[2] + m[13], z = m[2] * v[0] + m[6] * v[1] + m[10] * v[2] + m[14]
163
+ min[0] = Math.min(min[0], x); min[1] = Math.min(min[1], y); min[2] = Math.min(min[2], z)
164
+ max[0] = Math.max(max[0], x); max[1] = Math.max(max[1], y); max[2] = Math.max(max[2], z)
165
+ return [ x, y, z ]
166
+ }
167
+ for (let k = 0; k + 2 < n; k += 3) {
168
+ const a = at(k), b = at(k + 1), c = at(k + 2)
169
+ tris.push({ ax: a[0], ay: a[1], az: a[2], bx: b[0], by: b[1], bz: b[2], cx: c[0], cy: c[1], cz: c[2] })
170
+ }
171
+ }
172
+ }
173
+ return { tris, min, max }
174
+ }
175
+
176
+ /** Cosine-weighted directions on the upper hemisphere (world +Y), a fixed Fibonacci set. */
177
+ const skyRays = (count: number): [number, number, number][] => {
178
+ const out: [number, number, number][] = []
179
+ const golden = Math.PI * (3 - Math.sqrt(5))
180
+ for (let i = 0; i < count; i++) {
181
+ const u = (i + 0.5) / count // cosine-weighted: y = sqrt(1 - u) puts more rays near the zenith
182
+ const y = Math.sqrt(1 - u), r = Math.sqrt(u), phi = i * golden
183
+ out.push([ r * Math.cos(phi), y, r * Math.sin(phi) ])
184
+ }
185
+ return out
186
+ }
187
+
188
+ /**
189
+ * Bake the sky visibility of every vertex of the document's meshes into COLOR_0 as (ao, 1, 1, wind) — see the
190
+ * header. Returns the number of vertices written and the mean ao (a plant reads ~0.5–0.7).
191
+ */
192
+ export const bakeVertexAo = (doc: Document, opts: VertexAoOptions = {}): { vertices: number, meanAo: number } => {
193
+ const { tris, min, max } = collectOccluders(doc)
194
+ if (!tris.length) return { vertices: 0, meanAo: 1 }
195
+ const bvh = buildBvh(tris)
196
+ const rays = skyRays(opts.rays ?? 48)
197
+ const bias = opts.bias ?? 0.02
198
+ const reach = opts.maxDistance ?? Math.hypot(max[0] - min[0], max[1] - min[1], max[2] - min[2])
199
+ const cavReach = Math.max(opts.cavity ?? 0.8, 0)
200
+ const cavRays = skyRays(opts.cavityRays ?? 24)
201
+ const height = Math.max(max[1] - min[1], 0.05)
202
+ let vertices = 0, sum = 0
203
+ const done = new Set<Primitive>()
204
+ // the crown: the centre of every leaf card's vertices (world), what the canopy normals point away from
205
+ const canopy = Math.min(Math.max(opts.canopy ?? 0.6, 0), 1)
206
+ const crown = [ 0, 0, 0 ]
207
+ let crownN = 0
208
+ if (canopy > 0) {
209
+ for (const node of doc.getRoot().listNodes()) {
210
+ const mesh = node.getMesh()
211
+ if (!mesh || LOD_RE.test(node.getName())) continue
212
+ const m = node.getWorldMatrix()
213
+ for (const p of mesh.listPrimitives()) {
214
+ if (p.getMaterial()?.getAlphaMode() !== "MASK") continue
215
+ const pos = p.getAttribute("POSITION"); if (!pos) continue
216
+ const v: number[] = []
217
+ for (let i = 0; i < pos.getCount(); i++) { pos.getElement(i, v); crown[0] += m[0] * v[0] + m[4] * v[1] + m[8] * v[2] + m[12]; crown[1] += m[1] * v[0] + m[5] * v[1] + m[9] * v[2] + m[13]; crown[2] += m[2] * v[0] + m[6] * v[1] + m[10] * v[2] + m[14]; crownN++ }
218
+ }
219
+ }
220
+ if (crownN) { crown[0] /= crownN; crown[1] /= crownN; crown[2] /= crownN }
221
+ }
222
+ for (const node of doc.getRoot().listNodes()) {
223
+ const mesh = node.getMesh()
224
+ if (!mesh) continue
225
+ const m = node.getWorldMatrix()
226
+ for (const p of mesh.listPrimitives()) {
227
+ if (done.has(p)) continue
228
+ done.add(p)
229
+ const pos = p.getAttribute("POSITION")
230
+ if (!pos) continue
231
+ const n = pos.getCount()
232
+ // canopy normals on the leaf cards: the card's normal blended toward "away from the crown's centre" (in the
233
+ // node's frame: the crown centre pulled back through the inverse world matrix, rotation-only for the normal)
234
+ const nrm = p.getAttribute("NORMAL")
235
+ if (canopy > 0 && crownN && nrm && p.getMaterial()?.getAlphaMode() === "MASK") {
236
+ const inv = invert4(m)
237
+ const cl = inv ? [ inv[0] * crown[0] + inv[4] * crown[1] + inv[8] * crown[2] + inv[12], inv[1] * crown[0] + inv[5] * crown[1] + inv[9] * crown[2] + inv[13], inv[2] * crown[0] + inv[6] * crown[1] + inv[10] * crown[2] + inv[14] ] : null
238
+ if (cl) {
239
+ const out = new Float32Array(new ArrayBuffer(n * 12)), v: number[] = [], q: number[] = []
240
+ for (let i = 0; i < n; i++) {
241
+ pos.getElement(i, v); nrm.getElement(i, q)
242
+ let dx = v[0] - cl[0], dy = v[1] - cl[1], dz = v[2] - cl[2]
243
+ const l = Math.hypot(dx, dy, dz) || 1
244
+ dx /= l; dy /= l; dz /= l
245
+ // a card facing the wrong way is flipped into the hemisphere of its radial first (double-sided cards
246
+ // have no side of their own), then blended
247
+ const flip = q[0] * dx + q[1] * dy + q[2] * dz < 0 ? -1 : 1
248
+ let nx = q[0] * flip * (1 - canopy) + dx * canopy, ny = q[1] * flip * (1 - canopy) + dy * canopy, nz = q[2] * flip * (1 - canopy) + dz * canopy
249
+ const nl = Math.hypot(nx, ny, nz) || 1
250
+ out[i * 3] = nx / nl; out[i * 3 + 1] = ny / nl; out[i * 3 + 2] = nz / nl
251
+ }
252
+ p.setAttribute("NORMAL", doc.createAccessor().setType("VEC3").setArray(out).setBuffer(doc.getRoot().listBuffers()[0] ?? doc.createBuffer()))
253
+ if (nrm.listParents().length <= 1) nrm.dispose()
254
+ }
255
+ }
256
+ const old = p.getAttribute("COLOR_0")
257
+ const oldEl = old ? old.getElementSize() : 0
258
+ const authoredWind = windProfiled(p)
259
+ const out = new Float32Array(new ArrayBuffer(n * 16))
260
+ const v: number[] = [], c: number[] = [], q: number[] = []
261
+ // the cavity term: the bark (opaque) with a normal, rays in the hemisphere of the vertex' world normal
262
+ const cavNrm = cavReach > 0 && p.getMaterial()?.getAlphaMode() !== "MASK" ? p.getAttribute("NORMAL") : null
263
+ for (let i = 0; i < n; i++) {
264
+ pos.getElement(i, v)
265
+ const x = m[0] * v[0] + m[4] * v[1] + m[8] * v[2] + m[12], y = m[1] * v[0] + m[5] * v[1] + m[9] * v[2] + m[13], z = m[2] * v[0] + m[6] * v[1] + m[10] * v[2] + m[14]
266
+ // a per-vertex rotation of the fixed set about +Y, so neighbours never share a banding pattern
267
+ const rot = ((x * 12.9898 + z * 78.233 + y * 37.719) % 1 + 1) % 1 * Math.PI * 2, cr = Math.cos(rot), sr = Math.sin(rot)
268
+ let open = 0
269
+ for (const d of rays) {
270
+ const dx = d[0] * cr - d[2] * sr, dz = d[0] * sr + d[2] * cr
271
+ if (!occluded(bvh, tris, x + dx * bias, y + d[1] * bias, z + dz * bias, dx, d[1], dz, 0, reach)) open++
272
+ }
273
+ const ao = open / rays.length
274
+ let cav = 1
275
+ if (cavNrm) {
276
+ nrmOf(cavNrm, i, m, q)
277
+ // a tangent frame about the normal: the +Y ray set turned into it, and the per-vertex rotation kept
278
+ const ax = Math.abs(q[1]) < 0.9 ? 0 : 1, ay = ax ? 0 : 1
279
+ let tx = ay * q[2] - 0, ty = 0 - ax * q[2], tz = ax * q[1] - ay * q[0] // cross(n, a) for a = (ax, ay, 0)
280
+ const tl = Math.hypot(tx, ty, tz) || 1; tx /= tl; ty /= tl; tz /= tl
281
+ const bx = q[1] * tz - q[2] * ty, by = q[2] * tx - q[0] * tz, bz = q[0] * ty - q[1] * tx // cross(n, t)
282
+ let occ = 0
283
+ for (const d of cavRays) {
284
+ const rx = d[0] * cr - d[2] * sr, rz = d[0] * sr + d[2] * cr
285
+ const dx = tx * rx + q[0] * d[1] + bx * rz, dy = ty * rx + q[1] * d[1] + by * rz, dz = tz * rx + q[2] * d[1] + bz * rz
286
+ // the origin is lifted off the vertex' OWN surface along its normal only, and a hit at distance 0 counts: a limb's
287
+ // base vertex lies ON the trunk, and the rays into the trunk meet its surface right there - that IS the joint
288
+ const h = nearestHit(bvh, tris, x + q[0] * bias, y + q[1] * bias, z + q[2] * bias, dx, dy, dz, -1e-6, cavReach)
289
+ if (h < cavReach) occ += 1 - h / cavReach
290
+ }
291
+ cav = 1 - occ / cavRays.length
292
+ }
293
+ // the wind weight: the pack's own alpha when its shader AUTHORED one there (the material carries a wind profile,
294
+ // `lecodes.wind` - NatureManufacture paints the weight into the vertex alpha), else the height over the plant,
295
+ // squared. A pack whose wind mask lives in a texture (Chernobyl's Leaves.shader) ships the alpha at 1 everywhere,
296
+ // and taken as the weight that shivered every leaf by the field's full amplitude (2026-11-03).
297
+ let wind: number
298
+ if (old && oldEl === 4 && authoredWind) { old.getElement(i, c); wind = c[3] } else { const h = Math.min(Math.max((y - min[1]) / height, 0), 1); wind = h * h }
299
+ out[i * 4] = ao; out[i * 4 + 1] = cav; out[i * 4 + 2] = 1; out[i * 4 + 3] = wind
300
+ sum += ao; vertices++
301
+ }
302
+ const acc = doc.createAccessor().setType("VEC4").setArray(out).setBuffer(doc.getRoot().listBuffers()[0] ?? doc.createBuffer())
303
+ p.setAttribute("COLOR_0", acc)
304
+ if (old && old.listParents().length <= 1) old.dispose()
305
+ }
306
+ }
307
+ return { vertices, meanAo: vertices ? sum / vertices : 1 }
308
+ }
309
+
310
+ /** The vertex' normal in world space (the node's rotation part, then normalised), into `q`. */
311
+ const nrmOf = (nrm: { getElement(i: number, out: number[]): number[] }, i: number, m: ArrayLike<number>, q: number[]): void => {
312
+ nrm.getElement(i, q)
313
+ const x = m[0] * q[0] + m[4] * q[1] + m[8] * q[2], y = m[1] * q[0] + m[5] * q[1] + m[9] * q[2], z = m[2] * q[0] + m[6] * q[1] + m[10] * q[2]
314
+ const l = Math.hypot(x, y, z) || 1
315
+ q[0] = x / l; q[1] = y / l; q[2] = z / l
316
+ }
317
+
318
+ /** 4×4 column-major inverse (null when singular). */
319
+ const invert4 = (m: ArrayLike<number>): number[] | null => {
320
+ const a = Array.from(m), r = new Array<number>(16)
321
+ const [ a00, a01, a02, a03, a10, a11, a12, a13, a20, a21, a22, a23, a30, a31, a32, a33 ] = a
322
+ const b00 = a00 * a11 - a01 * a10, b01 = a00 * a12 - a02 * a10, b02 = a00 * a13 - a03 * a10, b03 = a01 * a12 - a02 * a11
323
+ const b04 = a01 * a13 - a03 * a11, b05 = a02 * a13 - a03 * a12, b06 = a20 * a31 - a21 * a30, b07 = a20 * a32 - a22 * a30
324
+ const b08 = a20 * a33 - a23 * a30, b09 = a21 * a32 - a22 * a31, b10 = a21 * a33 - a23 * a31, b11 = a22 * a33 - a23 * a32
325
+ let det = b00 * b11 - b01 * b10 + b02 * b09 + b03 * b08 - b04 * b07 + b05 * b06
326
+ if (Math.abs(det) < 1e-12) return null
327
+ det = 1 / det
328
+ r[0] = (a11 * b11 - a12 * b10 + a13 * b09) * det; r[1] = (a02 * b10 - a01 * b11 - a03 * b09) * det; r[2] = (a31 * b05 - a32 * b04 + a33 * b03) * det; r[3] = (a22 * b04 - a21 * b05 - a23 * b03) * det
329
+ r[4] = (a12 * b08 - a10 * b11 - a13 * b07) * det; r[5] = (a00 * b11 - a02 * b08 + a03 * b07) * det; r[6] = (a32 * b02 - a30 * b05 - a33 * b01) * det; r[7] = (a20 * b05 - a22 * b02 + a23 * b01) * det
330
+ r[8] = (a10 * b10 - a11 * b08 + a13 * b06) * det; r[9] = (a01 * b08 - a00 * b10 - a03 * b06) * det; r[10] = (a30 * b04 - a31 * b02 + a33 * b00) * det; r[11] = (a21 * b02 - a20 * b04 - a23 * b00) * det
331
+ r[12] = (a11 * b07 - a10 * b09 - a12 * b06) * det; r[13] = (a00 * b09 - a01 * b07 + a02 * b06) * det; r[14] = (a31 * b01 - a30 * b03 - a32 * b00) * det; r[15] = (a20 * b03 - a21 * b01 + a22 * b00) * det
332
+ return r
333
+ }