lecodes-assets 0.2.1 → 2.4.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/README.md +203 -26
  2. package/dist/cli.js +17948 -11911
  3. package/dist/index.js +17525 -11479
  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 +67 -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 +103 -0
  25. package/src/commands/shared.ts +121 -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 +286 -0
  32. package/src/doctor/doctor.ts +94 -30
  33. package/src/doctor/merge/accessors.ts +30 -0
  34. package/src/doctor/merge/atlasLayout.ts +94 -0
  35. package/src/doctor/merge/atlasPixels.ts +114 -0
  36. package/src/doctor/merge/bindSpace.ts +59 -0
  37. package/src/doctor/merge/classify.ts +136 -0
  38. package/src/doctor/merge/geometry.ts +167 -0
  39. package/src/doctor/merge/index.ts +98 -0
  40. package/src/doctor/merge/matrix.ts +60 -0
  41. package/src/doctor/merge/plan.ts +88 -0
  42. package/src/doctor/merge/textures.ts +60 -0
  43. package/src/doctor/merge/types.ts +77 -0
  44. package/src/doctor/transcodeKtx2.ts +179 -28
  45. package/src/doctor/validateGlb.ts +2 -2
  46. package/src/doctor/vertexAo.ts +321 -0
  47. package/src/fbx/read.ts +2 -0
  48. package/src/fbx/toDocument.ts +227 -209
  49. package/src/geometry.ts +222 -0
  50. package/src/index.ts +2 -0
  51. package/src/retarget/heading.ts +238 -171
  52. package/src/retarget/humanoid.ts +572 -561
  53. package/src/retarget/retarget.ts +2375 -2272
  54. package/src/retarget/rig.ts +4 -1
  55. package/src/retarget/writeMarks.ts +134 -132
  56. package/src/sky/cmgen.ts +3 -3
  57. package/src/sky/index.ts +57 -8
  58. package/src/terrainPack.ts +61 -22
  59. package/src/unity/colliderFit.ts +287 -0
  60. package/src/unity/fbxModel.ts +194 -0
  61. package/src/unity/foliage.ts +63 -0
  62. package/src/unity/gameObjects.ts +81 -0
  63. package/src/unity/guid.ts +104 -0
  64. package/src/unity/image.ts +58 -0
  65. package/src/unity/kindGlb.ts +364 -0
  66. package/src/unity/lecodesLevel.ts +381 -0
  67. package/src/unity/levelTerrain.ts +116 -0
  68. package/src/unity/matFile.ts +179 -0
  69. package/src/unity/materialSlots.ts +53 -0
  70. package/src/unity/materialWriter.ts +306 -0
  71. package/src/unity/materials.ts +34 -429
  72. package/src/unity/math.ts +82 -0
  73. package/src/unity/prefabTree.ts +285 -0
  74. package/src/unity/prefabs.ts +10 -36
  75. package/src/unity/propertyPath.ts +43 -0
  76. package/src/unity/scene.ts +325 -304
  77. package/src/unity/serialized.ts +242 -0
  78. package/src/unity/terrain.ts +221 -0
  79. package/src/unity/triBudget.ts +77 -0
  80. package/src/unity/triplanar.ts +122 -0
  81. package/src/unity/unityMesh.ts +151 -0
  82. package/src/unity/unpack.ts +24 -5
  83. package/src/unity/yaml.ts +131 -0
  84. package/src/unreal/index.ts +6 -5
  85. package/src/unreal/materialGraph.ts +224 -0
  86. package/src/unreal/materials.ts +117 -9
  87. package/src/unreal/package.ts +30 -12
  88. package/src/unreal/resolve.ts +6 -4
  89. package/src/unreal/sound.ts +128 -0
  90. package/src/unreal/texture.ts +70 -3
  91. package/src/unreal/toDocument.ts +7 -0
  92. package/src/unreal/versions.ts +8 -0
  93. package/src/unwrap/index.ts +167 -0
  94. package/src/unwrap/lightmapUv.ts +761 -0
  95. package/src/wasm/fbxReader.js +1 -1
  96. package/src/wasm/lightmapUv.js +1 -1
  97. package/src/doctor/lightmapUv.ts +0 -452
