lecodes-assets 0.2.0 → 2.4.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/README.md +738 -449
  2. package/dist/cli.js +19760 -8612
  3. package/dist/index.js +19334 -8167
  4. package/package.json +15 -3
  5. package/src/bvh/fromDocument.ts +136 -0
  6. package/src/bvh/toDocument.ts +202 -0
  7. package/src/cli-kit/command.ts +100 -0
  8. package/src/cli-kit/help.ts +72 -0
  9. package/src/cli-kit/index.ts +4 -0
  10. package/src/cli-kit/parse.ts +72 -0
  11. package/src/cli-kit/run.ts +52 -0
  12. package/src/cli.ts +24 -0
  13. package/src/commands/avatar.ts +77 -0
  14. package/src/commands/bvh.ts +26 -0
  15. package/src/commands/convert.ts +156 -0
  16. package/src/commands/doctor.ts +67 -0
  17. package/src/commands/geometry.ts +45 -0
  18. package/src/commands/index.ts +24 -0
  19. package/src/commands/marks.ts +38 -0
  20. package/src/commands/mirror.ts +35 -0
  21. package/src/commands/probe.ts +43 -0
  22. package/src/commands/retarget/donor.ts +63 -0
  23. package/src/commands/retarget/index.ts +169 -0
  24. package/src/commands/retarget/map.ts +81 -0
  25. package/src/commands/retarget/report.ts +77 -0
  26. package/src/commands/scene.ts +103 -0
  27. package/src/commands/shared.ts +121 -0
  28. package/src/commands/sky.ts +35 -0
  29. package/src/commands/terrainPack.ts +40 -0
  30. package/src/commands/unpack.ts +43 -0
  31. package/src/commands/unreal.ts +109 -0
  32. package/src/commands/unwrap.ts +56 -0
  33. package/src/doctor/cardTrim.ts +286 -0
  34. package/src/doctor/doctor.ts +305 -0
  35. package/src/doctor/lod.ts +177 -0
  36. package/src/doctor/merge/accessors.ts +30 -0
  37. package/src/doctor/merge/atlasLayout.ts +94 -0
  38. package/src/doctor/merge/atlasPixels.ts +114 -0
  39. package/src/doctor/merge/bindSpace.ts +59 -0
  40. package/src/doctor/merge/classify.ts +136 -0
  41. package/src/doctor/merge/geometry.ts +167 -0
  42. package/src/doctor/merge/index.ts +98 -0
  43. package/src/doctor/merge/matrix.ts +60 -0
  44. package/src/doctor/merge/plan.ts +88 -0
  45. package/src/doctor/merge/textures.ts +60 -0
  46. package/src/doctor/merge/types.ts +77 -0
  47. package/src/doctor/normalizeScale.ts +220 -0
  48. package/src/doctor/reduceGlbBones.ts +218 -0
  49. package/src/doctor/shading.ts +95 -0
  50. package/src/doctor/transcodeKtx2.ts +275 -0
  51. package/src/doctor/transcodeTextures.ts +101 -0
  52. package/src/doctor/validateGlb.ts +264 -0
  53. package/src/doctor/vertexAo.ts +321 -0
  54. package/src/fbx/convert.ts +327 -0
  55. package/src/fbx/read.ts +111 -0
  56. package/src/fbx/toDocument.ts +227 -0
  57. package/src/filmstrip.ts +263 -0
  58. package/src/geometry.ts +222 -0
  59. package/src/index.ts +19 -0
  60. package/src/mergeSkins.ts +117 -0
  61. package/src/probe.ts +167 -0
  62. package/src/props.ts +137 -0
  63. package/src/retarget/automap.ts +276 -0
  64. package/src/retarget/ghost.ts +23 -0
  65. package/src/retarget/heading.ts +238 -0
  66. package/src/retarget/humanoid.ts +572 -0
  67. package/src/retarget/inPlace.ts +130 -0
  68. package/src/retarget/marks.ts +581 -0
  69. package/src/retarget/mirror.ts +0 -0
  70. package/src/retarget/retarget.ts +2375 -0
  71. package/src/retarget/rig.ts +545 -0
  72. package/src/retarget/unityAnim.ts +171 -0
  73. package/src/retarget/writeMarks.ts +134 -0
  74. package/src/sky/analyze.ts +136 -0
  75. package/src/sky/cmgen.ts +71 -0
  76. package/src/sky/index.ts +200 -0
  77. package/src/terrainPack.ts +208 -0
  78. package/src/textures/resolveImage.ts +202 -0
  79. package/src/unity/colliderFit.ts +287 -0
  80. package/src/unity/fbxModel.ts +194 -0
  81. package/src/unity/foliage.ts +63 -0
  82. package/src/unity/gameObjects.ts +81 -0
  83. package/src/unity/guid.ts +104 -0
  84. package/src/unity/image.ts +58 -0
  85. package/src/unity/kindGlb.ts +364 -0
  86. package/src/unity/lecodesLevel.ts +381 -0
  87. package/src/unity/levelTerrain.ts +116 -0
  88. package/src/unity/matFile.ts +179 -0
  89. package/src/unity/materialSlots.ts +53 -0
  90. package/src/unity/materialWriter.ts +306 -0
  91. package/src/unity/materials.ts +198 -0
  92. package/src/unity/math.ts +82 -0
  93. package/src/unity/png.ts +75 -0
  94. package/src/unity/prefabTree.ts +285 -0
  95. package/src/unity/prefabs.ts +54 -0
  96. package/src/unity/propertyPath.ts +43 -0
  97. package/src/unity/scene.ts +384 -0
  98. package/src/unity/serialized.ts +242 -0
  99. package/src/unity/terrain.ts +221 -0
  100. package/src/unity/triBudget.ts +77 -0
  101. package/src/unity/triplanar.ts +122 -0
  102. package/src/unity/unityMesh.ts +151 -0
  103. package/src/unity/unpack.ts +149 -0
  104. package/src/unity/yaml.ts +131 -0
  105. package/src/unreal/anim.ts +284 -0
  106. package/src/unreal/clipSource.ts +25 -0
  107. package/src/unreal/convert.ts +67 -0
  108. package/src/unreal/convertAsset.ts +81 -0
  109. package/src/unreal/index.ts +115 -0
  110. package/src/unreal/list.ts +113 -0
  111. package/src/unreal/materialGraph.ts +224 -0
  112. package/src/unreal/materials.ts +399 -0
  113. package/src/unreal/mesh.ts +205 -0
  114. package/src/unreal/oodle.ts +153 -0
  115. package/src/unreal/package.ts +677 -0
  116. package/src/unreal/probe.ts +144 -0
  117. package/src/unreal/resolve.ts +53 -0
  118. package/src/unreal/rig.ts +149 -0
  119. package/src/unreal/sequencer.ts +193 -0
  120. package/src/unreal/skeleton.ts +74 -0
  121. package/src/unreal/sound.ts +128 -0
  122. package/src/unreal/texture.ts +269 -0
  123. package/src/unreal/toDocument.ts +367 -0
  124. package/src/unreal/versions.ts +108 -0
  125. package/src/unwrap/index.ts +167 -0
  126. package/src/unwrap/lightmapUv.ts +761 -0
  127. package/src/wasm/fbxReader.js +14 -0
  128. package/src/wasm/lightmapUv.js +14 -0
