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
@@ -0,0 +1,761 @@
1
+ // The lightmap unwrap — `lecodes assets unwrap` (./index.ts), and `lecodes lightmap bake` before it bakes: a second
2
+ // texture-coordinate set (TEXCOORD_1) that lays every triangle of the file out in ONE non-overlapping unit square, made
3
+ // by xatlas (vendor/xatlas, run as wasm — src/wasm/lightmapUv.js). This is what the lightmap bake samples and writes
4
+ // (engines/bake): each placed instance of the prop gets a rectangle of the scene atlas, so the props stay clones and one
5
+ // unwrap per file is enough.
6
+ //
7
+ // WHAT COUNTS IS THE CUTTING. The bake re-packs a file's uv1-connected charts itself (engines/bake README, "THE CHARTS
8
+ // ARE PACKED PER FILE": MaxRects at the instances' density, its own gutter, the `<stem>-uv1.bin` sidecar), so xatlas's
9
+ // packing here only matters where the bake keeps the unwrap's square as it is (a file whose TEXCOORD_1 a texture reads,
10
+ // a mesh under two groups, CBK_PACK_CHARTS=0): the padding is a fixed UNWRAP_PADDING texels of the bake's texel
11
+ // (`--texel`). The decisions the bake cannot take back are the charts, the groups and the zone's texel scales.
12
+ //
13
+ // xatlas RE-INDEXES the mesh: a vertex that sits on a chart seam is split into one copy per chart,
14
+ // and every output vertex carries `xref`, the input vertex it came from. So this pass rebuilds every
15
+ // attribute (positions, normals, UV0, colours, joints/weights, tangents, morph targets) by that map,
16
+ // preserving each accessor's component type and normalization, and widens the index buffer past
17
+ // 65535 when the split pushes it there. Skinned meshes are left alone: a lightmap is baked in ONE
18
+ // pose, and a rigged character never stays in it.
19
+ //
20
+ // GROUPS (`--groups <n>`): ONE unit square = ONE atlas rect in the bake, and a rect cannot span
21
+ // pages — a whole level exported as one GLB (the arena: 133 m, 800k triangles) got one 8192 page at 7 cm
22
+ // and no way to ask for more. With groups the mesh nodes are split into n parts of equal surface by
23
+ // bisecting their world centroids (neighbours stay together), each part is unwrapped into a unit square
24
+ // of ITS OWN, and every node carries `extras.lightmapGroup = k`. The bake describes one instance per
25
+ // group (`<key>#k`, creator-gl lightmap_bake.cpp), the runtime binds each group's page and rect to the
26
+ // group's renderables (glb.cpp lightmapBind) — one file, no texture paid twice.
27
+ //
28
+ // THE PLAYER'S ZONE (`--zone "x0,y0,z0,x1,y1,z1[;…]"`, with groups): a level is not seen from everywhere —
29
+ // an arena's player walks the sand, and 5 cm texels on tiers 40 m away are texels nobody can tell apart. The zone is
30
+ // the boxes the player can be in (world metres; the entrances of a passage count, the dark passage behind need not).
31
+ // A triangle's TEXEL SCALE follows its distance d to the zone: 1 within `--zone-near` (12 m), 2 to twice
32
+ // that, 4 beyond — a texel stays about the same size ON SCREEN. Scales never share a group: a large mesh that spans
33
+ // them (the seating bowl) is cut along them, a small one takes its finest. A group of scale s is unwrapped with its
34
+ // padding measured in ITS texels, carries `extras.lightmapTexelScale = s`, and the bake lays it out s times coarser
35
+ // (CbakeInstanceDesc.texelScale): a 16th of the texels at 4.
36
+ //
37
+ // LOD siblings (`<name>_LOD<n>`, n ≥ 1 — the kit's own or `doctor --lod`'s) are NOT unwrapped: the
38
+ // engine's lightmap pass traces level 0 only and binds ONE atlas rect per instance, which every
39
+ // level reads. So level 0 gets the xatlas layout and the LODs get level 0's UV1 by PROJECTION: each
40
+ // LOD triangle's centroid picks the closest level-0 triangle and with it a chart, and each corner
41
+ // takes the UV of its closest point on that chart (a corner that straddles charts is split, like a
42
+ // seam vertex). A decimated surface then samples the very texels its full-detail twin was traced
43
+ // into — no atlas paid twice, no LOD reading texels nothing lit (the two-features-corrupt-each-other
44
+ // bug of fps-demo FEEDBACK 92).
45
+ import type { Accessor, Document, Mesh, Node as GltfNode, Primitive } from '@gltf-transform/core'
46
+ // @ts-ignore — generated by build-wasm.sh (Emscripten single-file module)
47
+ import createLightmapUv from '../wasm/lightmapUv.js'
48
+
49
+ const TRIANGLES = 4
50
+
51
+ type Module = {
52
+ _malloc(n: number): number
53
+ _free(p: number): void
54
+ _lmuv_begin(hint: number): number
55
+ _lmuv_add_mesh(h: number, pos: number, normals: number, uv: number, vertexCount: number, indices: number, indexCount: number): number
56
+ _lmuv_generate(h: number, padding: number, iterations: number, texelsPerUnit: number): number
57
+ _lmuv_width(h: number): number
58
+ _lmuv_height(h: number): number
59
+ _lmuv_chart_count(h: number): number
60
+ _lmuv_mesh_count(h: number): number
61
+ _lmuv_mesh_vertex_count(h: number, i: number): number
62
+ _lmuv_mesh_index_count(h: number, i: number): number
63
+ _lmuv_mesh_xref(h: number, i: number): number
64
+ _lmuv_mesh_uv(h: number, i: number): number
65
+ _lmuv_mesh_indices(h: number, i: number): number
66
+ _lmuv_error(): number
67
+ _lmuv_end(h: number): void
68
+ HEAPU8: Uint8Array
69
+ HEAPU32: Uint32Array
70
+ HEAPF32: Float32Array
71
+ UTF8ToString(ptr: number): string
72
+ }
73
+
74
+ let modulePromise: Promise<Module> | undefined
75
+ const getModule = (): Promise<Module> => (modulePromise ??= (createLightmapUv as () => Promise<Module>)())
76
+
77
+ export interface UnwrapOptions {
78
+ /** the bake's texel in metres the (fixed) chart padding is measured in (0.05, `lecodes lightmap bake --texel`); 0 = xatlas's auto density */
79
+ texel?: number
80
+ /** xatlas chart-growing iterations — more = better charts, slower (default 1) */
81
+ iterations?: number
82
+ /** split the file's meshes into this many groups, each with a unit square (an atlas rect) of its own (default 1) */
83
+ groups?: number
84
+ /** the boxes the player can be in, world metres: "x0,y0,z0,x1,y1,z1[;…]" — what is far from them gets a coarser texel (needs groups) */
85
+ zone?: string
86
+ /** the distance to the zone within which the texel is the full one; the scale doubles every such distance past it (default 12) */
87
+ zoneNear?: number
88
+ /** the coarsest texel scale, a power of two: the doubling stops there (default 4 = twenty centimetres at a 5 cm texel) */
89
+ zoneMax?: number
90
+ /** the weight of the VERTICAL distance to the zone (default 1): 2 = a metre of height counts as two metres of reach, so
91
+ * an arena's upper tiers go coarse before the far end of the pitch does */
92
+ zoneUp?: number
93
+ }
94
+
95
+ export interface UnwrapPrim { mesh: Mesh; prim: Primitive; name: string; triangles: number; vertices: number; hasUv1: boolean }
96
+ /** A LOD sibling's primitive: takes its UV1 from `baseNode`'s (level-0) mesh by projection. */
97
+ export interface UnwrapLod { mesh: Mesh; prim: Primitive; node: GltfNode; baseNode: GltfNode; level: number; name: string; triangles: number; hasUv1: boolean }
98
+ export interface UnwrapPlan {
99
+ prims: UnwrapPrim[]
100
+ lods: UnwrapLod[]
101
+ skipped: { name: string; reason: string }[]
102
+ padding: number
103
+ iterations: number
104
+ /** the bake's density the padding is measured in: 1 / its texel (metres); 0 = xatlas's own auto density */
105
+ texelsPerUnit: number
106
+ /** how many unit squares the file's meshes are split into (1 = the classic single layout) */
107
+ groups: number
108
+ /** the player's zone: boxes [x0,y0,z0,x1,y1,z1] in world metres (empty = one texel everywhere) and its near distance */
109
+ zones: number[][]
110
+ zoneNear: number
111
+ zoneMax: number
112
+ zoneUp: number
113
+ }
114
+
115
+ /** texels between charts in xatlas's own packing — what the bake reads only where it keeps the unwrap's square (see the top) */
116
+ export const UNWRAP_PADDING = 4
117
+ /** `lecodes lightmap bake`'s default texel (metres): the unwrap keeps UNWRAP_PADDING texels of THIS size between charts */
118
+ export const DEFAULT_UNWRAP_TEXEL = 0.05
119
+
120
+ const LOD_SUFFIX = /_LOD(\d+)$/
121
+ /** "<base>_LOD<n>" → n (≥ 1), else 0 — the suffix creator-gl's lod.cpp groups renderables by. */
122
+ export const lodLevelOf = (name: string): number => { const m = LOD_SUFFIX.exec(name); return m ? Number(m[1]) : 0 }
123
+
124
+ const primName = (mesh: Mesh, i: number): string => `${mesh.getName() || 'mesh'}${mesh.listPrimitives().length > 1 ? `[${i}]` : ''}`
125
+
126
+ const parseZones = (text: string | undefined): number[][] => {
127
+ if (!text) return []
128
+ return text.split(';').map((t) => t.trim()).filter(Boolean).map((t) => {
129
+ const v = t.split(',').map(Number)
130
+ if (v.length !== 6 || v.some((x) => !Number.isFinite(x))) throw new Error(`--zone: "${t}" is not x0,y0,z0,x1,y1,z1`)
131
+ return [ Math.min(v[0], v[3]), Math.min(v[1], v[4]), Math.min(v[2], v[5]), Math.max(v[0], v[3]), Math.max(v[1], v[4]), Math.max(v[2], v[5]) ]
132
+ })
133
+ }
134
+
135
+ /** The texel scale of a world point: 1 within `near` of the zone, then doubling every `near` metres (2 to twice near, 4 to three
136
+ * times, 8 …) up to `zoneMax` (1 when there is no zone). The distance is to the zone's boxes, the vertical part weighed by
137
+ * `zoneUp` (the arena, 2026-09-18: with 1 the upper tiers, 15-20 m up and 25 m out, stayed at x4; with 2 they are x8 / x16). */
138
+ const texelScaleAt = (plan: UnwrapPlan, x: number, y: number, z: number): number => {
139
+ if (plan.zones.length === 0) return 1
140
+ let d = Infinity
141
+ for (const b of plan.zones) {
142
+ const dx = Math.max(b[0] - x, 0, x - b[3]), dy = Math.max(b[1] - y, 0, y - b[4]), dz = Math.max(b[2] - z, 0, z - b[5])
143
+ d = Math.min(d, Math.hypot(dx, dy * plan.zoneUp, dz))
144
+ }
145
+ return Math.min(plan.zoneMax, 2 ** Math.floor(d / plan.zoneNear))
146
+ }
147
+
148
+ /** What the unwrap would do to `doc` (pure): the primitives it lays out, the LOD siblings it projects, what it leaves. */
149
+ export function planUnwrap(doc: Document, opts: UnwrapOptions = {}): UnwrapPlan {
150
+ const root = doc.getRoot()
151
+ const skinnedMeshes = new Set<Mesh>()
152
+ const byName = new Map<string, GltfNode>()
153
+ for (const node of root.listNodes()) {
154
+ const mesh = node.getMesh()
155
+ if (mesh && node.getSkin()) skinnedMeshes.add(mesh)
156
+ if (mesh && node.getName() && !byName.has(node.getName())) byName.set(node.getName(), node)
157
+ }
158
+ // LOD siblings: a mesh drawn by a `<base>_LOD<n≥1>` node whose level-0 node (`<base>` or
159
+ // `<base>_LOD0`) has a mesh of its own; a mesh SHARED with level 0 needs nothing.
160
+ const lodMeshes = new Map<Mesh, { node: GltfNode; baseNode: GltfNode; level: number }>()
161
+ const level0Meshes = new Set<Mesh>()
162
+ for (const node of root.listNodes()) {
163
+ const mesh = node.getMesh()
164
+ if (!mesh) continue
165
+ if (lodLevelOf(node.getName()) === 0) level0Meshes.add(mesh)
166
+ }
167
+ for (const node of root.listNodes()) {
168
+ const mesh = node.getMesh()
169
+ if (!mesh || level0Meshes.has(mesh) || lodMeshes.has(mesh)) continue
170
+ const level = lodLevelOf(node.getName())
171
+ if (level === 0) continue
172
+ const base = node.getName().replace(LOD_SUFFIX, '')
173
+ const baseNode = byName.get(base) ?? byName.get(`${base}_LOD0`)
174
+ if (!baseNode || !baseNode.getMesh() || baseNode.getMesh() === mesh || skinnedMeshes.has(baseNode.getMesh()!)) continue
175
+ lodMeshes.set(mesh, { node, baseNode, level })
176
+ }
177
+ const prims: UnwrapPrim[] = []
178
+ const lods: UnwrapLod[] = []
179
+ const skipped: { name: string; reason: string }[] = []
180
+ for (const mesh of root.listMeshes()) {
181
+ const lod = lodMeshes.get(mesh)
182
+ mesh.listPrimitives().forEach((prim, i) => {
183
+ const name = primName(mesh, i)
184
+ const pos = prim.getAttribute('POSITION')
185
+ if (!pos || pos.getCount() === 0) { skipped.push({ name, reason: 'no positions' }); return }
186
+ if (prim.getMode() !== TRIANGLES) { skipped.push({ name, reason: 'not a triangle list' }); return }
187
+ if (skinnedMeshes.has(mesh)) { skipped.push({ name, reason: 'skinned' }); return }
188
+ const idx = prim.getIndices()
189
+ const triangles = Math.floor((idx ? idx.getCount() : pos.getCount()) / 3)
190
+ if (triangles === 0) { skipped.push({ name, reason: 'no triangles' }); return }
191
+ const hasUv1 = !!prim.getAttribute('TEXCOORD_1')
192
+ if (lod) { lods.push({ mesh, prim, node: lod.node, baseNode: lod.baseNode, level: lod.level, name, triangles, hasUv1 }); return }
193
+ prims.push({ mesh, prim, name, triangles, vertices: pos.getCount(), hasUv1 })
194
+ })
195
+ }
196
+ const texel = opts.texel ?? DEFAULT_UNWRAP_TEXEL
197
+ return {
198
+ prims, lods, skipped,
199
+ padding: UNWRAP_PADDING,
200
+ iterations: Math.max(1, Math.floor(opts.iterations ?? 1)),
201
+ texelsPerUnit: texel > 0 ? 1 / texel : 0,
202
+ groups: Math.max(1, Math.min(256, Math.floor(opts.groups ?? 1))),
203
+ zones: parseZones(opts.zone),
204
+ zoneNear: opts.zoneNear && opts.zoneNear > 0 ? opts.zoneNear : 12,
205
+ zoneMax: opts.zoneMax && opts.zoneMax > 1 ? 2 ** Math.round(Math.log2(opts.zoneMax)) : opts.zoneMax === 1 ? 1 : 4,
206
+ zoneUp: opts.zoneUp && opts.zoneUp > 0 ? opts.zoneUp : 1,
207
+ }
208
+ }
209
+
210
+ /** True when the unwrap has something to do: a primitive it lays out (or a LOD sibling it projects onto one) without a
211
+ * TEXCOORD_1. A file whose every such primitive carries one is left alone unless forced. */
212
+ export const planNeedsUnwrap = (plan: UnwrapPlan): boolean =>
213
+ plan.prims.length > 0 && (plan.prims.some((p) => !p.hasUv1) || plan.lods.some((l) => !l.hasUv1))
214
+
215
+ export interface UnwrapGroup { primitives: number; charts: number; width: number; height: number; area: number; texelScale: number }
216
+ export interface UnwrapResult {
217
+ primitives: number; charts: number; width: number; height: number; verticesBefore: number; verticesAfter: number
218
+ /** one entry per unit square when the file was split (`--groups`), else empty */
219
+ groups: UnwrapGroup[]
220
+ /** nodes added by cutting meshes larger than a group's share (splitLargeMeshes) */
221
+ splitNodes: number
222
+ /** LOD primitives that took level 0's layout by projection, and the vertices they hold afterwards */
223
+ lodPrimitives: number; lodVertices: number
224
+ }
225
+
226
+ /** Every element of `acc` as floats (dequantized), `size` per element. */
227
+ const floats = (acc: Accessor, size: number): Float32Array => {
228
+ const n = acc.getCount()
229
+ const out = new Float32Array(n * size)
230
+ const el: number[] = new Array(acc.getElementSize()).fill(0)
231
+ for (let i = 0; i < n; i++) { acc.getElement(i, el); for (let k = 0; k < size; k++) out[i * size + k] = el[k] ?? 0 }
232
+ return out
233
+ }
234
+
235
+ /** A copy of `acc` with its elements reordered by `xref` — same type, component type, normalization. */
236
+ const remapAccessor = (doc: Document, acc: Accessor, xref: Uint32Array, buffer: ReturnType<Document['createBuffer']>): Accessor => {
237
+ const src = acc.getArray()!
238
+ const es = acc.getElementSize()
239
+ const Ctor = src.constructor as { new (n: number): typeof src }
240
+ const out = new Ctor(xref.length * es)
241
+ for (let v = 0; v < xref.length; v++) {
242
+ const s = xref[v] * es
243
+ for (let k = 0; k < es; k++) out[v * es + k] = src[s + k]
244
+ }
245
+ return doc.createAccessor(acc.getName()).setType(acc.getType()).setNormalized(acc.getNormalized()).setArray(out).setBuffer(buffer)
246
+ }
247
+
248
+ /** Rebuild a primitive's attributes, morph targets and indices by `xref` (re-indexed geometry) and give it `uv1`. */
249
+ const reindexPrimitive = (doc: Document, prim: Primitive, xref: Uint32Array, indices32: Uint32Array<ArrayBuffer>, uv1: Float32Array<ArrayBuffer>,
250
+ buffer: ReturnType<Document['createBuffer']>, retired: Set<Accessor>): void => {
251
+ for (const semantic of prim.listSemantics()) {
252
+ const acc = prim.getAttribute(semantic)!
253
+ if (semantic === 'TEXCOORD_1') { retired.add(acc); continue } // replaced below
254
+ retired.add(acc)
255
+ prim.setAttribute(semantic, remapAccessor(doc, acc, xref, buffer))
256
+ }
257
+ for (const target of prim.listTargets()) {
258
+ for (const semantic of target.listSemantics()) {
259
+ const acc = target.getAttribute(semantic)!
260
+ retired.add(acc)
261
+ target.setAttribute(semantic, remapAccessor(doc, acc, xref, buffer))
262
+ }
263
+ }
264
+ const oldIdx = prim.getIndices()
265
+ if (oldIdx) retired.add(oldIdx)
266
+ const indices = xref.length <= 65535 ? Uint16Array.from(indices32) : indices32
267
+ prim.setIndices(doc.createAccessor().setType('SCALAR').setArray(indices).setBuffer(buffer))
268
+ prim.setAttribute('TEXCOORD_1', doc.createAccessor('lightmap_uv').setType('VEC2').setArray(uv1).setBuffer(buffer))
269
+ }
270
+
271
+ // ---- LOD projection ------------------------------------------------------------------------------
272
+
273
+ const xformPoint = (m: number[], x: number, y: number, z: number, out: Float32Array, o: number): void => {
274
+ out[o] = m[0] * x + m[4] * y + m[8] * z + m[12]
275
+ out[o + 1] = m[1] * x + m[5] * y + m[9] * z + m[13]
276
+ out[o + 2] = m[2] * x + m[6] * y + m[10] * z + m[14]
277
+ }
278
+
279
+ /** Level 0's triangles in world space with their UV1 and chart, on a uniform grid for nearest-point queries. */
280
+ class BaseSurface {
281
+ readonly tris: Float32Array // 9 floats per triangle (world positions)
282
+ readonly uvs: Float32Array // 6 floats per triangle
283
+ readonly chart: Int32Array // chart id per triangle (an xatlas chart = connected output vertices)
284
+ readonly count: number
285
+ private readonly min = [ 0, 0, 0 ]
286
+ private readonly cell: number
287
+ private readonly dims = [ 1, 1, 1 ]
288
+ private readonly cells = new Map<number, number[]>()
289
+
290
+ constructor(node: GltfNode) {
291
+ const world = node.getWorldMatrix()
292
+ const triPos: number[] = [], triUv: number[] = [], triChart: number[] = []
293
+ let chartBase = 0
294
+ for (const prim of node.getMesh()!.listPrimitives()) {
295
+ const pos = prim.getAttribute('POSITION'), uv1 = prim.getAttribute('TEXCOORD_1')
296
+ if (!pos || !uv1 || prim.getMode() !== TRIANGLES) continue
297
+ const idxAcc = prim.getIndices()
298
+ const idx = idxAcc ? idxAcc.getArray()! : Uint32Array.from({ length: pos.getCount() }, (_, i) => i)
299
+ const p = floats(pos, 3), u = floats(uv1, 2)
300
+ // charts = connected components over the (seam-split) index buffer
301
+ const parent = Int32Array.from({ length: pos.getCount() }, (_, i) => i)
302
+ const find = (a: number): number => { while (parent[a] !== a) { parent[a] = parent[parent[a]]; a = parent[a] } return a }
303
+ for (let i = 0; i + 2 < idx.length; i += 3) {
304
+ const a = find(idx[i]), b = find(idx[i + 1]), c = find(idx[i + 2])
305
+ parent[b] = a; parent[find(c)] = find(a)
306
+ }
307
+ const chartOf = new Map<number, number>()
308
+ for (let i = 0; i + 2 < idx.length; i += 3) {
309
+ const r = find(idx[i])
310
+ let ch = chartOf.get(r)
311
+ if (ch === undefined) { ch = chartBase + chartOf.size; chartOf.set(r, ch) }
312
+ const w = new Float32Array(9)
313
+ for (let k = 0; k < 3; k++) { const v = idx[i + k]; xformPoint(world, p[v * 3], p[v * 3 + 1], p[v * 3 + 2], w, k * 3); triUv.push(u[v * 2], u[v * 2 + 1]) }
314
+ for (let k = 0; k < 9; k++) triPos.push(w[k])
315
+ triChart.push(ch)
316
+ }
317
+ chartBase += chartOf.size
318
+ }
319
+ this.tris = Float32Array.from(triPos); this.uvs = Float32Array.from(triUv); this.chart = Int32Array.from(triChart)
320
+ this.count = triChart.length
321
+ if (this.count === 0) { this.cell = 1; return }
322
+ const min = [ Infinity, Infinity, Infinity ], max = [ -Infinity, -Infinity, -Infinity ]
323
+ for (let i = 0; i < this.tris.length; i += 3) for (let k = 0; k < 3; k++) { min[k] = Math.min(min[k], this.tris[i + k]); max[k] = Math.max(max[k], this.tris[i + k]) }
324
+ const ext = [ max[0] - min[0], max[1] - min[1], max[2] - min[2] ]
325
+ const longest = Math.max(ext[0], ext[1], ext[2], 1e-6)
326
+ // ~4 triangles per cell along the longest axis, 48 cells at most: a crate's 10k triangles land in a few hundred cells
327
+ this.cell = Math.max(longest / Math.min(48, Math.max(4, Math.cbrt(this.count) * 2)), 1e-5)
328
+ this.min = [ min[0] - this.cell * 0.5, min[1] - this.cell * 0.5, min[2] - this.cell * 0.5 ]
329
+ this.dims = ext.map((e) => Math.max(1, Math.ceil(e / this.cell) + 1))
330
+ for (let t = 0; t < this.count; t++) {
331
+ const lo = [ Infinity, Infinity, Infinity ], hi = [ -Infinity, -Infinity, -Infinity ]
332
+ for (let k = 0; k < 3; k++) for (let a = 0; a < 3; a++) { const v = this.tris[t * 9 + k * 3 + a]; lo[a] = Math.min(lo[a], v); hi[a] = Math.max(hi[a], v) }
333
+ const c0 = this.cellOf(lo), c1 = this.cellOf(hi)
334
+ for (let z = c0[2]; z <= c1[2]; z++) for (let y = c0[1]; y <= c1[1]; y++) for (let x = c0[0]; x <= c1[0]; x++) {
335
+ const key = this.key(x, y, z)
336
+ let list = this.cells.get(key)
337
+ if (!list) { list = []; this.cells.set(key, list) }
338
+ list.push(t)
339
+ }
340
+ }
341
+ }
342
+
343
+ private cellOf(p: ArrayLike<number>): number[] {
344
+ return [ 0, 1, 2 ].map((a) => Math.min(this.dims[a] - 1, Math.max(0, Math.floor((p[a] - this.min[a]) / this.cell))))
345
+ }
346
+ private key(x: number, y: number, z: number): number { return (z * this.dims[1] + y) * this.dims[0] + x }
347
+
348
+ /** Closest point on triangle `t` to `p` (Ericson, Real-Time Collision Detection 5.1.5): squared distance + barycentrics. */
349
+ private closest(t: number, p: number[], out: { d2: number; b: number[] }): void {
350
+ const T = this.tris, o = t * 9
351
+ const ax = T[o], ay = T[o + 1], az = T[o + 2], bx = T[o + 3], by = T[o + 4], bz = T[o + 5], cx = T[o + 6], cy = T[o + 7], cz = T[o + 8]
352
+ const abx = bx - ax, aby = by - ay, abz = bz - az, acx = cx - ax, acy = cy - ay, acz = cz - az
353
+ const apx = p[0] - ax, apy = p[1] - ay, apz = p[2] - az
354
+ const d1 = abx * apx + aby * apy + abz * apz, d2 = acx * apx + acy * apy + acz * apz
355
+ let u: number, v: number, w: number
356
+ if (d1 <= 0 && d2 <= 0) { u = 1; v = 0; w = 0 } else {
357
+ const bpx = p[0] - bx, bpy = p[1] - by, bpz = p[2] - bz
358
+ const d3 = abx * bpx + aby * bpy + abz * bpz, d4 = acx * bpx + acy * bpy + acz * bpz
359
+ if (d3 >= 0 && d4 <= d3) { u = 0; v = 1; w = 0 } else {
360
+ const vc = d1 * d4 - d3 * d2
361
+ if (vc <= 0 && d1 >= 0 && d3 <= 0) { v = d1 / (d1 - d3); u = 1 - v; w = 0 } else {
362
+ const cpx = p[0] - cx, cpy = p[1] - cy, cpz = p[2] - cz
363
+ const d5 = abx * cpx + aby * cpy + abz * cpz, d6 = acx * cpx + acy * cpy + acz * cpz
364
+ if (d6 >= 0 && d5 <= d6) { u = 0; v = 0; w = 1 } else {
365
+ const vb = d5 * d2 - d1 * d6
366
+ if (vb <= 0 && d2 >= 0 && d6 <= 0) { w = d2 / (d2 - d6); u = 1 - w; v = 0 } else {
367
+ const va = d3 * d6 - d5 * d4
368
+ if (va <= 0 && d4 - d3 >= 0 && d5 - d6 >= 0) { w = (d4 - d3) / ((d4 - d3) + (d5 - d6)); v = 1 - w; u = 0 } else {
369
+ const denom = 1 / (va + vb + vc)
370
+ v = vb * denom; w = vc * denom; u = 1 - v - w
371
+ }
372
+ }
373
+ }
374
+ }
375
+ }
376
+ }
377
+ const qx = ax * u + bx * v + cx * w, qy = ay * u + by * v + cy * w, qz = az * u + bz * v + cz * w
378
+ out.d2 = (qx - p[0]) ** 2 + (qy - p[1]) ** 2 + (qz - p[2]) ** 2
379
+ out.b[0] = u; out.b[1] = v; out.b[2] = w
380
+ }
381
+
382
+ /** The closest triangle to `p` (within chart `chart` when ≥ 0) and the barycentrics of the closest point; -1 = none. */
383
+ nearest(p: number[], chart: number, bary: number[]): number {
384
+ if (this.count === 0) return -1
385
+ const c = this.cellOf(p)
386
+ let best = -1, bestD2 = Infinity
387
+ const tmp = { d2: 0, b: [ 0, 0, 0 ] }
388
+ const maxR = Math.max(this.dims[0], this.dims[1], this.dims[2])
389
+ for (let r = 0; r <= maxR; r++) {
390
+ // everything not yet visited lies at least (r − 1) cells away: stop once the best is closer than that
391
+ if (best >= 0 && Math.sqrt(bestD2) <= (r - 1) * this.cell) break
392
+ for (let z = c[2] - r; z <= c[2] + r; z++) {
393
+ if (z < 0 || z >= this.dims[2]) continue
394
+ for (let y = c[1] - r; y <= c[1] + r; y++) {
395
+ if (y < 0 || y >= this.dims[1]) continue
396
+ const edgeYZ = Math.abs(z - c[2]) === r || Math.abs(y - c[1]) === r
397
+ for (let x = c[0] - r; x <= c[0] + r; x++) {
398
+ if (x < 0 || x >= this.dims[0]) continue
399
+ if (!edgeYZ && Math.abs(x - c[0]) !== r) continue // only the ring's shell
400
+ const list = this.cells.get(this.key(x, y, z))
401
+ if (!list) continue
402
+ for (const t of list) {
403
+ if (chart >= 0 && this.chart[t] !== chart) continue
404
+ this.closest(t, p, tmp)
405
+ if (tmp.d2 < bestD2) { bestD2 = tmp.d2; best = t; bary[0] = tmp.b[0]; bary[1] = tmp.b[1]; bary[2] = tmp.b[2] }
406
+ }
407
+ }
408
+ }
409
+ }
410
+ }
411
+ return best
412
+ }
413
+
414
+ uvAt(t: number, bary: number[], out: Float32Array, o: number): void {
415
+ const U = this.uvs, k = t * 6
416
+ out[o] = U[k] * bary[0] + U[k + 2] * bary[1] + U[k + 4] * bary[2]
417
+ out[o + 1] = U[k + 1] * bary[0] + U[k + 3] * bary[1] + U[k + 5] * bary[2]
418
+ }
419
+ }
420
+
421
+ /** Give a LOD primitive level 0's UV1: per triangle the centroid picks a chart, per corner the closest point on it. */
422
+ const projectLodPrimitive = (doc: Document, lod: UnwrapLod, base: BaseSurface, buffer: ReturnType<Document['createBuffer']>,
423
+ retired: Set<Accessor>): number => {
424
+ const prim = lod.prim
425
+ const pos = prim.getAttribute('POSITION')!
426
+ const idxAcc = prim.getIndices()
427
+ const idx = idxAcc ? Uint32Array.from(idxAcc.getArray()!) : Uint32Array.from({ length: pos.getCount() }, (_, i) => i)
428
+ const local = floats(pos, 3)
429
+ const world = lod.node.getWorldMatrix()
430
+ const wp = new Float32Array(local.length)
431
+ for (let v = 0; v < pos.getCount(); v++) xformPoint(world, local[v * 3], local[v * 3 + 1], local[v * 3 + 2], wp, v * 3)
432
+ const xref: number[] = [], uv: number[] = [], out: number[] = []
433
+ const slot = new Map<number, number>() // (vertex, chart) → output vertex
434
+ const bary = [ 0, 0, 0 ]
435
+ const centroid = [ 0, 0, 0 ], corner = [ 0, 0, 0 ]
436
+ const uv2 = new Float32Array(2)
437
+ for (let i = 0; i + 2 < idx.length; i += 3) {
438
+ for (let a = 0; a < 3; a++) centroid[a] = (wp[idx[i] * 3 + a] + wp[idx[i + 1] * 3 + a] + wp[idx[i + 2] * 3 + a]) / 3
439
+ const ct = base.nearest(centroid, -1, bary)
440
+ const chart = ct >= 0 ? base.chart[ct] : -1
441
+ for (let k = 0; k < 3; k++) {
442
+ const v = idx[i + k]
443
+ const key = v * 65536 + (chart + 1)
444
+ let o = slot.get(key)
445
+ if (o === undefined) {
446
+ corner[0] = wp[v * 3]; corner[1] = wp[v * 3 + 1]; corner[2] = wp[v * 3 + 2]
447
+ let t = base.nearest(corner, chart, bary)
448
+ if (t < 0) t = base.nearest(corner, -1, bary)
449
+ if (t >= 0) base.uvAt(t, bary, uv2, 0); else { uv2[0] = 0; uv2[1] = 0 }
450
+ o = xref.length
451
+ slot.set(key, o)
452
+ xref.push(v); uv.push(uv2[0], uv2[1])
453
+ }
454
+ out.push(o)
455
+ }
456
+ }
457
+ reindexPrimitive(doc, prim, Uint32Array.from(xref), Uint32Array.from(out), Float32Array.from(uv), buffer, retired)
458
+ return xref.length
459
+ }
460
+
461
+ /** A MESH LARGER THAN A GROUP'S SHARE IS CUT INTO SEVERAL NODES first (the arena's seating bowl: one mesh, 43 % of the
462
+ * level's surface - 6454 texels of layout at 5 cm, which no 4096 page holds and no grouping of whole meshes can help).
463
+ * Its triangles are bisected by their world centroids into k = ceil(area / share) parts; part 0 stays in the mesh, the
464
+ * others become sibling nodes `<name>_lm<i>` with the same transform and material. Left whole: a mesh drawn by several
465
+ * nodes, one with morph targets, one that has LOD siblings. Returns how many nodes were added. */
466
+ const splitLargeMeshes = (doc: Document, plan: UnwrapPlan, buffer: ReturnType<Document['createBuffer']>, retired: Set<Accessor>): number => {
467
+ const root = doc.getRoot()
468
+ const users = new Map<Mesh, GltfNode[]>()
469
+ for (const node of root.listNodes()) { const mesh = node.getMesh(); if (mesh) users.set(mesh, [ ...(users.get(mesh) ?? []), node ]) }
470
+ const lodBases = new Set(plan.lods.map((l) => l.baseNode.getMesh()!))
471
+ type Tri = { prim: Primitive; i: number; c: number[]; area: number; cls: number }
472
+ const perMesh = new Map<Mesh, Tri[]>()
473
+ let total = 0
474
+ const w = new Float32Array(9)
475
+ for (const p of plan.prims) {
476
+ const node = users.get(p.mesh)?.[0]
477
+ const m = node ? node.getWorldMatrix() : [ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 ]
478
+ const pos = floats(p.prim.getAttribute('POSITION')!, 3)
479
+ const idxAcc = p.prim.getIndices()
480
+ const idx = idxAcc ? idxAcc.getArray()! : Uint32Array.from({ length: pos.length / 3 }, (_, i) => i)
481
+ const list = perMesh.get(p.mesh) ?? []
482
+ for (let i = 0; i + 2 < idx.length; i += 3) {
483
+ for (let k = 0; k < 3; k++) { const v = idx[i + k]; xformPoint(m, pos[v * 3], pos[v * 3 + 1], pos[v * 3 + 2], w, k * 3) }
484
+ const ux = w[3] - w[0], uy = w[4] - w[1], uz = w[5] - w[2], vx = w[6] - w[0], vy = w[7] - w[1], vz = w[8] - w[2]
485
+ const area = 0.5 * Math.hypot(uy * vz - uz * vy, uz * vx - ux * vz, ux * vy - uy * vx)
486
+ const c = [ (w[0] + w[3] + w[6]) / 3, (w[1] + w[4] + w[7]) / 3, (w[2] + w[5] + w[8]) / 3 ]
487
+ list.push({ prim: p.prim, i, c, area, cls: texelScaleAt(plan, c[0], c[1], c[2]) })
488
+ }
489
+ perMesh.set(p.mesh, list)
490
+ }
491
+ // A mesh spans texel scales (the zone): it is cut along them when what it has in the coarser ones is worth a cut;
492
+ // else it is one piece at its finest. Shares are in EFFECTIVE area (area / scale²) - what the atlas pays.
493
+ const kMinCoarseArea = 50
494
+ const buckets = new Map<Mesh, Map<number, Tri[]>>()
495
+ for (const [ mesh, tris ] of perMesh) {
496
+ const finest = tris.reduce((a, t) => Math.min(a, t.cls), plan.zoneMax)
497
+ const coarseArea = tris.reduce((a, t) => a + (t.cls > finest ? t.area : 0), 0)
498
+ const byCls = new Map<number, Tri[]>()
499
+ for (const t of tris) {
500
+ const cls = coarseArea >= kMinCoarseArea ? t.cls : finest
501
+ byCls.set(cls, [ ...(byCls.get(cls) ?? []), t ])
502
+ }
503
+ buckets.set(mesh, byCls)
504
+ for (const [ cls, list ] of byCls) total += list.reduce((a, t) => a + t.area, 0) / (cls * cls)
505
+ }
506
+ const share = total / plan.groups
507
+ let added = 0
508
+ for (const [ mesh, byCls ] of buckets) {
509
+ const nodes = users.get(mesh) ?? []
510
+ const want = [ ...byCls ].map(([ cls, list ]) => ({ list, parts: Math.min(64, Math.max(1, Math.ceil(list.reduce((a, t) => a + t.area, 0) / (cls * cls) / share))) }))
511
+ if (want.reduce((a, b) => a + b.parts, 0) < 2 || nodes.length !== 1 || lodBases.has(mesh) || mesh.listPrimitives().some((q) => q.listTargets().length > 0)) continue
512
+ const node = nodes[0]
513
+ const out: Tri[][] = []
514
+ const bisect = (list: Tri[], n: number): void => {
515
+ if (n <= 1 || list.length <= 1) { out.push(list); return }
516
+ const lo = [ Infinity, Infinity, Infinity ], hi = [ -Infinity, -Infinity, -Infinity ]
517
+ for (const t of list) for (let k = 0; k < 3; k++) { lo[k] = Math.min(lo[k], t.c[k]); hi[k] = Math.max(hi[k], t.c[k]) }
518
+ let axis = 0
519
+ for (let k = 1; k < 3; k++) if (hi[k] - lo[k] > hi[axis] - lo[axis]) axis = k
520
+ list.sort((p, q) => p.c[axis] - q.c[axis])
521
+ const nl = Math.floor(n / 2)
522
+ const want = list.reduce((a, t) => a + t.area, 0) * nl / n
523
+ let acc = 0, cut = 0
524
+ while (cut < list.length - 1 && acc < want) acc += list[cut++].area
525
+ bisect(list.slice(0, Math.max(1, cut)), nl)
526
+ bisect(list.slice(Math.max(1, cut)), n - nl)
527
+ }
528
+ for (const b of want) bisect(b.list, b.parts)
529
+ // a part's triangles of one primitive -> a compact primitive (attributes remapped like a seam split)
530
+ const build = (prim: Primitive, list: Tri[], into: Primitive): void => {
531
+ const idxAcc = prim.getIndices()
532
+ const idx = idxAcc ? idxAcc.getArray()! : null
533
+ const slot = new Map<number, number>(), xref: number[] = [], indices: number[] = []
534
+ for (const t of list) for (let k = 0; k < 3; k++) {
535
+ const v = idx ? idx[t.i + k] : t.i + k
536
+ let o = slot.get(v)
537
+ if (o === undefined) { o = xref.length; slot.set(v, o); xref.push(v) }
538
+ indices.push(o)
539
+ }
540
+ const x = Uint32Array.from(xref)
541
+ for (const semantic of prim.listSemantics()) into.setAttribute(semantic, remapAccessor(doc, prim.getAttribute(semantic)!, x, buffer))
542
+ into.setIndices(doc.createAccessor().setType('SCALAR').setArray(xref.length <= 65535 ? Uint16Array.from(indices) : Uint32Array.from(indices)).setBuffer(buffer))
543
+ into.setMaterial(prim.getMaterial()).setMode(prim.getMode())
544
+ }
545
+ const prims = mesh.listPrimitives()
546
+ const made: { mesh: Mesh; prim: Primitive }[] = []
547
+ const replacements = new Map<Primitive, Primitive>()
548
+ out.forEach((list, k) => {
549
+ const target = k === 0 ? null : doc.createMesh(`${mesh.getName()}_lm${k}`)
550
+ for (const prim of prims) {
551
+ const mine = list.filter((t) => t.prim === prim)
552
+ if (mine.length === 0) continue
553
+ const q = doc.createPrimitive()
554
+ build(prim, mine, q)
555
+ if (target) { target.addPrimitive(q); made.push({ mesh: target, prim: q }) } else replacements.set(prim, q)
556
+ }
557
+ if (target) {
558
+ const twin = doc.createNode(`${node.getName()}_lm${k}`).setMesh(target).setTranslation(node.getTranslation()).setRotation(node.getRotation()).setScale(node.getScale()).setExtras({ ...node.getExtras() })
559
+ const parent = node.getParentNode()
560
+ if (parent) parent.addChild(twin); else for (const scene of root.listScenes()) if (scene.listChildren().includes(node)) scene.addChild(twin)
561
+ added++
562
+ }
563
+ })
564
+ // part 0 replaces the mesh's own primitives
565
+ for (const prim of prims) {
566
+ for (const semantic of prim.listSemantics()) retired.add(prim.getAttribute(semantic)!)
567
+ const oldIdx = prim.getIndices(); if (oldIdx) retired.add(oldIdx)
568
+ mesh.removePrimitive(prim)
569
+ const q = replacements.get(prim)
570
+ if (q) { mesh.addPrimitive(q); made.push({ mesh, prim: q }) }
571
+ prim.dispose()
572
+ }
573
+ plan.prims = plan.prims.filter((p) => p.mesh !== mesh)
574
+ for (const m of made) {
575
+ const pos = m.prim.getAttribute('POSITION')!
576
+ plan.prims.push({ mesh: m.mesh, prim: m.prim, name: m.mesh.getName(), triangles: m.prim.getIndices()!.getCount() / 3, vertices: pos.getCount(), hasUv1: false })
577
+ }
578
+ }
579
+ return added
580
+ }
581
+
582
+ /** Split the plan's meshes into `n` groups of about equal world surface by bisecting their centroids along the longest
583
+ * axis (neighbours share a group — a page holds a part of the level, not a scatter of it). A mesh drawn by several
584
+ * nodes is placed by its first one: UV1 belongs to the mesh, so all of its nodes are in the same group. */
585
+ const partitionMeshes = (doc: Document, plan: UnwrapPlan): { groupOf: Map<Mesh, number>; areas: number[]; scales: number[] } => {
586
+ const firstNode = new Map<Mesh, GltfNode>()
587
+ for (const node of doc.getRoot().listNodes()) {
588
+ const mesh = node.getMesh()
589
+ if (mesh && lodLevelOf(node.getName()) === 0 && !firstNode.has(mesh)) firstNode.set(mesh, node)
590
+ }
591
+ type Item = { mesh: Mesh; c: number[]; area: number; cls: number }
592
+ const items: Item[] = []
593
+ const seen = new Set<Mesh>()
594
+ const w = new Float32Array(9)
595
+ for (const p of plan.prims) {
596
+ if (seen.has(p.mesh)) continue
597
+ seen.add(p.mesh)
598
+ const node = firstNode.get(p.mesh)
599
+ const m = node ? node.getWorldMatrix() : [ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 ]
600
+ let area = 0, cls = plan.zoneMax
601
+ const c = [ 0, 0, 0 ]
602
+ for (const q of plan.prims) {
603
+ if (q.mesh !== p.mesh) continue
604
+ const pos = floats(q.prim.getAttribute('POSITION')!, 3)
605
+ const idxAcc = q.prim.getIndices()
606
+ const idx = idxAcc ? idxAcc.getArray()! : Uint32Array.from({ length: pos.length / 3 }, (_, i) => i)
607
+ for (let i = 0; i + 2 < idx.length; i += 3) {
608
+ for (let k = 0; k < 3; k++) { const v = idx[i + k]; xformPoint(m, pos[v * 3], pos[v * 3 + 1], pos[v * 3 + 2], w, k * 3) }
609
+ const ux = w[3] - w[0], uy = w[4] - w[1], uz = w[5] - w[2], vx = w[6] - w[0], vy = w[7] - w[1], vz = w[8] - w[2]
610
+ const nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx
611
+ const a = 0.5 * Math.sqrt(nx * nx + ny * ny + nz * nz)
612
+ area += a
613
+ for (let k = 0; k < 3; k++) c[k] += a * (w[k] + w[3 + k] + w[6 + k]) / 3
614
+ // a mesh's scale is its FINEST triangle's (splitLargeMeshes has already cut what was worth cutting)
615
+ cls = Math.min(cls, texelScaleAt(plan, (w[0] + w[3] + w[6]) / 3, (w[1] + w[4] + w[7]) / 3, (w[2] + w[5] + w[8]) / 3))
616
+ }
617
+ }
618
+ if (area > 0) for (let k = 0; k < 3; k++) c[k] /= area
619
+ items.push({ mesh: p.mesh, c, area, cls: area > 0 ? cls : 1 })
620
+ }
621
+ const groupOf = new Map<Mesh, number>()
622
+ const areas: number[] = []
623
+ const scales: number[] = []
624
+ const split = (list: Item[], n: number): void => {
625
+ if (n <= 1 || list.length <= 1) {
626
+ const g = areas.length
627
+ areas.push(list.reduce((a, it) => a + it.area, 0))
628
+ scales.push(list[0]?.cls ?? 1)
629
+ for (const it of list) groupOf.set(it.mesh, g)
630
+ return
631
+ }
632
+ const lo = [ Infinity, Infinity, Infinity ], hi = [ -Infinity, -Infinity, -Infinity ]
633
+ for (const it of list) for (let k = 0; k < 3; k++) { lo[k] = Math.min(lo[k], it.c[k]); hi[k] = Math.max(hi[k], it.c[k]) }
634
+ let axis = 0
635
+ for (let k = 1; k < 3; k++) if (hi[k] - lo[k] > hi[axis] - lo[axis]) axis = k
636
+ const sorted = [ ...list ].sort((p, q) => p.c[axis] - q.c[axis])
637
+ const nl = Math.floor(n / 2)
638
+ const want = sorted.reduce((a, it) => a + it.area, 0) * nl / n
639
+ let acc = 0, cut = 0
640
+ while (cut < sorted.length - 1 && (cut === 0 || acc + sorted[cut].area * 0.5 <= want)) acc += sorted[cut++].area
641
+ split(sorted.slice(0, cut), nl)
642
+ split(sorted.slice(cut), n - nl)
643
+ }
644
+ // every scale gets its share of the groups by what it costs in the atlas (area / scale²), one at least
645
+ const classes = [ ...new Set(items.map((it) => it.cls)) ].sort((a, b) => a - b)
646
+ const eff = classes.map((cls) => items.filter((it) => it.cls === cls).reduce((a, it) => a + it.area, 0) / (cls * cls))
647
+ const effTotal = eff.reduce((a, b) => a + b, 0) || 1
648
+ classes.forEach((cls, i) => split(items.filter((it) => it.cls === cls), Math.max(1, Math.round(plan.groups * eff[i] / effTotal))))
649
+ return { groupOf, areas, scales }
650
+ }
651
+
652
+ export async function applyUnwrap(doc: Document, plan: UnwrapPlan): Promise<UnwrapResult> {
653
+ const res: UnwrapResult = { primitives: 0, charts: 0, width: 0, height: 0, verticesBefore: 0, verticesAfter: 0, groups: [], splitNodes: 0, lodPrimitives: 0, lodVertices: 0 }
654
+ if (plan.prims.length === 0) return res
655
+ const m = await getModule()
656
+ const buffer = doc.getRoot().listBuffers()[0] ?? doc.createBuffer()
657
+ const retired = new Set<Accessor>()
658
+
659
+ // one xatlas layout = one unit square = one atlas rect of the bake
660
+ const unwrap = (prims: UnwrapPrim[], texelScale = 1): UnwrapGroup => {
661
+ const h = m._lmuv_begin(prims.length)
662
+ if (h === 0) throw new Error(`lightmap UV: ${m.UTF8ToString(m._lmuv_error())}`)
663
+ const allocs: number[] = []
664
+ const upload = (arr: Float32Array | Uint32Array): number => {
665
+ const ptr = m._malloc(arr.byteLength)
666
+ allocs.push(ptr)
667
+ if (arr instanceof Float32Array) m.HEAPF32.set(arr, ptr >> 2)
668
+ else m.HEAPU32.set(arr, ptr >> 2)
669
+ return ptr
670
+ }
671
+ try {
672
+ for (const p of prims) {
673
+ const pos = p.prim.getAttribute('POSITION')!
674
+ const nrm = p.prim.getAttribute('NORMAL')
675
+ const uv0 = p.prim.getAttribute('TEXCOORD_0')
676
+ const idxAcc = p.prim.getIndices()
677
+ const indices = idxAcc ? Uint32Array.from(idxAcc.getArray()!) : Uint32Array.from({ length: pos.getCount() }, (_, i) => i)
678
+ const indexCount = indices.length - (indices.length % 3)
679
+ const posPtr = upload(floats(pos, 3))
680
+ const nrmPtr = nrm && nrm.getCount() === pos.getCount() ? upload(floats(nrm, 3)) : 0
681
+ const uvPtr = uv0 && uv0.getCount() === pos.getCount() ? upload(floats(uv0, 2)) : 0
682
+ const idxPtr = upload(indices)
683
+ const err = m._lmuv_add_mesh(h, posPtr, nrmPtr, uvPtr, pos.getCount(), idxPtr, indexCount)
684
+ if (err !== 0) throw new Error(`lightmap UV: ${p.name}: ${m.UTF8ToString(m._lmuv_error())}`)
685
+ res.verticesBefore += pos.getCount()
686
+ }
687
+ // xatlas copies the mesh data in AddMesh — the inputs can go before the (long) generate.
688
+ for (const ptr of allocs) m._free(ptr)
689
+ allocs.length = 0
690
+ const err = m._lmuv_generate(h, plan.padding, plan.iterations, plan.texelsPerUnit / texelScale)
691
+ if (err !== 0) throw new Error(`lightmap UV: ${m.UTF8ToString(m._lmuv_error())}`)
692
+ const group: UnwrapGroup = { texelScale, primitives: prims.length, charts: m._lmuv_chart_count(h), width: m._lmuv_width(h), height: m._lmuv_height(h), area: 0 }
693
+ if (m._lmuv_mesh_count(h) !== prims.length) throw new Error('lightmap UV: xatlas returned a different mesh count')
694
+ prims.forEach((p, i) => {
695
+ const vcount = m._lmuv_mesh_vertex_count(h, i)
696
+ const icount = m._lmuv_mesh_index_count(h, i)
697
+ const xrefPtr = m._lmuv_mesh_xref(h, i) >> 2
698
+ const xref = m.HEAPU32.slice(xrefPtr, xrefPtr + vcount)
699
+ const uvPtr = m._lmuv_mesh_uv(h, i) >> 2
700
+ const uv1 = m.HEAPF32.slice(uvPtr, uvPtr + vcount * 2)
701
+ const idxPtr = m._lmuv_mesh_indices(h, i) >> 2
702
+ const idx32 = m.HEAPU32.slice(idxPtr, idxPtr + icount)
703
+ reindexPrimitive(doc, p.prim, xref, idx32, uv1, buffer, retired)
704
+ res.verticesAfter += vcount
705
+ res.primitives++
706
+ })
707
+ return group
708
+ } finally {
709
+ for (const ptr of allocs) m._free(ptr)
710
+ m._lmuv_end(h)
711
+ }
712
+ }
713
+
714
+ // a node's group rides in its extras; a file unwrapped whole carries none (and loses what an earlier split left)
715
+ const setGroup = (node: GltfNode, g: number | undefined, texelScale = 1): void => {
716
+ const extras = { ...(node.getExtras() as Record<string, unknown>) }
717
+ if (g === undefined) delete extras.lightmapGroup; else extras.lightmapGroup = g
718
+ if (g === undefined || texelScale <= 1) delete extras.lightmapTexelScale; else extras.lightmapTexelScale = texelScale
719
+ node.setExtras(extras)
720
+ }
721
+ if (plan.groups <= 1) {
722
+ const g = unwrap(plan.prims)
723
+ res.charts = g.charts; res.width = g.width; res.height = g.height
724
+ for (const node of doc.getRoot().listNodes()) if (node.getMesh() && (node.getExtras() as Record<string, unknown>).lightmapGroup !== undefined) setGroup(node, undefined)
725
+ } else {
726
+ res.splitNodes = splitLargeMeshes(doc, plan, buffer, retired)
727
+ const { groupOf, areas, scales } = partitionMeshes(doc, plan)
728
+ for (let k = 0; k < areas.length; k++) {
729
+ const g = unwrap(plan.prims.filter((p) => groupOf.get(p.mesh) === k), scales[k])
730
+ g.area = areas[k]
731
+ res.groups.push(g)
732
+ res.charts += g.charts; res.width = Math.max(res.width, g.width); res.height = Math.max(res.height, g.height)
733
+ }
734
+ // every node of a grouped mesh, and the LOD siblings with their level 0 (they read its texels)
735
+ const lodBase = new Map<Mesh, Mesh>()
736
+ for (const lod of plan.lods) lodBase.set(lod.mesh, lod.baseNode.getMesh()!)
737
+ for (const node of doc.getRoot().listNodes()) {
738
+ const mesh = node.getMesh()
739
+ if (!mesh) continue
740
+ const g = groupOf.get(lodBase.get(mesh) ?? mesh)
741
+ setGroup(node, g, g !== undefined ? scales[g] : 1)
742
+ }
743
+ }
744
+
745
+ // the LOD siblings, from the level-0 meshes that now carry the layout
746
+ const surfaces = new Map<GltfNode, BaseSurface>()
747
+ for (const lod of plan.lods) {
748
+ let base = surfaces.get(lod.baseNode)
749
+ if (!base) { base = new BaseSurface(lod.baseNode); surfaces.set(lod.baseNode, base) }
750
+ if (base.count === 0) continue // level 0 got no layout (skipped above): the LOD keeps what it had
751
+ res.lodVertices += projectLodPrimitive(doc, lod, base, buffer, retired)
752
+ res.lodPrimitives++
753
+ }
754
+
755
+ // A retired accessor may still back another primitive (shared POSITION with different indices)
756
+ // or a morph target we did not touch — only drop the ones nothing references any more.
757
+ for (const acc of retired) {
758
+ if (acc.listParents().every((parent) => parent.propertyType === 'Root')) acc.dispose()
759
+ }
760
+ return res
761
+ }