@@ -0,0 +1,287 @@
1
+ // DOES A PACK'S BOX COLLIDER SIT ON THE GEOMETRY IT COVERS?
2
+ //
3
+ // A Unity pack authors its own colliders, and for anything but a crate they are an approximation:
4
+ // the Modular Shooting Range's wall module is flat at ±0.100 m and only its edge trim reaches the
5
+ // ±0.138 m its box collider was fitted to, so the box's face floats 3.8 cm in front of the wall the
6
+ // player sees. Everything that lands ON the hit point then lands in mid-air: a bullet stops short,
7
+ // its impact particles hang off the surface, and a projected decal (fps-demo's bullet holes, box
8
+ // depth ±3 cm) never reaches the wall at all — the hole is simply not there. The same pack's TABLE,
9
+ // by contrast, carries five hand-placed boxes that hug the top, the side panels and the brackets,
10
+ // and swapping those for a mesh would buy nothing.
11
+ //
12
+ // So the verdict is per KIND and it is measured, not guessed: shoot a grid of rays from each box
13
+ // face inward and look at what they meet.
14
+ //
15
+ // • gap — the median distance from the face to the first surface under it. The face is a lie
16
+ // by this much: 0 on a crate, 0.038 on that wall module.
17
+ // • covered — the share of the face's samples that meet anything at all inside the box. A face
18
+ // over a void (the ceiling-panel box, 6 m of air under its underside) is the worst
19
+ // case of the same fault, so it counts as a miss whatever its gap.
20
+ //
21
+ // A kind whose every box passes keeps its boxes (cheap, and a convex box is the better collider for
22
+ // a character to slide along); one box failing sends the whole kind to a mesh collider, since a mix
23
+ // would collide twice. A mesh shape is built once per GLB and shared by every placement
24
+ // (creator-gl physicsBuildMeshFromEntity caches per asset), so the cost is per kind, not per copy.
25
+
26
+ /** One box as the prefab file will spell it: the node's frame plus the Shape's own half extents and origin. */
27
+ export type FitBox = {
28
+ /** Shape `box` — half extents along the box's own axes. */
29
+ half: [number, number, number]
30
+ /** Shape `origin` — the box's centre in the node's frame. */
31
+ origin: [number, number, number]
32
+ /** The collider node's translation in the kind's frame. */
33
+ t: [number, number, number]
34
+ /** The collider node's rotation (quaternion xyzw). */
35
+ rot: [number, number, number, number]
36
+ }
37
+
38
+ export type FitTriangles = { positions: Float32Array, indices: Uint32Array }
39
+
40
+ export type FaceFit = {
41
+ /** "x+", "y-", … — which face of which box (`box` is its index in the kind's list). */
42
+ face: string
43
+ box: number
44
+ /** Metres from the face to the surface under it (median over the samples that met one). */
45
+ gap: number
46
+ /** 0…1 share of the face's samples that met a surface inside the box. */
47
+ covered: number
48
+ }
49
+
50
+ export type FitVerdict = {
51
+ /** true = the boxes lie, give the kind a mesh collider. */
52
+ mesh: boolean
53
+ /** The face that decided it (the worst one), or null when there was nothing to measure. */
54
+ worst: FaceFit | null
55
+ faces: FaceFit[]
56
+ }
57
+
58
+ export type FitOptions = {
59
+ /** Metres a box face may stand off the surface under it (default 0.015). */
60
+ tolerance?: number
61
+ /** Least share of a face's samples that must meet a surface (default 0.25). */
62
+ minCovered?: number
63
+ /** Rays per face edge (default 8 → 64 samples a face). */
64
+ samples?: number
65
+ }
66
+
67
+ const EPS = 1e-9
68
+
69
+ /** Rotate `v` by the quaternion `q` (xyzw). */
70
+ const rotate = (q: readonly number[], v: readonly number[]): [number, number, number] => {
71
+ const [ x, y, z, w ] = q
72
+ const tx = 2 * (y * v[2] - z * v[1]), ty = 2 * (z * v[0] - x * v[2]), tz = 2 * (x * v[1] - y * v[0])
73
+ return [
74
+ v[0] + w * tx + (y * tz - z * ty),
75
+ v[1] + w * ty + (z * tx - x * tz),
76
+ v[2] + w * tz + (x * ty - y * tx),
77
+ ]
78
+ }
79
+
80
+ /** …by its inverse. */
81
+ const unrotate = (q: readonly number[], v: readonly number[]): [number, number, number] =>
82
+ rotate([ -q[0], -q[1], -q[2], q[3] ], v)
83
+
84
+ /** Möller–Trumbore: the ray's distance to the triangle, or -1. Both sides count — a one-sided test
85
+ * would miss a wall whose back face is the one inside the box. */
86
+ const rayTriangle = (
87
+ ox: number, oy: number, oz: number, dx: number, dy: number, dz: number,
88
+ ax: number, ay: number, az: number, bx: number, by: number, bz: number, cx: number, cy: number, cz: number,
89
+ ): number => {
90
+ const e1x = bx - ax, e1y = by - ay, e1z = bz - az
91
+ const e2x = cx - ax, e2y = cy - ay, e2z = cz - az
92
+ const px = dy * e2z - dz * e2y, py = dz * e2x - dx * e2z, pz = dx * e2y - dy * e2x
93
+ const det = e1x * px + e1y * py + e1z * pz
94
+ if (det > -EPS && det < EPS) return -1
95
+ const inv = 1 / det
96
+ const tx = ox - ax, ty = oy - ay, tz = oz - az
97
+ const u = (tx * px + ty * py + tz * pz) * inv
98
+ if (u < -1e-6 || u > 1 + 1e-6) return -1
99
+ const qx = ty * e1z - tz * e1y, qy = tz * e1x - tx * e1z, qz = tx * e1y - ty * e1x
100
+ const v = (dx * qx + dy * qy + dz * qz) * inv
101
+ if (v < -1e-6 || u + v > 1 + 1e-6) return -1
102
+ return (e2x * qx + e2y * qy + e2z * qz) * inv
103
+ }
104
+
105
+ /** How well one box covers the geometry: a FaceFit per face, worst first. */
106
+ export const boxFit = (tris: FitTriangles, box: FitBox, opts: FitOptions = {}): FaceFit[] => {
107
+ const n = Math.max(2, opts.samples ?? 8)
108
+ const centre: [number, number, number] = [
109
+ box.t[0] + rotate(box.rot, box.origin)[0],
110
+ box.t[1] + rotate(box.rot, box.origin)[1],
111
+ box.t[2] + rotate(box.rot, box.origin)[2],
112
+ ]
113
+ // the triangles in the BOX's frame, keeping only those that reach inside it (a 1 cm skin, so a
114
+ // surface sitting exactly on a face still counts)
115
+ const h = box.half
116
+ const keep: number[] = []
117
+ const P = tris.positions, I = tris.indices
118
+ const v = new Float32Array(9)
119
+ for (let i = 0; i + 2 < I.length; i += 3) {
120
+ let inside = false
121
+ const lo = [ Infinity, Infinity, Infinity ], hi = [ -Infinity, -Infinity, -Infinity ]
122
+ for (let k = 0; k < 3; k++) {
123
+ const j = I[i + k] * 3
124
+ const p = unrotate(box.rot, [ P[j] - centre[0], P[j + 1] - centre[1], P[j + 2] - centre[2] ])
125
+ v[k * 3] = p[0]; v[k * 3 + 1] = p[1]; v[k * 3 + 2] = p[2]
126
+ for (let a = 0; a < 3; a++) { lo[a] = Math.min(lo[a], p[a]); hi[a] = Math.max(hi[a], p[a]) }
127
+ }
128
+ inside = [ 0, 1, 2 ].every((a) => hi[a] >= -h[a] - 0.01 && lo[a] <= h[a] + 0.01)
129
+ if (inside) for (let k = 0; k < 9; k++) keep.push(v[k])
130
+ }
131
+ const T = Float32Array.from(keep)
132
+
133
+ const out: FaceFit[] = []
134
+ for (let axis = 0; axis < 3; axis++) {
135
+ for (const sign of [ 1, -1 ] as const) {
136
+ const u = (axis + 1) % 3, w = (axis + 2) % 3
137
+ const depth = 2 * h[axis]
138
+ if (depth <= 0) continue
139
+ const hits: number[] = []
140
+ let total = 0
141
+ for (let a = 0; a < n; a++) {
142
+ for (let b = 0; b < n; b++) {
143
+ // inset, so a ray does not graze the box's own rim
144
+ const fu = ((a + 0.5) / n - 0.5) * 2 * 0.9, fw = ((b + 0.5) / n - 0.5) * 2 * 0.9
145
+ const o = [ 0, 0, 0 ]
146
+ o[axis] = sign * h[axis]; o[u] = fu * h[u]; o[w] = fw * h[w]
147
+ const d = [ 0, 0, 0 ]; d[axis] = -sign
148
+ total++
149
+ let best = Infinity
150
+ for (let i = 0; i + 8 < T.length; i += 9) {
151
+ const t = rayTriangle(o[0], o[1], o[2], d[0], d[1], d[2],
152
+ T[i], T[i + 1], T[i + 2], T[i + 3], T[i + 4], T[i + 5], T[i + 6], T[i + 7], T[i + 8])
153
+ if (t >= -1e-4 && t < best && t <= depth + 1e-4) best = Math.max(0, t)
154
+ }
155
+ if (best < Infinity) hits.push(best)
156
+ }
157
+ }
158
+ hits.sort((x, y) => x - y)
159
+ const gap = hits.length ? hits[Math.floor(hits.length / 2)] : depth
160
+ out.push({ face: "xyz"[axis] + (sign > 0 ? "+" : "-"), box: 0, gap, covered: total ? hits.length / total : 0 })
161
+ }
162
+ }
163
+ return out
164
+ }
165
+
166
+ /** The kind's verdict over all of its boxes. */
167
+ export const fitVerdict = (tris: FitTriangles | null, boxes: FitBox[], opts: FitOptions = {}): FitVerdict => {
168
+ const tol = opts.tolerance ?? 0.015
169
+ const minCovered = opts.minCovered ?? 0.25
170
+ if (!tris || tris.indices.length === 0 || boxes.length === 0) return { mesh: false, worst: null, faces: [] }
171
+ const faces: FaceFit[] = []
172
+ boxes.forEach((b, i) => { for (const f of boxFit(tris, b, opts)) faces.push({ ...f, box: i }) })
173
+ // the worst face: how badly it fails (a face over a void fails by its whole depth)
174
+ const bad = (f: FaceFit): boolean => f.covered < minCovered || f.gap > tol
175
+ const failing = faces.filter(bad)
176
+ failing.sort((a, b) => b.gap - a.gap)
177
+ return { mesh: failing.length > 0, worst: failing[0] ?? null, faces }
178
+ }
179
+
180
+ // ---- reading the geometry back out of a kind's GLB -------------------------------------------------
181
+
182
+ type GlbJson = {
183
+ nodes?: { name?: string, mesh?: number, children?: number[], matrix?: number[], translation?: number[], rotation?: number[], scale?: number[] }[]
184
+ meshes?: { primitives: { attributes: Record<string, number>, indices?: number, mode?: number }[] }[]
185
+ accessors?: { bufferView?: number, byteOffset?: number, componentType: number, count: number, type: string }[]
186
+ bufferViews?: { buffer: number, byteOffset?: number, byteLength: number, byteStride?: number }[]
187
+ }
188
+
189
+ const mul = (A: number[], B: number[]): number[] => {
190
+ const M = new Array(16).fill(0)
191
+ for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++) {
192
+ let s = 0
193
+ for (let k = 0; k < 4; k++) s += A[k * 4 + r] * B[c * 4 + k]
194
+ M[c * 4 + r] = s
195
+ }
196
+ return M
197
+ }
198
+
199
+ const trsMatrix = (t: number[], q: number[], s: number[]): number[] => {
200
+ const [ x, y, z, w ] = q
201
+ const m = [
202
+ (1 - 2 * (y * y + z * z)) * s[0], (2 * (x * y + z * w)) * s[0], (2 * (x * z - y * w)) * s[0], 0,
203
+ (2 * (x * y - z * w)) * s[1], (1 - 2 * (x * x + z * z)) * s[1], (2 * (y * z + x * w)) * s[1], 0,
204
+ (2 * (x * z + y * w)) * s[2], (2 * (y * z - x * w)) * s[2], (1 - 2 * (x * x + y * y)) * s[2], 0,
205
+ t[0], t[1], t[2], 1,
206
+ ]
207
+ return m
208
+ }
209
+
210
+ /** The LOD0 triangles of a kind's GLB, in the file's own frame.
211
+ *
212
+ * LOD levels are left out (`<base>_LOD<n>`, the engine's own convention — creator-gl src/lod.cpp):
213
+ * they are coincident shells of the same surface and only the level 0 one is ever drawn. */
214
+ export const readGlbTriangles = (bytes: Uint8Array): FitTriangles | null => {
215
+ const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
216
+ if (dv.getUint32(0, true) !== 0x46546c67) return null // "glTF"
217
+ let off = 12, json: GlbJson | null = null, bin: Uint8Array | null = null
218
+ while (off + 8 <= bytes.byteLength) {
219
+ const len = dv.getUint32(off, true), type = dv.getUint32(off + 4, true)
220
+ const chunk = bytes.subarray(off + 8, off + 8 + len)
221
+ if (type === 0x4e4f534a) json = JSON.parse(new TextDecoder().decode(chunk)) as GlbJson
222
+ else if (type === 0x004e4942) bin = chunk
223
+ off += 8 + len + ((4 - (len % 4)) % 4)
224
+ }
225
+ if (!json?.nodes || !json.meshes || !json.accessors || !bin) return null
226
+ const views = json.bufferViews ?? []
227
+
228
+ const read = (index: number, want: number): Float64Array | null => {
229
+ const a = json!.accessors![index]
230
+ if (a.bufferView === undefined) return null
231
+ const bv = views[a.bufferView]
232
+ const comps = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4 }[a.type] ?? 0
233
+ if (comps !== want) return null
234
+ const size = { 5120: 1, 5121: 1, 5122: 2, 5123: 2, 5125: 4, 5126: 4 }[a.componentType] ?? 0
235
+ if (!size) return null
236
+ const stride = bv.byteStride || size * comps
237
+ const base = (bv.byteOffset ?? 0) + (a.byteOffset ?? 0)
238
+ const view = new DataView(bin!.buffer, bin!.byteOffset, bin!.byteLength)
239
+ const out = new Float64Array(a.count * comps)
240
+ for (let i = 0; i < a.count; i++) {
241
+ for (let c = 0; c < comps; c++) {
242
+ const at = base + i * stride + c * size
243
+ out[i * comps + c] = a.componentType === 5126 ? view.getFloat32(at, true)
244
+ : a.componentType === 5125 ? view.getUint32(at, true)
245
+ : a.componentType === 5123 ? view.getUint16(at, true)
246
+ : a.componentType === 5122 ? view.getInt16(at, true)
247
+ : view.getUint8(at)
248
+ }
249
+ }
250
+ return out
251
+ }
252
+
253
+ const positions: number[] = []
254
+ const indices: number[] = []
255
+ const walk = (ni: number, parent: number[]): void => {
256
+ const nd = json!.nodes![ni]
257
+ const local = nd.matrix ?? trsMatrix(nd.translation ?? [ 0, 0, 0 ], nd.rotation ?? [ 0, 0, 0, 1 ], nd.scale ?? [ 1, 1, 1 ])
258
+ const world = mul(parent, local)
259
+ const lod = /_LOD(\d+)$/.exec(nd.name ?? "")
260
+ const skip = lod !== null && Number(lod[1]) > 0
261
+ if (nd.mesh !== undefined && !skip) {
262
+ for (const prim of json!.meshes![nd.mesh].primitives) {
263
+ if (prim.mode !== undefined && prim.mode !== 4) continue
264
+ const pos = prim.attributes.POSITION !== undefined ? read(prim.attributes.POSITION, 3) : null
265
+ if (!pos) continue
266
+ const first = positions.length / 3
267
+ for (let i = 0; i < pos.length; i += 3) {
268
+ const x = pos[i], y = pos[i + 1], z = pos[i + 2]
269
+ positions.push(
270
+ world[0] * x + world[4] * y + world[8] * z + world[12],
271
+ world[1] * x + world[5] * y + world[9] * z + world[13],
272
+ world[2] * x + world[6] * y + world[10] * z + world[14],
273
+ )
274
+ }
275
+ const idx = prim.indices !== undefined ? read(prim.indices, 1) : null
276
+ if (idx) for (let i = 0; i < idx.length; i++) indices.push(first + idx[i])
277
+ else for (let i = 0; i < pos.length / 3; i++) indices.push(first + i)
278
+ }
279
+ }
280
+ for (const c of nd.children ?? []) walk(c, world)
281
+ }
282
+ const child = new Set<number>()
283
+ for (const nd of json.nodes) for (const c of nd.children ?? []) child.add(c)
284
+ const I4 = [ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 ]
285
+ json.nodes.forEach((_, i) => { if (!child.has(i)) walk(i, I4) })
286
+ return { positions: Float32Array.from(positions), indices: Uint32Array.from(indices) }
287
+ }
@@ -0,0 +1,194 @@
1
+ // A MODEL FILE AS A PREFAB. Prefabs and scenes instance an FBX directly (`m_SourcePrefab` = the
2
+ // FBX's GUID) and override its objects by fileID, so the importer needs the GameObject tree Unity's
3
+ // own model importer would have built from that file — shape, ids and transforms.
4
+
5
+ import { existsSync, readFileSync } from "node:fs"
6
+ import { basename, extname } from "node:path"
7
+ import { readFbx, type FbxNode } from "../fbx/read"
8
+ import type { Tree, UComp, UGO } from "./gameObjects"
9
+ import { mirrorQuat } from "./math"
10
+ import { isMap, num, parseUnityYaml, ref, str, type YMap } from "./yaml"
11
+
12
+ // Unity names a model's objects in the FBX's `.meta` (`fileIDToRecycleName` — classID·100000 + 2n —
13
+ // or, since 2020, `internalIDToNameTable`), after the FBX nodes. The tree is rebuilt from the file's
14
+ // own nodes the way Unity's importer shapes it: the root takes the file's name and the //RootNode
15
+ // ids, a single top-level GROUP collapses into it, transforms are mirrored into Unity's frame with
16
+ // the importer's `globalScale` on positions.
17
+
18
+ export type FbxMeta = {
19
+ globalScale: number
20
+ /** fileID → object name (`//RootNode`, a node name, a mesh name). */
21
+ names: Map<string, string>
22
+ /** `<classID>:<name>` → fileID. */
23
+ ids: Map<string, string>
24
+ /** FBX material name → the remapped `.mat` (externalObjects). */
25
+ materials: Map<string, string>
26
+ /** The importer's `addColliders`: Unity puts a MeshCollider on every mesh of this model at IMPORT,
27
+ * so the collider is in neither the prefab nor the scene and there is nothing to read but this. */
28
+ addColliders: boolean
29
+ /** The importer's `bakeAxisConversion`: the Z-up→Y-up correction a Blender export keeps on its top
30
+ * nodes is baked into the meshes, so it survives a placement that overwrites the root's rotation. */
31
+ bakeAxisConversion: boolean
32
+ }
33
+
34
+ export const readFbxMeta = (metaPath: string): FbxMeta => {
35
+ const out: FbxMeta = { globalScale: 1, names: new Map(), ids: new Map(), materials: new Map(), addColliders: false, bakeAxisConversion: false }
36
+ if (!existsSync(metaPath)) return out
37
+ const doc = parseUnityYaml(readFileSync(metaPath, "utf8"))
38
+ const mi = isMap(doc.ModelImporter) ? doc.ModelImporter : {}
39
+ const meshes = isMap(mi.meshes) ? mi.meshes : {}
40
+ out.globalScale = num(meshes.globalScale, 1)
41
+ out.addColliders = num(meshes.addColliders, 0) !== 0
42
+ out.bakeAxisConversion = num(meshes.bakeAxisConversion, 0) !== 0
43
+ const add = (cls: number, id: string, name: string): void => {
44
+ out.names.set(id, name)
45
+ const k = `${cls}:${name}`
46
+ if (!out.ids.has(k)) out.ids.set(k, id)
47
+ }
48
+ if (isMap(mi.fileIDToRecycleName)) for (const [ id, name ] of Object.entries(mi.fileIDToRecycleName)) add(Math.floor(Number(id) / 100000), id, str(name))
49
+ if (Array.isArray(mi.internalIDToNameTable)) for (const e of mi.internalIDToNameTable) {
50
+ if (!isMap(e) || !isMap(e.first)) continue
51
+ for (const [ cls, id ] of Object.entries(e.first)) add(Number(cls), str(id), str(e.second))
52
+ }
53
+ if (Array.isArray(mi.externalObjects)) for (const e of mi.externalObjects) {
54
+ if (!isMap(e) || !isMap(e.first)) continue
55
+ if (!/Material$/.test(str(e.first.type))) continue
56
+ const g = ref(e.second).guid
57
+ if (g) out.materials.set(str(e.first.name), g)
58
+ }
59
+ return out
60
+ }
61
+
62
+ export type ModelInfo = { stem: string, nodes: FbxNode[], materials: string[][], meta: FbxMeta }
63
+ export const MODEL_EXT = /\.(fbx|obj|dae|blend)$/i
64
+
65
+ export const readModel = async (path: string): Promise<ModelInfo | null> => {
66
+ try {
67
+ const scene = await readFbx(new Uint8Array(readFileSync(path)))
68
+ // per node: the FBX material names its primitives use, in primitive order (= Unity's submeshes)
69
+ const materials = scene.nodes.map((n) => {
70
+ const mesh = n.mesh >= 0 ? scene.meshes[n.mesh] : null
71
+ const names: string[] = []
72
+ for (const p of mesh?.primitives ?? []) { const nm = scene.materials[p.material]?.name ?? ""; if (!names.includes(nm)) names.push(nm) }
73
+ return names
74
+ })
75
+ return { stem: basename(path, extname(path)), nodes: scene.nodes, materials, meta: readFbxMeta(path + ".meta") }
76
+ } catch { return null }
77
+ }
78
+
79
+ /** The name the FBX importer gives a model's MESH object: the node's (for the collapsed root too). */
80
+ export const MODEL_ROOT = "//RootNode"
81
+
82
+ // UNITY'S OWN fileIDs FOR AN IMPORTED MODEL'S ROOT OBJECTS, by class id.
83
+ //
84
+ // A model `.meta` usually ships NO id table at all (`internalIDToNameTable: []`), so there is
85
+ // nothing to read the root's ids out of - and yet every prefab that wraps that FBX, and every scene
86
+ // that places such a prefab, addresses them. They are not hashes of anything in the file: these four
87
+ // numbers are the same in every pack (measured across seven vendors' packs here - tactical, gunvfx,
88
+ // both blood packs, mc-core, the school), because Unity assigns a model prefab's ROOT a fixed id set
89
+ // and only its CHILDREN get per-name ids.
90
+ //
91
+ // Getting this wrong is silent and total. A kit whose prefabs are bare wrappers around an FBX keeps
92
+ // every placement's position, rotation and material override on the ROOT, addressed as
93
+ // `xor(thatPrefabInstance, thisId)`; invent the root's id instead and not one override in the level
94
+ // resolves, so the whole thing loads with every piece stacked on the origin.
95
+ const MODEL_ROOT_IDS: Record<number, string> = {
96
+ 1: "919132149155446097", // GameObject
97
+ 4: "-8679921383154817045", // Transform
98
+ 23: "-7511558181221131132", // MeshRenderer
99
+ 205: "4742473837712756890", // LODGroup
100
+ }
101
+
102
+ /** `bakeAxisConversion`'s half turn about the up axis in front of a rotation: RotY(180°) · q. */
103
+ const bakedAxis = (q: number[]): number[] => [ q[2], q[3], -q[0], -q[1] ]
104
+
105
+ /** How much of an FBX node's own transform its GameObject takes:
106
+ * `full` — all of it (an ordinary child);
107
+ * `none` — none (the model's root, which Unity authors at the origin);
108
+ * `axis` — what the ROOT's mesh takes when the FBX's single top node IS the mesh. In Unity that
109
+ * node is the prefab's root, and a root's position and rotation are never the model's: every
110
+ * PrefabInstance overrides them (`SM_Door_01.prefab` writes 0 / identity over the node's −2 m and
111
+ * its 180°), so neither may be applied here. What survives is the importer's `bakeAxisConversion`:
112
+ * it turns the file half a turn about the up axis (FBX front → Unity forward) and bakes the top
113
+ * node's rotation INTO THE MESH, where no override reaches it: mesh' = RotY(180°) · R · mesh.
114
+ * Without the flag nothing is baked and the placement's own rotation stands the mesh up.
115
+ * Measured on the school, whose four flagged models are the only ones this touches:
116
+ * `SM_Door_01` (R = RotY 180° · RotX −90°, so the half turns cancel) stands in its wall's opening,
117
+ * x −2.66..−1.34 — the whole R flipped it to +1.34..+2.66, R with the position put it at ±0.66,
118
+ * nothing laid it down; `SM_Door_02` (R = identity, mesh authored upright at x 0.78..3.22, nested
119
+ * at −2 in `SM_Wall_Door_03`) lands in ITS opening, −5.16..−2.84, only with the half turn —
120
+ * without it the double door stood 4 m off, which is how it shipped before.
121
+ * The node's SCALE is the root's too (`rootScale` below, `axis` keeps none of it): a Blender export
122
+ * without "Apply Unit" puts ×100 on that node, Unity shows it as the ROOT's scale, and a placement
123
+ * that writes `m_LocalScale` REPLACES it. Peremena's desks (×100 in the file, 85 in the scene) came out
124
+ * 8500× their mesh — 130 m across — while they were a child's scale multiplied by the placement's. */
125
+ type TransformShare = "full" | "none" | "axis" | "rootScale"
126
+
127
+ const xyz = (v: number[]): YMap => ({ x: String(v[0]), y: String(v[1]), z: String(v[2]) })
128
+ const xyzw = (q: number[]): YMap => ({ x: String(q[0]), y: String(q[1]), z: String(q[2]), w: String(q[3]) })
129
+
130
+ /** A node's local transform as a Unity Transform body: X mirrored, positions × the importer's scale. */
131
+ const transformBody = (n: FbxNode | null, share: TransformShare, meta: FbxMeta): YMap => {
132
+ const gs = meta.globalScale
133
+ const t = n && share === "full" ? [ -n.t[0] * gs, n.t[1] * gs, n.t[2] * gs ] : [ 0, 0, 0 ]
134
+ const q = !n || share === "none" || share === "rootScale" ? [ 0, 0, 0, 1 ] : share === "full" ? n.r : meta.bakeAxisConversion ? bakedAxis(n.r) : [ 0, 0, 0, 1 ]
135
+ const s = n && (share === "full" || share === "rootScale") ? n.s : [ 1, 1, 1 ]
136
+ return { m_LocalPosition: xyz(t), m_LocalRotation: xyzw(mirrorQuat(q)), m_LocalScale: xyz(s) }
137
+ }
138
+
139
+ /** The GameObject tree Unity's importer makes of a model file, under the ids its `.meta` publishes. */
140
+ export const modelTree = (info: ModelInfo, guid: string): Tree => {
141
+ const { nodes, meta } = info
142
+ const ids = new Map<string, UGO | UComp>()
143
+ const kids = new Map<number, number[]>()
144
+ nodes.forEach((n, i) => { if (n.parent >= 0) { const a = kids.get(n.parent) ?? []; a.push(i); kids.set(n.parent, a) } })
145
+ // helper nodes (geometric transform / scale inheritance) are not GameObjects: skip through them
146
+ const realChildren = (i: number): number[] => (kids.get(i) ?? []).flatMap((c) => (nodes[c].helper ? realChildren(c) : [ c ]))
147
+ const meshOf = (i: number): number => nodes[i].mesh >= 0 ? i : (kids.get(i) ?? []).find((c) => nodes[c].helper && meshOf(c) >= 0) ?? -1
148
+ const sceneRoot = nodes.findIndex((n) => n.isRoot)
149
+ const tops = sceneRoot >= 0 ? realChildren(sceneRoot) : nodes.map((_, i) => i).filter((i) => nodes[i].parent < 0)
150
+ // ONE top-level node collapses into the root — but only a BARE GROUP. A node that carries the MESH
151
+ // has to stay a child, because the root's transform never reaches the GLB (kindGlb builds the kind
152
+ // from its parts) and its `axis` share is the thing that stands a Blender export up.
153
+ const topMesh = tops.length === 1 && meshOf(tops[0]) >= 0 ? tops[0] : -1
154
+ const collapse = tops.length === 1 && topMesh < 0
155
+
156
+ // ids: the `.meta`'s own, Unity's fixed set for the root, else a placeholder nothing can address
157
+ let placeholders = 0
158
+ const idFor = (cls: number, name: string): string => meta.ids.get(`${cls}:${name}`)
159
+ ?? (name === MODEL_ROOT ? MODEL_ROOT_IDS[cls] : undefined)
160
+ ?? `-${cls}${String(++placeholders).padStart(6, "0")}`
161
+ const add = (go: UGO, cls: string, classId: number, key: string, body: YMap): UComp => {
162
+ const c: UComp = { cls, id: idFor(classId, key), body, go }
163
+ go.comps.push(c)
164
+ ids.set(c.id, c)
165
+ return c
166
+ }
167
+
168
+ const make = (i: number | null, key: string, name: string, parent: UGO | null, share: TransformShare): UGO => {
169
+ const go: UGO = { id: idFor(1, key), body: { m_Name: name, m_IsActive: "1" }, comps: [], children: [], parent }
170
+ ids.set(go.id, go)
171
+ // the root of a model whose single top node is the mesh takes that node's scale (TransformShare)
172
+ const tn = i === null ? (parent === null && topMesh >= 0 ? topMesh : null) : i
173
+ add(go, "Transform", 4, key, transformBody(tn === null ? null : nodes[tn], i === null && tn !== null ? "rootScale" : share, meta))
174
+ const mi = i === null ? -1 : meshOf(i)
175
+ if (mi >= 0) {
176
+ const meshName = nodes[i!].name
177
+ const mesh = { fileID: meta.ids.get(`43:${meshName}`) ?? `name:${meshName}`, guid }
178
+ const mats = info.materials[mi].map((m) => { const g = meta.materials.get(m); return g ? { fileID: "2100000", guid: g } : { fileID: "0", guid: `name:${m}` } })
179
+ add(go, "MeshFilter", 33, key, { m_Mesh: mesh })
180
+ add(go, "MeshRenderer", 23, key, { m_Enabled: "1", m_CastShadows: "1", m_Materials: mats })
181
+ // `addColliders` is the importer generating a MeshCollider per mesh — the only trace of it is
182
+ // this flag, so a kit that leans on it (here: the interior floor, the ceiling and four walls)
183
+ // otherwise imports with nothing to stand on and the player falls through the level.
184
+ if (meta.addColliders) add(go, "MeshCollider", 64, key, { m_Enabled: "1", m_Convex: "0", m_Mesh: mesh })
185
+ }
186
+ for (const c of i === null ? tops : realChildren(i)) go.children.push(make(c, nodes[c].name, nodes[c].name, go, c === topMesh ? "axis" : "full"))
187
+ return go
188
+ }
189
+ const root = make(collapse ? tops[0] : null, MODEL_ROOT, info.stem, null, "none")
190
+ // every id here came from the FBX .meta's own tables, i.e. it IS the id this asset publishes
191
+ const bySource = new Map<string, UGO | UComp>()
192
+ for (const [ k, v ] of ids) bySource.set(`${guid}:${k}`, v)
193
+ return { roots: [ root ], ids, bySource }
194
+ }
@@ -0,0 +1,63 @@
1
+ // WHAT IS A PLANT on an imported level — the kinds the level writer marks `foliage: true` (the SDK's
2
+ // vegetation tier: wind, touch bending, per-copy tint).
3
+ //
4
+ // The first rule was "any part wears an alpha-cutout material", and that is too broad by a mile: a
5
+ // poster is cutout, so is a road marking, a door's grille, a window frame — and the tier SWAYS them.
6
+ // On Chernobyl it caught 77 kinds of which 27 were plants; on the school it took two doors.
7
+ //
8
+ // So the evidence is the MATERIAL'S OWN NAME AND FOLDER, never its alpha mode: a pack sorts its
9
+ // materials by what they are (`Materials/Vegetation/…` against `Materials/Decals/`) or names them
10
+ // so (`M_Grass_Tall`, `Grass_01`), and that is the author telling us directly. The kind's name is
11
+ // weaker (`Fencing_Flowerbed_01` is a railing) and is consulted only when NO material of the kind
12
+ // could be read (binary .mat, a missing .meta). And because every rule is a guess about someone
13
+ // else's naming, the import takes an explicit list: `--foliage <re>` marks exactly the kinds whose
14
+ // name matches (the rules are then OFF), `--no-foliage` marks none.
15
+ import { basename } from "node:path"
16
+ import type { SceneKind } from "./scene"
17
+ import type { UnityMaterial } from "./matFile"
18
+
19
+ export type FoliageOptions = {
20
+ /** Explicit: exactly the kinds whose NAME matches wear the tier; the material rules are off. */
21
+ foliage?: RegExp
22
+ /** Nothing wears the tier. */
23
+ noFoliage?: boolean
24
+ }
25
+
26
+ /** A material FOLDER that says "this is a plant" — one path segment, whole. */
27
+ const VEGETATION_DIR = /(^|[\\/])(vegetation|foliage|plants?|shrubs?|grass|trees?|bushes|flowers?)([\\/]|$)/i
28
+ /** A material NAME that says so — a whole word of it (`M_Grass_Tall`, `Grass_01`, `Leaves_Birch`). */
29
+ const VEGETATION_WORD = /(^|[^a-z])(grass|bush|bushes|shrub|leaf|leaves|foliage|vegetation|fern|ivy|vine|reed|clover|weed|moss|hedge|plant|flower|spruce|pine|birch|oak|palm|tree)(e?s)?([^a-z]|$)/i
30
+ /** …unless the same name says it is the WOODY part or the debris: a trunk, a stump, a fallen log. */
31
+ const NOT_VEGETATION_WORD = /(^|[^a-z])(trunk|bark|stump|log|logs|fallen|cut|root|roots|rock|stone|mushroom)([^a-z]|$)/i
32
+
33
+ /** Does the material's file say vegetation — by the folder it sits in, or by its own name? */
34
+ export const isVegetationMaterial = (um: UnityMaterial): boolean => {
35
+ if (!um.file) return false
36
+ const norm = um.file.replace(/\\/g, "/")
37
+ const dir = norm.slice(0, norm.lastIndexOf("/") + 1)
38
+ if (VEGETATION_DIR.test(dir)) return true
39
+ const name = basename(norm).replace(/\.mat$/i, "")
40
+ return VEGETATION_WORD.test(name) && !NOT_VEGETATION_WORD.test(name)
41
+ }
42
+
43
+ // Vegetation by kind name — the fallback for a kind none of whose materials could be read.
44
+ const FOLIAGE_NAME = /grass|bush|shrub|flower|plant|reed|clover|fern|ivy|vine|leaf|leaves|tree|spruce|pine|birch|oak|palm|weed|moss|hedge|foliage/i
45
+ const NOT_FOLIAGE = /stump|log|branch|trunk_cut|rock|stone|mushroom|fenc|rail/i
46
+
47
+ /** Should this kind wear the foliage tier? `materials` = every material of every part the builder
48
+ * could read (null = unreadable), in part order. */
49
+ export const decideFoliage = (kind: SceneKind, materials: (UnityMaterial | null)[], opts: FoliageOptions = {}): boolean => {
50
+ if (opts.noFoliage) return false
51
+ if (opts.foliage) return opts.foliage.test(kind.name)
52
+ const read = materials.filter((m): m is UnityMaterial => !!m)
53
+ if (read.length > 0) return read.some(isVegetationMaterial)
54
+ return FOLIAGE_NAME.test(kind.name) && !NOT_FOLIAGE.test(kind.name)
55
+ }
56
+
57
+ /** The import log's one line about the verdict — so a wrong guess is seen, not found in the wind. */
58
+ export const describeFoliage = (names: string[], opts: FoliageOptions = {}): string => {
59
+ const how = opts.noFoliage ? "--no-foliage" : opts.foliage ? `--foliage ${opts.foliage.source}` : "by material folder / name"
60
+ if (names.length === 0) return `🌿 foliage tier: none (${how}; --foliage <re> marks kinds by name)`
61
+ const shown = names.slice(0, 12).join(", ") + (names.length > 12 ? `, … +${names.length - 12}` : "")
62
+ return `🌿 foliage tier: ${names.length} kind(s) ${how} — ${shown}${opts.foliage || opts.noFoliage ? "" : " (--foliage <re> overrides, --no-foliage none)"}`
63
+ }
@@ -0,0 +1,81 @@
1
+ // A Unity file's objects as a tree: GameObjects with their components (raw YAML bodies) and
2
+ // children. prefabTree.ts builds these out of `.prefab` / `.unity` text, fbxModel.ts out of a model
3
+ // file; scene.ts reads the finished tree through the accessors at the bottom.
4
+ //
5
+ // Raw bodies stay raw (YAML maps) until a reader asks, so a prefab modification is just "set this
6
+ // path on that body" whatever the component.
7
+
8
+ import { isMap, num, quat, ref, str, vec3, type Quat, type Vec3, type YMap } from "./yaml"
9
+ import type { TRS } from "./math"
10
+
11
+ export type UComp = { cls: string, id: string, body: YMap, go: UGO }
12
+ export type UGO = {
13
+ id: string
14
+ body: YMap
15
+ comps: UComp[]
16
+ children: UGO[]
17
+ parent: UGO | null
18
+ /** Set on the root GameObject of a prefab instance: the source prefab's GUID. */
19
+ prefab?: string
20
+ }
21
+ // `ids` addresses an object the way the CURRENT file does (a nested prefab's are XOR-composed,
22
+ // Unity's own scheme); `bySource` addresses it the way an OVERRIDE does - by the asset the object
23
+ // is defined in plus its id THERE, which survives any amount of nesting. See the lookup below.
24
+ export type Tree = { roots: UGO[], ids: Map<string, UGO | UComp>, bySource: Map<string, UGO | UComp> }
25
+
26
+ /** A deep copy: every instance of a prefab gets its own bodies to apply modifications to. */
27
+ export const cloneTree = (t: Tree): Tree => {
28
+ const map = new Map<UGO | UComp, UGO | UComp>()
29
+ const cloneGo = (g: UGO, parent: UGO | null): UGO => {
30
+ const n: UGO = { id: g.id, body: structuredClone(g.body), comps: [], children: [], parent, prefab: g.prefab }
31
+ map.set(g, n)
32
+ for (const c of g.comps) { const nc: UComp = { cls: c.cls, id: c.id, body: structuredClone(c.body), go: n }; n.comps.push(nc); map.set(c, nc) }
33
+ for (const ch of g.children) n.children.push(cloneGo(ch, n))
34
+ return n
35
+ }
36
+ const roots = t.roots.map((r) => cloneGo(r, null))
37
+ const ids = new Map<string, UGO | UComp>()
38
+ for (const [ k, v ] of t.ids) { const n = map.get(v); if (n) ids.set(k, n) }
39
+ const bySource = new Map<string, UGO | UComp>()
40
+ for (const [ k, v ] of t.bySource) { const n = map.get(v); if (n) bySource.set(k, n) }
41
+ return { roots, ids, bySource }
42
+ }
43
+
44
+ // ---- reading the finished tree --------------------------------------------------------------------
45
+
46
+ export const comp = (go: UGO, cls: string): UComp | undefined => go.comps.find((c) => c.cls === cls)
47
+ export const goName = (go: UGO): string => str(go.body.m_Name)
48
+ export const goActive = (go: UGO): boolean => num(go.body.m_IsActive, 1) !== 0
49
+ export const localTRS = (go: UGO): TRS => {
50
+ const t = comp(go, "Transform") ?? comp(go, "RectTransform")
51
+ if (!t) return { t: [ 0, 0, 0 ], r: [ 0, 0, 0, 1 ], s: [ 1, 1, 1 ] }
52
+ const r = quat(t.body.m_LocalRotation)
53
+ const l = Math.hypot(r[0], r[1], r[2], r[3]) || 1
54
+ return { t: vec3(t.body.m_LocalPosition), r: [ r[0] / l, r[1] / l, r[2] / l, r[3] / l ], s: vec3(t.body.m_LocalScale, [ 1, 1, 1 ]) }
55
+ }
56
+ export const enabled = (c: UComp): boolean => num(c.body.m_Enabled, 1) !== 0
57
+
58
+ /** LOD level per renderer component (from every LODGroup in the tree): renderer → level. */
59
+ export const lodLevels = (roots: UGO[], ids?: Map<string, UGO | UComp>): Map<UComp, number> => {
60
+ const out = new Map<UComp, number>()
61
+ const walk = (g: UGO): void => {
62
+ for (const c of g.comps) {
63
+ if (c.cls !== "LODGroup" || !Array.isArray(c.body.m_LODs)) continue
64
+ // renderer refs are ids in the file the LODGroup came from — resolve among the group's own subtree
65
+ const byId = new Map<string, UComp>()
66
+ const collect = (x: UGO): void => { for (const k of x.comps) byId.set(k.id, k); x.children.forEach(collect) }
67
+ collect(g)
68
+ c.body.m_LODs.forEach((lod, level) => {
69
+ if (!isMap(lod) || !Array.isArray(lod.renderers)) return
70
+ for (const r of lod.renderers) {
71
+ const id = ref(isMap(r) ? r.renderer : undefined).fileID
72
+ const rc = byId.get(id) ?? (ids?.get(id) as UComp | undefined)
73
+ if (rc && "go" in rc && !out.has(rc)) out.set(rc, level)
74
+ }
75
+ })
76
+ }
77
+ g.children.forEach(walk)
78
+ }
79
+ roots.forEach(walk)
80
+ return out
81
+ }