@@ -0,0 +1,54 @@
1
+ // Prefab material overrides.
2
+ //
3
+ // Asset-store packs often leave the FBX's materials as placeholders ("17 - Default", "Material #3150")
4
+ // and assign the real material on the PREFAB's MeshRenderer — the FBX alone converts untextured.
5
+ // This reads the text-serialised `.prefab` files around an FBX and reports, per GameObject that
6
+ // renders a mesh, which FBX mesh it shows and which material GUIDs its renderer carries, so the
7
+ // converter can re-material the matching glTF node (`applyUnityMaterials`).
8
+
9
+ import { readFileSync } from "node:fs"
10
+ import { walkFiles } from "./guid"
11
+ import { isMap, readUnityObjects, ref } from "./yaml"
12
+
13
+ export type PrefabOverride = {
14
+ /** The .prefab file. */
15
+ prefab: string
16
+ /** GameObject name — the FBX node / glTF node it renders (packs keep them identical). */
17
+ node: string
18
+ /** GUID of the mesh asset (the FBX) the MeshFilter references, when it is an external asset. */
19
+ meshGuid: string | undefined
20
+ /** Material GUIDs by primitive (sub-mesh) index; an empty slot = keep the FBX's. */
21
+ materials: (string | undefined)[]
22
+ }
23
+
24
+ /** Material overrides declared by one text-serialised prefab (empty for binary / non-prefab files). */
25
+ export const readPrefabOverrides = (file: string): PrefabOverride[] => {
26
+ const text = readFileSync(file, "utf8")
27
+ if (!text.startsWith("%YAML")) return []
28
+ const names = new Map<string, string>() // GameObject id → name
29
+ const meshes = new Map<string, string | undefined>() // GameObject id → mesh guid
30
+ const renderers = new Map<string, (string | undefined)[]>() // GameObject id → material guids
31
+ for (const { cls, id, body } of readUnityObjects(text)) {
32
+ const go = ref(body.m_GameObject).fileID
33
+ if (cls === "GameObject" && typeof body.m_Name === "string") names.set(id, body.m_Name)
34
+ else if (cls === "MeshFilter") meshes.set(go, ref(body.m_Mesh).guid)
35
+ else if (cls === "MeshRenderer" || cls === "SkinnedMeshRenderer") {
36
+ renderers.set(go, Array.isArray(body.m_Materials) ? body.m_Materials.map((m) => (isMap(m) && typeof m.guid === "string" ? m.guid : undefined)) : [])
37
+ if (cls === "SkinnedMeshRenderer") meshes.set(go, ref(body.m_Mesh).guid)
38
+ }
39
+ }
40
+ const out: PrefabOverride[] = []
41
+ for (const [ go, materials ] of renderers) {
42
+ const node = names.get(go)
43
+ if (!node || materials.every((m) => !m)) continue
44
+ out.push({ prefab: file, node, meshGuid: meshes.get(go), materials })
45
+ }
46
+ return out
47
+ }
48
+
49
+ /** Every `.prefab` under `dirs` (recursive to `depth`), read for overrides. */
50
+ export const collectPrefabOverrides = (dirs: string[], depth = 3): PrefabOverride[] => {
51
+ const files: string[] = []
52
+ for (const d of dirs) walkFiles(d, depth, files, (n) => /\.prefab$/i.test(n))
53
+ return [ ...new Set(files) ].flatMap(readPrefabOverrides)
54
+ }
@@ -0,0 +1,43 @@
1
+ // Unity's serialized property paths (`m_LocalPosition.x`, `m_Materials.Array.data[1]`) on a raw
2
+ // YAML body — what a PrefabInstance's `m_Modifications` are written in.
3
+
4
+ import { isMap, num, type Y, type YMap } from "./yaml"
5
+
6
+ /** Read what a dotted property path currently holds (the mirror of `setPath`), so an override can be
7
+ * compared with what it would replace. Undefined when any step is missing. */
8
+ export const readPath = (body: YMap, path: string): Y | undefined => {
9
+ let cur: Y | undefined = body
10
+ for (const p of path.split(".")) {
11
+ if (!isMap(cur)) return undefined
12
+ cur = cur[p]
13
+ }
14
+ return cur
15
+ }
16
+
17
+ /** `m_LocalPosition.x`, `m_Materials.Array.data[1]`, `m_Materials.Array.size`, `m_Size.y` … */
18
+ export const setPath = (body: YMap, path: string, value: Y | undefined): void => {
19
+ if (!path || value === undefined) return
20
+ const parts = path.split(".")
21
+ let cur: Y = body
22
+ for (let i = 0; i < parts.length; i++) {
23
+ const p = parts[i]
24
+ const last = i === parts.length - 1
25
+ if (p === "Array" && Array.isArray(cur)) continue
26
+ const arr = /^data\[(\d+)\]$/.exec(p)
27
+ if (arr && Array.isArray(cur)) {
28
+ const k = +arr[1]
29
+ if (last) { cur[k] = value; return }
30
+ if (cur[k] === undefined || typeof cur[k] !== "object") cur[k] = {}
31
+ cur = cur[k]
32
+ continue
33
+ }
34
+ if (p === "size" && Array.isArray(cur) && last) { cur.length = Math.max(0, num(value as Y, cur.length)); return }
35
+ if (!isMap(cur)) return
36
+ if (last) { cur[p] = value; return }
37
+ let next: Y | undefined = cur[p]
38
+ // an array field addressed as `field.Array.…` — make sure it is one
39
+ if (parts[i + 1] === "Array" && !Array.isArray(next)) { next = []; cur[p] = next }
40
+ else if (next === undefined || typeof next !== "object") { next = {}; cur[p] = next }
41
+ cur = next
42
+ }
43
+ }
@@ -0,0 +1,384 @@
1
+ // Unity scene (`.unity`, text-serialised) → a level description in LeCodes' frame: every placed
2
+ // prefab instance as a KIND (a distinct fully-expanded GameObject tree: meshes + materials + LOD
3
+ // levels + colliders, relative to its root) plus one placement per instance, the terrain (heights,
4
+ // splat layers) with its painted trees and detail meshes as more placements, and the lights.
5
+ //
6
+ // Kinds are keyed by CONTENT, not by prefab: two instances of one prefab share a kind unless the
7
+ // scene overrode something inside one of them (a moved child, another material) — then that one gets
8
+ // a variant kind. Nested prefabs, variants and modifications are resolved by prefabTree.ts; the
9
+ // scene's own loose mesh objects become kinds too.
10
+ //
11
+ // Frame: every transform is conjugated by the X mirror (math.ts) — placements AND the part matrices
12
+ // inside kinds.
13
+
14
+ import { readFileSync } from "node:fs"
15
+ import { basename } from "node:path"
16
+ import { comp, enabled, goActive, goName, localTRS, lodLevels, type UComp, type UGO } from "./gameObjects"
17
+ import { resolverFor } from "./guid"
18
+ import { I4, decompose, mirrorX, mmul, r6, trsMatrix, type Mat4 } from "./math"
19
+ import { PrefabLoader, newStats } from "./prefabTree"
20
+ import { readUnityTerrain, type UnityTerrain } from "./terrain"
21
+ import { color, isMap, num, ref, vec3, type Quat, type Vec3 } from "./yaml"
22
+
23
+ export type Handedness = "x" | "none"
24
+
25
+ // ---- output types -------------------------------------------------------------------------------------
26
+
27
+ export type KindPart = {
28
+ /** GameObject path inside the kind (`Spruce_3/Spruce_3_LOD1`). */
29
+ path: string
30
+ /** Kind-local transform, LeCodes frame (column-major). */
31
+ matrix: Mat4
32
+ mesh: { guid: string, fileID: string }
33
+ /** `.mat` GUIDs, one per submesh (Unity's order). */
34
+ materials: string[]
35
+ /** LODGroup level (0 when the renderer is in no LODGroup). */
36
+ lod: number
37
+ castShadows: boolean
38
+ }
39
+ export type KindCollider = {
40
+ path: string
41
+ matrix: Mat4
42
+ trigger: boolean
43
+ } & (
44
+ | { shape: "box", center: Vec3, size: Vec3 }
45
+ | { shape: "sphere", center: Vec3, radius: number }
46
+ | { shape: "capsule", center: Vec3, radius: number, height: number, direction: number }
47
+ | { shape: "mesh", convex: boolean, mesh: { guid: string, fileID: string } | null }
48
+ )
49
+ export type KindLight = { path: string, matrix: Mat4, type: number, color: [number, number, number, number], intensity: number, range: number, area?: LightArea, unit?: string }
50
+ export type SceneKind = {
51
+ key: string
52
+ /** File-safe unique name (`P_Bush_3`, `P_Bush_3_v2`). */
53
+ name: string
54
+ prefab: string | null
55
+ prefabPath: string | null
56
+ parts: KindPart[]
57
+ colliders: KindCollider[]
58
+ lights: KindLight[]
59
+ particles: number
60
+ count: number
61
+ }
62
+ export type SceneInstance = {
63
+ kind: string
64
+ name: string
65
+ /** `Vegetation/Grass` — the scene groups above the instance; `Terrain/Trees`, `Terrain/Details`. */
66
+ group: string
67
+ position: Vec3
68
+ rotation: Quat
69
+ scale: Vec3
70
+ }
71
+ /** An AREA light's emitter (Unity rectangle / disc lights, HDRP rectangle / tube / disc): `width` runs along the light's
72
+ * local X (`along`), `height` along its local Y (`up`; 0 for a tube), and it emits along `direction` (local +Z). The
73
+ * lightmap bake takes such a lamp as a rectangle (`Light.point({ bakeArea })`) - a fixture lights from its length, not
74
+ * from a point. */
75
+ export type LightArea = { shape: "rect" | "tube" | "disc", width: number, height: number }
76
+ export type SceneLight = { name: string, type: "directional" | "point" | "spot", position: Vec3, direction: Vec3, color: [number, number, number], intensity: number, range: number, shadows: boolean,
77
+ area?: LightArea, along?: Vec3, up?: Vec3,
78
+ /** HDRP's unit of `intensity`: lumen | candela | lux | nits | ev100 (absent = the pipeline's unitless number). */
79
+ unit?: string }
80
+ export type SceneCamera = { name: string, position: Vec3, forward: Vec3, fov: number }
81
+ export type SceneTerrainInfo = {
82
+ name: string
83
+ dataPath: string
84
+ /** LeCodes node origin (sample (0,0) — the mirrored grid's corner). */
85
+ position: Vec3
86
+ resolution: number
87
+ size: Vec3
88
+ layers: { name: string, diffuse: string | null, normal: string | null, tileSize: [number, number], coverage: number }[]
89
+ }
90
+ export type SceneImport = {
91
+ source: string
92
+ frame: string
93
+ kinds: Record<string, SceneKind>
94
+ instances: SceneInstance[]
95
+ lights: SceneLight[]
96
+ cameras: SceneCamera[]
97
+ terrain: SceneTerrainInfo | null
98
+ stats: { prefabInstances: number, sceneMeshes: number, trees: number, details: number, inactive: number, unresolvedMods: number, guessedTransforms: number, missingPrefabs: number, binaryPrefabs: string[], variants: number, particles: number, prefabLights: number }
99
+ }
100
+ /** The terrain arrays that do not belong in JSON (the `.terrain` writer reads them). */
101
+ export type SceneImportFull = SceneImport & { terrainData: UnityTerrain | null }
102
+
103
+ export type ImportSceneOptions = {
104
+ /** `unity-index.txt` for GUID → path (default: found walking up from the scene, else the Unity
105
+ * project's `.meta` sidecars). */
106
+ indexFile?: string | null
107
+ handedness?: Handedness
108
+ /** keep only instances whose kind name / group matches */
109
+ filter?: RegExp
110
+ /** …and the opposite: DROP every instance whose kind name / group / node name matches. For props a
111
+ * pack authors inside the geometry they decorate — the Modular Shooting Range sinks its wall signs
112
+ * ~3.5 cm into the concrete and lets Unity's decal ordering sort it out, so they z-fight here. */
113
+ drop?: RegExp
114
+ /** DROP the placements standing at these points (lecodes frame, as the scene file prints them; within 10 cm) —
115
+ * for the one instance of a kind the game moves while its twins stay: the shooting range's five lane targets
116
+ * are `SM_CardboardTarget_01` like the sixth on a shelf, so no name tells them apart, their place does. */
117
+ dropAt?: [number, number, number][]
118
+ /** Unity-space X/Z rectangle [x0, z0, x1, z1] — instances outside are dropped */
119
+ region?: [number, number, number, number]
120
+ /** only instances under a group whose path contains this */
121
+ group?: string
122
+ includeInactive?: boolean
123
+ /** Skip the terrain's painted trees / detail meshes. */
124
+ noTrees?: boolean
125
+ noDetails?: boolean
126
+ }
127
+
128
+ // ---- component readers -----------------------------------------------------------------------------
129
+
130
+ /** A collider component as a kind's collider (`M` = the owner's kind-local matrix, mirrored), or null for any other component. */
131
+ const readCollider = (c: UComp, path: string, M: Mat4): KindCollider | null => {
132
+ const base = { path, matrix: M, trigger: num(c.body.m_IsTrigger, 0) !== 0 }
133
+ switch (c.cls) {
134
+ case "BoxCollider": return { ...base, shape: "box", center: vec3(c.body.m_Center), size: vec3(c.body.m_Size, [ 1, 1, 1 ]) }
135
+ case "SphereCollider": return { ...base, shape: "sphere", center: vec3(c.body.m_Center), radius: num(c.body.m_Radius, 0.5) }
136
+ case "CapsuleCollider": return { ...base, shape: "capsule", center: vec3(c.body.m_Center), radius: num(c.body.m_Radius, 0.5), height: num(c.body.m_Height, 2), direction: num(c.body.m_Direction, 1) }
137
+ case "MeshCollider": {
138
+ const mesh = ref(c.body.m_Mesh)
139
+ return { ...base, shape: "mesh", convex: num(c.body.m_Convex, 0) !== 0, mesh: mesh.guid ? { guid: mesh.guid, fileID: mesh.fileID } : null }
140
+ }
141
+ default: return null
142
+ }
143
+ }
144
+
145
+ /** The emitter of an area light, or undefined for a punctual one. HDRP keeps it in the HDAdditionalLightData script next to
146
+ * the Light (m_PointlightHDType 1 = area; m_AreaLightShape 0 rectangle, 1 tube, 2 disc; m_ShapeWidth / m_ShapeHeight);
147
+ * the built-in and URP pipelines in the Light itself (m_Type 3 rectangle, 4 disc; m_AreaSize). */
148
+ const HD_UNITS = [ "lumen", "candela", "lux", "nits", "ev100" ]
149
+ const readArea = (comps: UComp[], light: UComp): { area?: LightArea, unit?: string } => {
150
+ const hd = comps.find((c) => c.cls === "MonoBehaviour" && c.body.m_AreaLightShape !== undefined && c.body.m_ShapeWidth !== undefined)
151
+ if (hd) {
152
+ const unit = HD_UNITS[num(hd.body.m_LightUnit, -1)]
153
+ if (num(hd.body.m_PointlightHDType, 0) !== 1) return { unit }
154
+ const shape = ([ "rect", "tube", "disc" ] as const)[num(hd.body.m_AreaLightShape, 0)] ?? "rect"
155
+ const width = num(hd.body.m_ShapeWidth, 0.5)
156
+ return { unit, area: { shape, width, height: shape === "tube" ? 0 : shape === "disc" ? width : num(hd.body.m_ShapeHeight, 0.5) } }
157
+ }
158
+ const type = num(light.body.m_Type, 2)
159
+ if (type !== 3 && type !== 4) return {}
160
+ const size = light.body.m_AreaSize
161
+ const width = isMap(size) ? num(size.x, 1) : 1, height = isMap(size) ? num(size.y, 1) : 1
162
+ return { area: { shape: type === 4 ? "disc" : "rect", width, height: type === 4 ? width : height } }
163
+ }
164
+ const axisOf = (M: Mat4, col: number): Vec3 => {
165
+ const f = M.slice(col * 4, col * 4 + 3)
166
+ const l = Math.hypot(...f) || 1
167
+ return f.map((v) => r6(v / l)) as Vec3
168
+ }
169
+
170
+ /** The unit +Z of a (mirrored) world matrix: where a light shines and a camera looks. */
171
+ const forwardOf = (M: Mat4): Vec3 => {
172
+ const f = M.slice(8, 11)
173
+ const l = Math.hypot(...f) || 1
174
+ return f.map((v) => r6(v / l)) as Vec3
175
+ }
176
+
177
+ const readLight = (name: string, light: UComp, M: Mat4, comps: UComp[] = []): SceneLight => {
178
+ const type = num(light.body.m_Type, 2)
179
+ const c = color(light.body.m_Color)
180
+ return {
181
+ name, type: type === 1 ? "directional" : type === 0 ? "spot" : "point", position: decompose(M).t.map(r6) as Vec3, direction: forwardOf(M),
182
+ color: [ c[0], c[1], c[2] ], intensity: num(light.body.m_Intensity, 1), range: num(light.body.m_Range, 10),
183
+ shadows: isMap(light.body.m_Shadows) ? num(light.body.m_Shadows.m_Type, 0) !== 0 : false,
184
+ ...readArea(comps, light), along: axisOf(M, 0), up: axisOf(M, 1),
185
+ }
186
+ }
187
+
188
+ const readCamera = (name: string, cam: UComp, M: Mat4): SceneCamera =>
189
+ ({ name, position: [ M[12], M[13], M[14] ].map(r6) as Vec3, forward: forwardOf(M), fov: num(cam.body["field of view"], 60) })
190
+
191
+ export const importUnityScene = async (scenePath: string, opts: ImportSceneOptions = {}): Promise<SceneImportFull> => {
192
+ const text = readFileSync(scenePath, "utf8")
193
+ if (!text.startsWith("%YAML")) throw new Error(`${basename(scenePath)} is not text-serialised (Unity: Edit ▸ Project Settings ▸ Editor ▸ Asset Serialization = Force Text)`)
194
+ const handedness = opts.handedness ?? "x"
195
+ const mirror = (m: Mat4): Mat4 => (handedness === "x" ? mirrorX(m) : m)
196
+ const resolver = resolverFor(scenePath, opts.indexFile)
197
+ const tstats = newStats()
198
+ const loader = new PrefabLoader(resolver, tstats)
199
+ // the terrain first: its tree / detail prototypes join the model preload
200
+ const tdGuid = /m_TerrainData: \{fileID: -?\d+, guid: ([0-9a-f]{32})/.exec(text)?.[1]
201
+ const tdPath = tdGuid ? resolver.resolve(tdGuid) : null
202
+ const tdLocal = tdPath ? readUnityTerrain(tdPath, [ 0, 0, 0 ], resolver) : null
203
+ await loader.preload([ text ], tdLocal ? [ ...new Set([ ...tdLocal.trees, ...tdLocal.details ].map((p) => p.prefab)) ] : [])
204
+ const tree = loader.buildText(text, null)
205
+
206
+ const kinds: Record<string, SceneKind> = {}
207
+ const nameUse = new Map<string, number>()
208
+ const instances: SceneInstance[] = []
209
+ const lights: SceneLight[] = []
210
+ const cameras: SceneCamera[] = []
211
+ const stats: SceneImport["stats"] = { prefabInstances: 0, sceneMeshes: 0, trees: 0, details: 0, inactive: 0, unresolvedMods: 0, guessedTransforms: 0, missingPrefabs: 0, binaryPrefabs: [], variants: 0, particles: 0, prefabLights: 0 }
212
+ const prefabName = (guid: string | null): string | null => { const p = guid ? resolver.resolve(guid) : null; return p ? basename(p).replace(/\.(prefab|fbx|obj|dae|blend)$/i, "") : null }
213
+
214
+ // ---- a prefab instance → a kind (+ its nested prefab instances as placements of their own) ----
215
+ // Kinds stop at nested prefab roots: a composite (a cliff wall built from cliff-rock prefabs, a
216
+ // campfire around a fire prefab) becomes placements of its LEAF prefabs, so a moved rock inside one
217
+ // wall is one more placement, not one more GLB carrying twelve rocks.
218
+ const lodSuffix = (n: string): number => { const m = /_LOD(\d+)$/i.exec(n); return m ? +m[1] : 0 }
219
+ const kindOf = (root: UGO, prefab: string | null, fallbackName: string, nested: { g: UGO, local: Mat4 }[]): SceneKind | null => {
220
+ const parts: KindPart[] = [], colliders: KindCollider[] = [], klights: KindLight[] = []
221
+ let particles = 0
222
+ const lods = lodLevels([ root ])
223
+ const walk = (g: UGO, parentM: Mat4, path: string, top: boolean): void => {
224
+ if (!goActive(g) && !opts.includeInactive) return
225
+ const l = localTRS(g)
226
+ const m = top ? I4() : mmul(parentM, trsMatrix(l.t, l.r, l.s))
227
+ if (!top && g.prefab) { nested.push({ g, local: m }); return }
228
+ const p = top ? "" : (path ? `${path}/` : "") + goName(g)
229
+ const M = mirror(m)
230
+ const mf = comp(g, "MeshFilter"), mr = comp(g, "MeshRenderer")
231
+ if (mf && mr && enabled(mr)) {
232
+ const mesh = ref(mf.body.m_Mesh)
233
+ if (mesh.guid && mesh.guid !== "0000000000000000e000000000000000") {
234
+ const mats = Array.isArray(mr.body.m_Materials) ? mr.body.m_Materials.map((x) => ref(x).guid ?? "") : []
235
+ parts.push({ path: p, matrix: M, mesh: { guid: mesh.guid, fileID: mesh.fileID }, materials: mats, lod: lods.get(mr) ?? lodSuffix(goName(g)), castShadows: num(mr.body.m_CastShadows, 1) !== 0 })
236
+ }
237
+ }
238
+ for (const c of g.comps) {
239
+ if (!enabled(c)) continue
240
+ const collider = readCollider(c, p, M)
241
+ if (collider) colliders.push(collider)
242
+ else if (c.cls === "Light") klights.push({ path: p, matrix: M, type: num(c.body.m_Type, 2), color: color(c.body.m_Color), intensity: num(c.body.m_Intensity, 1), range: num(c.body.m_Range, 10), ...readArea(g.comps, c) })
243
+ else if (c.cls === "ParticleSystem") particles++
244
+ }
245
+ for (const ch of g.children) walk(ch, m, p, false)
246
+ }
247
+ walk(root, I4(), "", true)
248
+ stats.particles += particles
249
+ stats.prefabLights += klights.length
250
+ if (!parts.length && !colliders.length) return null
251
+ const key = JSON.stringify([ prefab, parts.map((x) => [ x.path, x.matrix.map(r6), x.mesh, x.materials, x.lod, x.castShadows ]), colliders.map((c) => ({ ...c, matrix: c.matrix.map(r6) })) ])
252
+ let k = kinds[key]
253
+ if (!k) {
254
+ const base = (prefabName(prefab) ?? fallbackName).replace(/\s*\(\d+\)$/, "").replace(/[^\w.-]+/g, "_")
255
+ const n = (nameUse.get(base) ?? 0) + 1
256
+ nameUse.set(base, n)
257
+ if (n > 1) stats.variants++
258
+ k = kinds[key] = { key, name: n > 1 ? `${base}_v${n}` : base, prefab, prefabPath: prefab ? resolver.resolve(prefab) : null, parts, colliders, lights: klights, particles, count: 0 }
259
+ }
260
+ return k
261
+ }
262
+
263
+ // ---- placements ----
264
+ const worldOf = (m: Mat4): { position: Vec3, rotation: Quat, scale: Vec3 } => {
265
+ const d = decompose(mirror(m))
266
+ return { position: d.t.map(r6) as Vec3, rotation: d.r.map(r6) as Quat, scale: d.s.map(r6) as Vec3 }
267
+ }
268
+ const inRegion = (m: Mat4): boolean => !opts.region || (m[12] >= opts.region[0] && m[12] <= opts.region[2] && m[14] >= opts.region[1] && m[14] <= opts.region[3])
269
+ const place = (k: SceneKind, name: string, group: string, m: Mat4): boolean => {
270
+ if (opts.group && !group.includes(opts.group)) return false
271
+ if (opts.filter && !(opts.filter.test(k.name) || opts.filter.test(group) || opts.filter.test(name))) return false
272
+ if (opts.drop && (opts.drop.test(k.name) || opts.drop.test(group) || opts.drop.test(name))) return false
273
+ if (!inRegion(m)) return false
274
+ const w = worldOf(m)
275
+ if (opts.dropAt?.some((p) => Math.hypot(p[0] - w.position[0], p[1] - w.position[1], p[2] - w.position[2]) < 0.1)) return false
276
+ k.count++
277
+ instances.push({ kind: k.name, name, group, ...w })
278
+ return true
279
+ }
280
+ /** A prefab instance at `world` (its root's own transform included) — itself and every nested one. */
281
+ const placePrefab = (root: UGO, world: Mat4, groups: string[], fallbackName: string): number => {
282
+ const nested: { g: UGO, local: Mat4 }[] = []
283
+ const k = kindOf(root, root.prefab ?? null, fallbackName, nested)
284
+ const name = goName(root) || fallbackName
285
+ let n = k && place(k, name, groups.join("/"), world) ? 1 : 0
286
+ for (const x of nested) n += placePrefab(x.g, mmul(world, x.local), [ ...groups, name ], goName(x.g))
287
+ return n
288
+ }
289
+
290
+ let terrainInfo: SceneTerrainInfo | null = null
291
+ let terrainOrigin: Vec3 = [ 0, 0, 0 ]
292
+ const visit = (g: UGO, parentM: Mat4, groups: string[]): void => {
293
+ if (!goActive(g) && !opts.includeInactive) { stats.inactive++; return }
294
+ const l = localTRS(g)
295
+ const m = mmul(parentM, trsMatrix(l.t, l.r, l.s))
296
+ const name = goName(g)
297
+ if (g.prefab) {
298
+ stats.prefabInstances++
299
+ placePrefab(g, m, groups, name)
300
+ return
301
+ }
302
+ // the scene's own objects
303
+ const light = comp(g, "Light")
304
+ if (light && enabled(light)) lights.push(readLight(name, light, mirror(m), g.comps))
305
+ const cam = comp(g, "Camera")
306
+ if (cam && enabled(cam)) cameras.push(readCamera(name, cam, mirror(m)))
307
+ const terrain = comp(g, "Terrain")
308
+ if (terrain && enabled(terrain) && tdLocal && !terrainInfo) {
309
+ const td = tdLocal
310
+ terrainOrigin = [ m[12], m[13], m[14] ]
311
+ const origin: Vec3 = handedness === "x" ? [ -(m[12] + td.size[0]), m[13], m[14] ] : [ m[12], m[13], m[14] ]
312
+ terrainInfo = {
313
+ name: td.name, dataPath: tdPath!, position: origin.map(r6) as Vec3, resolution: td.resolution, size: td.size,
314
+ layers: td.layers.map((ly, i) => { let s = 0; const w = td.weights[i]; if (w) for (let k = 0; k < w.length; k++) s += w[k]; return { name: ly.name, diffuse: ly.diffuse, normal: ly.normal, tileSize: ly.tileSize, coverage: w ? r6(s / w.length / 255) : 0 } }),
315
+ }
316
+ }
317
+ if (comp(g, "MeshFilter") && comp(g, "MeshRenderer") && !terrain) {
318
+ // a loose mesh object: its own kind, children visited separately
319
+ const k = kindOf({ ...g, children: [] }, null, name, [])
320
+ if (k && place(k, name, groups.join("/"), m)) stats.sceneMeshes++
321
+ }
322
+ for (const ch of g.children) visit(ch, m, [ ...groups, name ])
323
+ }
324
+ for (const r of tree.roots) visit(r, I4(), [])
325
+
326
+ // ---- terrain trees + details: placements of their prefabs ----
327
+ const td = terrainInfo ? tdLocal : null
328
+ if (td) {
329
+ const protos = new Map<string, UGO | null>()
330
+ const protoRoot = (guid: string): UGO | null => {
331
+ if (!protos.has(guid)) protos.set(guid, loader.prefab(guid)?.roots[0] ?? null)
332
+ return protos.get(guid)!
333
+ }
334
+ const emit = (list: UnityTerrain["trees"], group: string): number => {
335
+ let n = 0
336
+ for (const p of list) {
337
+ const root = protoRoot(p.prefab)
338
+ if (!root) continue
339
+ root.prefab ??= p.prefab
340
+ const h = p.rotation / 2
341
+ const pos: Vec3 = [ terrainOrigin[0] + p.position[0], terrainOrigin[1] + p.position[1], terrainOrigin[2] + p.position[2] ]
342
+ n += placePrefab(root, trsMatrix(pos, [ 0, Math.sin(h), 0, Math.cos(h) ], [ p.widthScale, p.heightScale, p.widthScale ]), [ group ], prefabName(p.prefab) ?? p.prefab)
343
+ }
344
+ return n
345
+ }
346
+ if (!opts.noTrees) stats.trees = emit(td.trees, "Terrain/Trees")
347
+ if (!opts.noDetails) stats.details = emit(td.details, "Terrain/Details")
348
+ }
349
+
350
+ // kinds nothing uses (filtered out) are dropped
351
+ for (const k of Object.keys(kinds)) if (!kinds[k].count) delete kinds[k]
352
+ stats.unresolvedMods = tstats.unresolvedMods
353
+ stats.guessedTransforms = tstats.guessedTransforms
354
+ stats.missingPrefabs = tstats.missingPrefabs.size
355
+ stats.binaryPrefabs = [ ...tstats.binaryPrefabs ]
356
+
357
+ return {
358
+ source: basename(scenePath),
359
+ frame: handedness === "none" ? "unity (left-handed, untouched)" : "lecodes: Unity mirrored on X (position −x, quaternion (x, −y, −z, w)), metres, Y up",
360
+ kinds, instances, lights, cameras, terrain: terrainInfo, terrainData: terrainInfo ? tdLocal : null, stats,
361
+ }
362
+ }
363
+
364
+ /** The import as printable lines: kinds most used first, then what was skipped. */
365
+ export const describeSceneImport = (r: SceneImport, max = 20): string[] => {
366
+ const rows = Object.values(r.kinds).sort((a, b) => b.count - a.count)
367
+ const s = r.stats
368
+ const lines = [ `${r.instances.length} placements of ${rows.length} kind(s) — ${s.prefabInstances} prefab instances, ${s.trees} terrain trees, ${s.details} detail meshes, ${s.sceneMeshes} scene meshes${s.variants ? ` · ${s.variants} override variant(s)` : ""}${s.inactive ? ` · ${s.inactive} inactive skipped` : ""}` ]
369
+ for (const k of rows.slice(0, max)) lines.push(` ${String(k.count).padStart(5)} × ${k.name.padEnd(34)} ${k.parts.length} mesh part(s)${k.colliders.length ? `, ${k.colliders.length} collider(s)` : ""}${k.parts.some((p) => p.lod > 0) ? `, LOD 0–${Math.max(...k.parts.map((p) => p.lod))}` : ""}`)
370
+ if (rows.length > max) lines.push(` … ${rows.length - max} more`)
371
+ if (r.terrain) lines.push(` terrain ${r.terrain.name}: ${r.terrain.resolution}² samples, ${r.terrain.size[0]}×${r.terrain.size[2]} m, layers ${r.terrain.layers.map((l) => `${l.name} ${(l.coverage * 100).toFixed(1)}%`).join(", ")}`)
372
+ if (r.lights.length) lines.push(` lights: ${r.lights.map((l) => `${l.area ? `${l.area.shape} ${+l.area.width.toFixed(2)}${l.area.height ? ` x ${+l.area.height.toFixed(2)}` : ""} m` : l.type} ${l.name}`).join(", ")}`)
373
+ const areaKinds = Object.values(r.kinds).filter((k) => k.lights.some((l) => l.area))
374
+ if (areaKinds.length) lines.push(` area lights in prefabs: ${areaKinds.map((k) => `${k.name} x${k.count} (${k.lights.filter((l) => l.area).map((l) => `${l.area!.shape} ${+l.area!.width.toFixed(2)} x ${+l.area!.height.toFixed(2)} m`).join(", ")})`).join(", ")} - the lightmap bake takes them as rectangles: Light.point({ bakeArea })`)
375
+ const notes: string[] = []
376
+ if (s.unresolvedMods) notes.push(`${s.unresolvedMods} prefab modification(s) with no target`)
377
+ if (s.guessedTransforms) notes.push(`${s.guessedTransforms} unaddressable child transform(s) matched by position`)
378
+ if (s.missingPrefabs) notes.push(`${s.missingPrefabs} prefab GUID(s) not found`)
379
+ if (s.binaryPrefabs.length) notes.push(`${s.binaryPrefabs.length} binary-serialised prefab(s) skipped`)
380
+ if (s.particles) notes.push(`${s.particles} particle system(s) not converted`)
381
+ if (s.prefabLights) notes.push(`${s.prefabLights} light(s) inside prefabs not converted`)
382
+ if (notes.length) lines.push(` ℹ ${notes.join(" · ")}`)
383
+ return lines
384
+ }