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
@@ -1,363 +1,398 @@
1
- // Unity scene (`.unity`, text-serialised YAML) → a level description: every placed prefab instance
2
- // and mesh object with its world transform, already in LeCodes' frame, plus a table of the prefabs
3
- // used (and a best guess at the FBX + node each one renders). The vendor's demo map becomes a JSON
4
- // a `level.ts` can walk: one `Model.load` per prefab, one `clone()` per instance.
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
5
  //
6
- // What is read: `PrefabInstance` (source prefab GUID, the m_LocalPosition/Rotation/Scale + m_Name +
7
- // m_IsActive overrides, the transform it is parented to), `Transform` / `GameObject` (the scene's
8
- // own hierarchy: groups like "Static/Roads", plain mesh objects via `MeshFilter`). Parents that are
9
- // themselves inside a prefab instance ("stripped" transforms) resolve to that instance's transform —
10
- // the prefab's internal offsets live in the prefab file, which the big packs ship binary-serialised,
11
- // so a child parented deep inside a prefab lands at the prefab's root (counted in `approximate`).
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.
12
10
  //
13
- // Frame: Unity is left-handed (+Z forward); glTF/LeCodes right-handed. Unity's FBX importer mirrors
14
- // X, so undoing it is: position (−x, y, z), quaternion (x, −y, −z, w), scale unchanged — the
15
- // UnityGLTF convention. `handedness: "z"` mirrors Z instead (for content authored in Unity itself),
16
- // `"none"` keeps Unity's numbers.
11
+ // Frame: every transform is conjugated by the X mirror (math.ts) — placements AND the part matrices
12
+ // inside kinds.
17
13
 
18
- import { existsSync, readFileSync } from "node:fs"
19
- import { basename, dirname, resolve } from "node:path"
20
- import { findUnityIndex } from "./materials"
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"
21
22
 
22
- // ---- a YAML subset parser (what Unity writes: block maps/sequences, flow maps, scalars) ----------
23
+ export type Handedness = "x" | "none"
23
24
 
24
- export type Y = string | Y[] | { [k: string]: Y }
25
- type Line = { indent: number, text: string }
25
+ // ---- output types -------------------------------------------------------------------------------------
26
26
 
27
- const splitLines = (text: string): Line[] => {
28
- const raw = text.split(/\r?\n/)
29
- const out: Line[] = []
30
- for (let i = 0; i < raw.length; i++) {
31
- let l = raw[i]
32
- if (!l.trim() || l.startsWith("%")) continue
33
- // wrapped flow maps: `{fileID: 1, guid: abc,` ⏎ ` type: 3}`
34
- let open = (l.match(/\{/g) ?? []).length - (l.match(/\}/g) ?? []).length
35
- while (open > 0 && i + 1 < raw.length) {
36
- const nx = raw[++i]
37
- l += " " + nx.trim()
38
- open += (nx.match(/\{/g) ?? []).length - (nx.match(/\}/g) ?? []).length
39
- }
40
- const indent = l.length - l.trimStart().length
41
- out.push({ indent, text: l.trim() })
42
- }
43
- return out
44
- }
45
-
46
- const scalar = (s: string): Y => {
47
- s = s.trim()
48
- if (s === "" || s === "~" || s === "null") return ""
49
- if (s === "[]") return []
50
- if (s === "{}") return {}
51
- if (s.startsWith("{") && s.endsWith("}")) {
52
- const obj: { [k: string]: Y } = {}
53
- for (const part of s.slice(1, -1).split(",")) {
54
- const c = part.indexOf(":")
55
- if (c < 0) continue
56
- obj[part.slice(0, c).trim()] = scalar(part.slice(c + 1))
57
- }
58
- return obj
59
- }
60
- if (s.startsWith("[") && s.endsWith("]")) return s.slice(1, -1).split(",").map((x) => scalar(x))
61
- if ((s.startsWith("'") && s.endsWith("'")) || (s.startsWith("\"") && s.endsWith("\""))) return s.slice(1, -1).replace(/''/g, "'")
62
- return s
63
- }
64
-
65
- export const parseUnityYaml = (text: string): { [k: string]: Y } => {
66
- const lines = splitLines(text)
67
- let pos = 0
68
- const parseValue = (minIndent: number): Y => {
69
- if (pos >= lines.length || lines[pos].indent < minIndent) return ""
70
- return lines[pos].text.startsWith("- ") || lines[pos].text === "-" ? parseSeq(lines[pos].indent) : parseMap(lines[pos].indent)
71
- }
72
- const parseMap = (indent: number): { [k: string]: Y } => {
73
- const obj: { [k: string]: Y } = {}
74
- while (pos < lines.length && lines[pos].indent === indent && !lines[pos].text.startsWith("- ")) {
75
- const t = lines[pos].text
76
- const c = t.search(/:( |$)/)
77
- if (c < 0) { pos++; continue }
78
- const key = t.slice(0, c).trim()
79
- const rest = t.slice(c + 1).trim()
80
- pos++
81
- if (rest === "") obj[key] = pos < lines.length && lines[pos].indent > indent ? parseValue(indent + 1) : (pos < lines.length && lines[pos].indent === indent && lines[pos].text.startsWith("- ") ? parseSeq(indent) : "")
82
- else obj[key] = scalar(rest)
83
- }
84
- return obj
85
- }
86
- const parseSeq = (indent: number): Y[] => {
87
- const arr: Y[] = []
88
- while (pos < lines.length && lines[pos].indent === indent && (lines[pos].text.startsWith("- ") || lines[pos].text === "-")) {
89
- const rest = lines[pos].text.slice(1).trim()
90
- if (rest === "") { pos++; arr.push(parseValue(indent + 1)); continue }
91
- // a flow map / flow list / plain scalar item (`- {fileID: 0, guid: …}`) is one value, not a key
92
- if (rest.startsWith("{") || rest.startsWith("[") || rest.search(/:( |$)/) < 0) { pos++; arr.push(scalar(rest)); continue }
93
- // the item's first key shares the dash line; the rest of its keys sit two deeper
94
- lines[pos] = { indent: indent + 2, text: rest }
95
- arr.push(parseValue(indent + 2))
96
- }
97
- return arr
98
- }
99
- return parseMap(lines[0]?.indent ?? 0)
100
- }
101
-
102
- // ---- the scene -----------------------------------------------------------------------------------
103
-
104
- type Ref = { fileID: string, guid?: string }
105
- const ref = (v: Y | undefined): Ref => (typeof v === "object" && !Array.isArray(v) && v ? { fileID: String(v.fileID ?? "0"), guid: typeof v.guid === "string" ? v.guid : undefined } : { fileID: "0" })
106
- const num = (v: Y | undefined, d = 0): number => { const n = typeof v === "string" ? Number(v) : NaN; return Number.isFinite(n) ? n : d }
107
- const str = (v: Y | undefined): string => (typeof v === "string" ? v : "")
108
- const vec3 = (v: Y | undefined, d: [number, number, number]): [number, number, number] => {
109
- const o = typeof v === "object" && !Array.isArray(v) && v ? v : {}
110
- return [ num(o.x, d[0]), num(o.y, d[1]), num(o.z, d[2]) ]
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
+ /** The level this part is drawn at. THE ENGINE'S LOD GROUP IS THE WHOLE GLB (creator-gl lod.cpp): at level n it draws
36
+ * the `_LOD<n>` nodes and nothing else, so a renderer Unity draws at several levels (listed under each, or in a
37
+ * cull-only LODGroup, or in none while the kind has levels) is written once per level — the same mesh under a
38
+ * node per level. One part here is one node. */
39
+ lod: number
40
+ castShadows: boolean
111
41
  }
112
- const quat = (v: Y | undefined): [number, number, number, number] => {
113
- const o = typeof v === "object" && !Array.isArray(v) && v ? v : {}
114
- return [ num(o.x, 0), num(o.y, 0), num(o.z, 0), num(o.w, 1) ]
115
- }
116
-
117
- type Vec3 = [number, number, number]
118
- type Quat = [number, number, number, number]
119
- type TRS = { t: Vec3, r: Quat, s: Vec3 }
120
- const ID: TRS = { t: [ 0, 0, 0 ], r: [ 0, 0, 0, 1 ], s: [ 1, 1, 1 ] }
121
- const qmul = (a: Quat, b: Quat): Quat => [
122
- a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1],
123
- a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0],
124
- a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3],
125
- a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2],
126
- ]
127
- const qnorm = (q: Quat): Quat => { const l = Math.hypot(q[0], q[1], q[2], q[3]) || 1; return [ q[0] / l, q[1] / l, q[2] / l, q[3] / l ] }
128
- const qrot = (q: Quat, v: Vec3): Vec3 => {
129
- const [ x, y, z, w ] = q
130
- const ix = w * v[0] + y * v[2] - z * v[1], iy = w * v[1] + z * v[0] - x * v[2], iz = w * v[2] + x * v[1] - y * v[0], iw = -x * v[0] - y * v[1] - z * v[2]
131
- return [ ix * w + iw * -x + iy * -z - iz * -y, iy * w + iw * -y + iz * -x - ix * -z, iz * w + iw * -z + ix * -y - iy * -x ]
132
- }
133
- const compose = (p: TRS, l: TRS): TRS => {
134
- const st: Vec3 = [ p.s[0] * l.t[0], p.s[1] * l.t[1], p.s[2] * l.t[2] ]
135
- const rt = qrot(p.r, st)
136
- return { t: [ p.t[0] + rt[0], p.t[1] + rt[1], p.t[2] + rt[2] ], r: qnorm(qmul(p.r, l.r)), s: [ p.s[0] * l.s[0], p.s[1] * l.s[1], p.s[2] * l.s[2] ] }
137
- }
138
-
139
- export type Handedness = "x" | "z" | "none"
140
- const toLeCodes = (w: TRS, h: Handedness): TRS => {
141
- if (h === "x") return { t: [ -w.t[0], w.t[1], w.t[2] ], r: [ w.r[0], -w.r[1], -w.r[2], w.r[3] ], s: w.s }
142
- if (h === "z") return { t: [ w.t[0], w.t[1], -w.t[2] ], r: [ -w.r[0], -w.r[1], w.r[2], w.r[3] ], s: w.s }
143
- return w
42
+ export type KindCollider = {
43
+ path: string
44
+ matrix: Mat4
45
+ trigger: boolean
46
+ } & (
47
+ | { shape: "box", center: Vec3, size: Vec3 }
48
+ | { shape: "sphere", center: Vec3, radius: number }
49
+ | { shape: "capsule", center: Vec3, radius: number, height: number, direction: number }
50
+ | { shape: "mesh", convex: boolean, mesh: { guid: string, fileID: string } | null }
51
+ )
52
+ export type KindLight = { path: string, matrix: Mat4, type: number, color: [number, number, number, number], intensity: number, range: number, area?: LightArea, unit?: string }
53
+ export type SceneKind = {
54
+ key: string
55
+ /** File-safe unique name (`P_Bush_3`, `P_Bush_3_v2`). */
56
+ name: string
57
+ prefab: string | null
58
+ prefabPath: string | null
59
+ parts: KindPart[]
60
+ colliders: KindCollider[]
61
+ lights: KindLight[]
62
+ particles: number
63
+ count: number
144
64
  }
145
-
146
65
  export type SceneInstance = {
66
+ kind: string
147
67
  name: string
148
- /** prefab GUID (key into `prefabs`), or null for a plain mesh object */
149
- prefab: string | null
150
- /** `Static/Roads` — the scene's grouping GameObjects above the instance */
68
+ /** `Vegetation/Grass` — the scene groups above the instance; `Terrain/Trees`, `Terrain/Details`. */
151
69
  group: string
152
70
  position: Vec3
153
71
  rotation: Quat
154
72
  scale: Vec3
155
- /** true when a parent sat inside a binary prefab — placed at that prefab's root, not its inner child */
156
- approximate?: boolean
157
73
  }
158
- export type ScenePrefab = { path: string | null, name: string, fbx: string | null, node: string | null, count: number }
74
+ /** An AREA light's emitter (Unity rectangle / disc lights, HDRP rectangle / tube / disc): `width` runs along the light's
75
+ * local X (`along`), `height` along its local Y (`up`; 0 for a tube), and it emits along `direction` (local +Z). The
76
+ * lightmap bake takes such a lamp as a rectangle (`Light.point({ bakeArea })`) - a fixture lights from its length, not
77
+ * from a point. */
78
+ export type LightArea = { shape: "rect" | "tube" | "disc", width: number, height: number }
79
+ export type SceneLight = { name: string, type: "directional" | "point" | "spot", position: Vec3, direction: Vec3, color: [number, number, number], intensity: number, range: number, shadows: boolean,
80
+ area?: LightArea, along?: Vec3, up?: Vec3,
81
+ /** HDRP's unit of `intensity`: lumen | candela | lux | nits | ev100 (absent = the pipeline's unitless number). */
82
+ unit?: string }
83
+ export type SceneCamera = { name: string, position: Vec3, forward: Vec3, fov: number }
84
+ export type SceneTerrainInfo = {
85
+ name: string
86
+ dataPath: string
87
+ /** LeCodes node origin (sample (0,0) — the mirrored grid's corner). */
88
+ position: Vec3
89
+ resolution: number
90
+ size: Vec3
91
+ layers: { name: string, diffuse: string | null, normal: string | null, tileSize: [number, number], coverage: number }[]
92
+ }
159
93
  export type SceneImport = {
160
94
  source: string
161
95
  frame: string
162
- prefabs: Record<string, ScenePrefab>
96
+ kinds: Record<string, SceneKind>
163
97
  instances: SceneInstance[]
164
- stats: { prefabInstances: number, meshObjects: number, inactive: number, approximate: number, unresolvedPrefabs: number }
98
+ lights: SceneLight[]
99
+ cameras: SceneCamera[]
100
+ terrain: SceneTerrainInfo | null
101
+ 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 }
165
102
  }
103
+ /** The terrain arrays that do not belong in JSON (the `.terrain` writer reads them). */
104
+ export type SceneImportFull = SceneImport & { terrainData: UnityTerrain | null }
166
105
 
167
106
  export type ImportSceneOptions = {
168
- /** `unity-index.txt` for GUID → path (default: found walking up from the scene file) */
107
+ /** `unity-index.txt` for GUID → path (default: found walking up from the scene, else the Unity
108
+ * project's `.meta` sidecars). */
169
109
  indexFile?: string | null
170
110
  handedness?: Handedness
171
- /** keep only instances whose prefab path / name / group matches */
111
+ /** keep only instances whose kind name / group matches */
172
112
  filter?: RegExp
113
+ /** …and the opposite: DROP every instance whose kind name / group / node name matches. For props a
114
+ * pack authors inside the geometry they decorate — the Modular Shooting Range sinks its wall signs
115
+ * ~3.5 cm into the concrete and lets Unity's decal ordering sort it out, so they z-fight here. */
116
+ drop?: RegExp
117
+ /** DROP the placements standing at these points (lecodes frame, as the scene file prints them; within 10 cm) —
118
+ * for the one instance of a kind the game moves while its twins stay: the shooting range's five lane targets
119
+ * are `SM_CardboardTarget_01` like the sixth on a shelf, so no name tells them apart, their place does. */
120
+ dropAt?: [number, number, number][]
173
121
  /** Unity-space X/Z rectangle [x0, z0, x1, z1] — instances outside are dropped */
174
122
  region?: [number, number, number, number]
175
123
  /** only instances under a group whose path contains this */
176
124
  group?: string
177
125
  includeInactive?: boolean
126
+ /** Skip the terrain's painted trees / detail meshes. */
127
+ noTrees?: boolean
128
+ noDetails?: boolean
129
+ }
130
+
131
+ // ---- component readers -----------------------------------------------------------------------------
132
+
133
+ /** A collider component as a kind's collider (`M` = the owner's kind-local matrix, mirrored), or null for any other component. */
134
+ const readCollider = (c: UComp, path: string, M: Mat4): KindCollider | null => {
135
+ const base = { path, matrix: M, trigger: num(c.body.m_IsTrigger, 0) !== 0 }
136
+ switch (c.cls) {
137
+ case "BoxCollider": return { ...base, shape: "box", center: vec3(c.body.m_Center), size: vec3(c.body.m_Size, [ 1, 1, 1 ]) }
138
+ case "SphereCollider": return { ...base, shape: "sphere", center: vec3(c.body.m_Center), radius: num(c.body.m_Radius, 0.5) }
139
+ 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) }
140
+ case "MeshCollider": {
141
+ const mesh = ref(c.body.m_Mesh)
142
+ return { ...base, shape: "mesh", convex: num(c.body.m_Convex, 0) !== 0, mesh: mesh.guid ? { guid: mesh.guid, fileID: mesh.fileID } : null }
143
+ }
144
+ default: return null
145
+ }
146
+ }
147
+
148
+ /** The emitter of an area light, or undefined for a punctual one. HDRP keeps it in the HDAdditionalLightData script next to
149
+ * the Light (m_PointlightHDType 1 = area; m_AreaLightShape 0 rectangle, 1 tube, 2 disc; m_ShapeWidth / m_ShapeHeight);
150
+ * the built-in and URP pipelines in the Light itself (m_Type 3 rectangle, 4 disc; m_AreaSize). */
151
+ const HD_UNITS = [ "lumen", "candela", "lux", "nits", "ev100" ]
152
+ const readArea = (comps: UComp[], light: UComp): { area?: LightArea, unit?: string } => {
153
+ const hd = comps.find((c) => c.cls === "MonoBehaviour" && c.body.m_AreaLightShape !== undefined && c.body.m_ShapeWidth !== undefined)
154
+ if (hd) {
155
+ const unit = HD_UNITS[num(hd.body.m_LightUnit, -1)]
156
+ if (num(hd.body.m_PointlightHDType, 0) !== 1) return { unit }
157
+ const shape = ([ "rect", "tube", "disc" ] as const)[num(hd.body.m_AreaLightShape, 0)] ?? "rect"
158
+ const width = num(hd.body.m_ShapeWidth, 0.5)
159
+ return { unit, area: { shape, width, height: shape === "tube" ? 0 : shape === "disc" ? width : num(hd.body.m_ShapeHeight, 0.5) } }
160
+ }
161
+ const type = num(light.body.m_Type, 2)
162
+ if (type !== 3 && type !== 4) return {}
163
+ const size = light.body.m_AreaSize
164
+ const width = isMap(size) ? num(size.x, 1) : 1, height = isMap(size) ? num(size.y, 1) : 1
165
+ return { area: { shape: type === 4 ? "disc" : "rect", width, height: type === 4 ? width : height } }
166
+ }
167
+ const axisOf = (M: Mat4, col: number): Vec3 => {
168
+ const f = M.slice(col * 4, col * 4 + 3)
169
+ const l = Math.hypot(...f) || 1
170
+ return f.map((v) => r6(v / l)) as Vec3
171
+ }
172
+
173
+ /** The unit +Z of a (mirrored) world matrix: where a light shines and a camera looks. */
174
+ const forwardOf = (M: Mat4): Vec3 => {
175
+ const f = M.slice(8, 11)
176
+ const l = Math.hypot(...f) || 1
177
+ return f.map((v) => r6(v / l)) as Vec3
178
+ }
179
+
180
+ const readLight = (name: string, light: UComp, M: Mat4, comps: UComp[] = []): SceneLight => {
181
+ const type = num(light.body.m_Type, 2)
182
+ const c = color(light.body.m_Color)
183
+ return {
184
+ name, type: type === 1 ? "directional" : type === 0 ? "spot" : "point", position: decompose(M).t.map(r6) as Vec3, direction: forwardOf(M),
185
+ color: [ c[0], c[1], c[2] ], intensity: num(light.body.m_Intensity, 1), range: num(light.body.m_Range, 10),
186
+ shadows: isMap(light.body.m_Shadows) ? num(light.body.m_Shadows.m_Type, 0) !== 0 : false,
187
+ ...readArea(comps, light), along: axisOf(M, 0), up: axisOf(M, 1),
188
+ }
178
189
  }
179
190
 
180
- export const importUnityScene = (scenePath: string, opts: ImportSceneOptions = {}): SceneImport => {
191
+ const readCamera = (name: string, cam: UComp, M: Mat4): SceneCamera =>
192
+ ({ name, position: [ M[12], M[13], M[14] ].map(r6) as Vec3, forward: forwardOf(M), fov: num(cam.body["field of view"], 60) })
193
+
194
+ export const importUnityScene = async (scenePath: string, opts: ImportSceneOptions = {}): Promise<SceneImportFull> => {
181
195
  const text = readFileSync(scenePath, "utf8")
182
196
  if (!text.startsWith("%YAML")) throw new Error(`${basename(scenePath)} is not text-serialised (Unity: Edit ▸ Project Settings ▸ Editor ▸ Asset Serialization = Force Text)`)
183
197
  const handedness = opts.handedness ?? "x"
184
- const indexFile = opts.indexFile === undefined ? findUnityIndex(dirname(resolve(scenePath))) : opts.indexFile
185
- const guidPath = new Map<string, string>()
186
- if (indexFile && existsSync(indexFile)) {
187
- for (const line of readFileSync(indexFile, "utf8").split(/\r?\n/)) { const t = line.indexOf("\t"); if (t > 0) guidPath.set(line.slice(0, t), line.slice(t + 1).trim()) }
188
- }
198
+ const mirror = (m: Mat4): Mat4 => (handedness === "x" ? mirrorX(m) : m)
199
+ const resolver = resolverFor(scenePath, opts.indexFile)
200
+ const tstats = newStats()
201
+ const loader = new PrefabLoader(resolver, tstats)
202
+ // the terrain first: its tree / detail prototypes join the model preload
203
+ const tdGuid = /m_TerrainData: \{fileID: -?\d+, guid: ([0-9a-f]{32})/.exec(text)?.[1]
204
+ const tdPath = tdGuid ? resolver.resolve(tdGuid) : null
205
+ const tdLocal = tdPath ? readUnityTerrain(tdPath, [ 0, 0, 0 ], resolver) : null
206
+ await loader.preload([ text ], tdLocal ? [ ...new Set([ ...tdLocal.trees, ...tdLocal.details ].map((p) => p.prefab)) ] : [])
207
+ const tree = loader.buildText(text, null)
189
208
 
190
- // split into objects
191
- type Obj = { type: number, id: string, stripped: boolean, cls: string, body: { [k: string]: Y } }
192
- const objs = new Map<string, Obj>()
193
- const re = /^--- !u!(\d+) &(-?\d+)( stripped)?\s*$/gm
194
- const starts: { idx: number, type: number, id: string, stripped: boolean, end: number }[] = []
195
- for (let m = re.exec(text); m; m = re.exec(text)) starts.push({ idx: m.index, type: +m[1], id: m[2], stripped: !!m[3], end: m.index + m[0].length })
196
- for (let i = 0; i < starts.length; i++) {
197
- const s = starts[i]
198
- const chunk = text.slice(s.end, i + 1 < starts.length ? starts[i + 1].idx : text.length)
199
- const body = parseUnityYaml(chunk)
200
- const cls = Object.keys(body)[0] ?? ""
201
- objs.set(s.id, { type: s.type, id: s.id, stripped: s.stripped, cls, body: (body[cls] as { [k: string]: Y }) ?? {} })
202
- }
209
+ const kinds: Record<string, SceneKind> = {}
210
+ const nameUse = new Map<string, number>()
211
+ const instances: SceneInstance[] = []
212
+ const lights: SceneLight[] = []
213
+ const cameras: SceneCamera[] = []
214
+ 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 }
215
+ 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 }
203
216
 
204
- // transforms + game objects
205
- type T = { local: TRS, father: string, gameObject: string, prefabInstance: string | null }
206
- const transforms = new Map<string, T>()
207
- const gameObjects = new Map<string, { name: string, active: boolean, components: string[] }>()
208
- const meshFilters = new Map<string, { mesh: Ref }>() // by game object id
209
- type PI = { parent: string, local: TRS, guid: string | undefined, name: string | null, active: boolean }
210
- const prefabInstances = new Map<string, PI>()
211
- for (const o of objs.values()) {
212
- if (o.cls === "Transform" || o.cls === "RectTransform") {
213
- transforms.set(o.id, {
214
- local: { t: vec3(o.body.m_LocalPosition, [ 0, 0, 0 ]), r: quat(o.body.m_LocalRotation), s: vec3(o.body.m_LocalScale, [ 1, 1, 1 ]) },
215
- father: ref(o.body.m_Father).fileID, gameObject: ref(o.body.m_GameObject).fileID,
216
- prefabInstance: o.stripped ? ref(o.body.m_PrefabInstance).fileID : null,
217
- })
218
- } else if (o.cls === "GameObject") {
219
- const comps = Array.isArray(o.body.m_Component) ? o.body.m_Component.map((c) => ref(typeof c === "object" && !Array.isArray(c) ? c.component : undefined).fileID) : []
220
- gameObjects.set(o.id, { name: str(o.body.m_Name), active: num(o.body.m_IsActive, 1) !== 0, components: comps })
221
- } else if (o.cls === "MeshFilter") {
222
- meshFilters.set(ref(o.body.m_GameObject).fileID, { mesh: ref(o.body.m_Mesh) })
223
- } else if (o.cls === "PrefabInstance") {
224
- const mod = (typeof o.body.m_Modification === "object" && !Array.isArray(o.body.m_Modification) ? o.body.m_Modification : {}) as { [k: string]: Y }
225
- const local: TRS = { t: [ 0, 0, 0 ], r: [ 0, 0, 0, 1 ], s: [ 1, 1, 1 ] }
226
- let name: string | null = null, active = true
227
- const mods = Array.isArray(mod.m_Modifications) ? mod.m_Modifications : []
228
- // the modifications target the prefab's ROOT transform (the first fileID seen for TRS keys);
229
- // overrides on inner children are ignored — they need the prefab's contents
230
- let rootTarget: string | null = null
231
- for (const m of mods) {
232
- if (typeof m !== "object" || Array.isArray(m)) continue
233
- const path = str(m.propertyPath), value = str(m.value), target = ref(m.target).fileID
234
- const mt = /^m_Local(Position|Rotation|Scale)\.([xyzw])$/.exec(path)
235
- if (mt) {
236
- if (rootTarget === null) rootTarget = target
237
- if (target !== rootTarget) continue
238
- const k = "xyzw".indexOf(mt[2])
239
- if (mt[1] === "Position") local.t[k] = num(value, local.t[k])
240
- else if (mt[1] === "Rotation") local.r[k] = num(value, local.r[k])
241
- else local.s[k] = num(value, local.s[k])
242
- } else if (path === "m_Name" && name === null) name = value
243
- else if (path === "m_IsActive" && value === "0") active = false
217
+ // ---- a prefab instance → a kind (+ its nested prefab instances as placements of their own) ----
218
+ // Kinds stop at nested prefab roots: a composite (a cliff wall built from cliff-rock prefabs, a
219
+ // campfire around a fire prefab) becomes placements of its LEAF prefabs, so a moved rock inside one
220
+ // wall is one more placement, not one more GLB carrying twelve rocks.
221
+ const lodSuffix = (n: string): number => { const m = /_LOD(\d+)$/i.exec(n); return m ? +m[1] : 0 }
222
+ const kindOf = (root: UGO, prefab: string | null, fallbackName: string, nested: { g: UGO, local: Mat4 }[]): SceneKind | null => {
223
+ const parts: KindPart[] = [], colliders: KindCollider[] = [], klights: KindLight[] = []
224
+ // a renderer and the levels it is drawn at: `[]` = every level the kind has (expanded once the kind's levels are known)
225
+ const drawn: { part: Omit<KindPart, "lod">, levels: number[] }[] = []
226
+ let particles = 0
227
+ const lods = lodLevels([ root ])
228
+ const walk = (g: UGO, parentM: Mat4, path: string, top: boolean): void => {
229
+ if (!goActive(g) && !opts.includeInactive) return
230
+ const l = localTRS(g)
231
+ const m = top ? I4() : mmul(parentM, trsMatrix(l.t, l.r, l.s))
232
+ if (!top && g.prefab) { nested.push({ g, local: m }); return }
233
+ const p = top ? "" : (path ? `${path}/` : "") + goName(g)
234
+ const M = mirror(m)
235
+ const mf = comp(g, "MeshFilter"), mr = comp(g, "MeshRenderer")
236
+ if (mf && mr && enabled(mr)) {
237
+ const mesh = ref(mf.body.m_Mesh)
238
+ if (mesh.guid && mesh.guid !== "0000000000000000e000000000000000") {
239
+ const mats = Array.isArray(mr.body.m_Materials) ? mr.body.m_Materials.map((x) => ref(x).guid ?? "") : []
240
+ // in no LODGroup: a loose renderer named like its node (`…_LOD1`) keeps that level; a plain one is drawn at every level
241
+ const suffix = lodSuffix(goName(g))
242
+ const levels = lods.get(mr) ?? (suffix > 0 ? [ suffix ] : [])
243
+ drawn.push({ part: { path: p, matrix: M, mesh: { guid: mesh.guid, fileID: mesh.fileID }, materials: mats, castShadows: num(mr.body.m_CastShadows, 1) !== 0 }, levels })
244
+ }
245
+ }
246
+ for (const c of g.comps) {
247
+ if (!enabled(c)) continue
248
+ const collider = readCollider(c, p, M)
249
+ if (collider) colliders.push(collider)
250
+ 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) })
251
+ else if (c.cls === "ParticleSystem") particles++
244
252
  }
245
- local.r = qnorm(local.r)
246
- prefabInstances.set(o.id, { parent: ref(mod.m_TransformParent).fileID, local, guid: ref(o.body.m_SourcePrefab).guid, name, active })
253
+ for (const ch of g.children) walk(ch, m, p, false)
247
254
  }
255
+ walk(root, I4(), "", true)
256
+ // the kind's levels: a part drawn at every level gets a node per level (the same mesh, `<name>_LOD<n>` beside `<name>`)
257
+ const maxLevel = drawn.reduce((m, d) => Math.max(m, ...d.levels), 0)
258
+ for (const d of drawn) {
259
+ const levels = d.levels.length ? d.levels : Array.from({ length: maxLevel + 1 }, (_, i) => i)
260
+ for (const lod of levels) parts.push({ ...d.part, lod })
261
+ }
262
+ stats.particles += particles
263
+ stats.prefabLights += klights.length
264
+ if (!parts.length && !colliders.length) return null
265
+ 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) })) ])
266
+ let k = kinds[key]
267
+ if (!k) {
268
+ const base = (prefabName(prefab) ?? fallbackName).replace(/\s*\(\d+\)$/, "").replace(/[^\w.-]+/g, "_")
269
+ const n = (nameUse.get(base) ?? 0) + 1
270
+ nameUse.set(base, n)
271
+ if (n > 1) stats.variants++
272
+ 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 }
273
+ }
274
+ return k
248
275
  }
249
276
 
250
- // world transforms, memoised; stripped transforms resolve to their prefab instance
251
- const worldT = new Map<string, { w: TRS, approx: boolean, path: string[] }>()
252
- const worldPI = new Map<string, { w: TRS, approx: boolean, path: string[] }>()
253
- const ROOT = { w: ID, approx: false, path: [] as string[] }
254
- const resolveT = (id: string, depth = 0): { w: TRS, approx: boolean, path: string[] } => {
255
- if (id === "0" || depth > 64) return ROOT
256
- const hit = worldT.get(id); if (hit) return hit
257
- const t = transforms.get(id)
258
- let out = ROOT
259
- if (t) {
260
- if (t.prefabInstance) { const p = resolvePI(t.prefabInstance, depth + 1); out = { w: p.w, approx: true, path: p.path } }
261
- else {
262
- const par = resolveT(t.father, depth + 1)
263
- const go = gameObjects.get(t.gameObject)
264
- out = { w: compose(par.w, t.local), approx: par.approx, path: go?.name ? [ ...par.path, go.name ] : par.path }
265
- }
266
- }
267
- worldT.set(id, out)
268
- return out
277
+ // ---- placements ----
278
+ const worldOf = (m: Mat4): { position: Vec3, rotation: Quat, scale: Vec3 } => {
279
+ const d = decompose(mirror(m))
280
+ return { position: d.t.map(r6) as Vec3, rotation: d.r.map(r6) as Quat, scale: d.s.map(r6) as Vec3 }
269
281
  }
270
- const resolvePI = (id: string, depth = 0): { w: TRS, approx: boolean, path: string[] } => {
271
- const hit = worldPI.get(id); if (hit) return hit
272
- const p = prefabInstances.get(id)
273
- let out = ROOT
274
- if (p) {
275
- const par = resolveT(p.parent, depth + 1)
276
- const nm = p.name ?? prefabName(p.guid)
277
- out = { w: compose(par.w, p.local), approx: par.approx, path: [ ...par.path, nm ] }
278
- }
279
- worldPI.set(id, out)
280
- return out
282
+ 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])
283
+ const place = (k: SceneKind, name: string, group: string, m: Mat4): boolean => {
284
+ if (opts.group && !group.includes(opts.group)) return false
285
+ if (opts.filter && !(opts.filter.test(k.name) || opts.filter.test(group) || opts.filter.test(name))) return false
286
+ if (opts.drop && (opts.drop.test(k.name) || opts.drop.test(group) || opts.drop.test(name))) return false
287
+ if (!inRegion(m)) return false
288
+ const w = worldOf(m)
289
+ 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
290
+ k.count++
291
+ instances.push({ kind: k.name, name, group, ...w })
292
+ return true
281
293
  }
282
- const prefabName = (guid: string | undefined): string => {
283
- const p = guid ? guidPath.get(guid) : undefined
284
- return p ? basename(p).replace(/\.prefab$/i, "") : (guid ?? "prefab")
294
+ /** A prefab instance at `world` (its root's own transform included) — itself and every nested one. */
295
+ const placePrefab = (root: UGO, world: Mat4, groups: string[], fallbackName: string): number => {
296
+ const nested: { g: UGO, local: Mat4 }[] = []
297
+ const k = kindOf(root, root.prefab ?? null, fallbackName, nested)
298
+ const name = goName(root) || fallbackName
299
+ let n = k && place(k, name, groups.join("/"), world) ? 1 : 0
300
+ for (const x of nested) n += placePrefab(x.g, mmul(world, x.local), [ ...groups, name ], goName(x.g))
301
+ return n
285
302
  }
286
303
 
287
- // FBX guess per prefab: an FBX from the index under the prefab's folder (`Source/` first), whose
288
- // stem is a prefix of the prefab name when there are several
289
- const fbxByDir = new Map<string, string[]>()
290
- for (const p of guidPath.values()) if (/\.fbx$/i.test(p)) { const d = dirname(p); fbxByDir.set(d, [ ...(fbxByDir.get(d) ?? []), p ]) }
291
- const guessFbx = (prefabPath: string, name: string): string | null => {
292
- const d = dirname(prefabPath)
293
- const cands = [ ...(fbxByDir.get(d + "/Source") ?? []), ...(fbxByDir.get(d) ?? []), ...(fbxByDir.get(dirname(d) + "/Source") ?? []) ]
294
- if (cands.length === 0) return null
295
- const lname = name.toLowerCase()
296
- const stem = (f: string): string => basename(f).replace(/\.fbx$/i, "").toLowerCase()
297
- return cands.find((f) => lname.startsWith(stem(f))) ?? cands.find((f) => lname.includes(stem(f)) || stem(f).includes(lname)) ?? (cands.length === 1 ? cands[0] : null)
304
+ let terrainInfo: SceneTerrainInfo | null = null
305
+ let terrainOrigin: Vec3 = [ 0, 0, 0 ]
306
+ const visit = (g: UGO, parentM: Mat4, groups: string[]): void => {
307
+ if (!goActive(g) && !opts.includeInactive) { stats.inactive++; return }
308
+ const l = localTRS(g)
309
+ const m = mmul(parentM, trsMatrix(l.t, l.r, l.s))
310
+ const name = goName(g)
311
+ if (g.prefab) {
312
+ stats.prefabInstances++
313
+ placePrefab(g, m, groups, name)
314
+ return
315
+ }
316
+ // the scene's own objects
317
+ const light = comp(g, "Light")
318
+ if (light && enabled(light)) lights.push(readLight(name, light, mirror(m), g.comps))
319
+ const cam = comp(g, "Camera")
320
+ if (cam && enabled(cam)) cameras.push(readCamera(name, cam, mirror(m)))
321
+ const terrain = comp(g, "Terrain")
322
+ if (terrain && enabled(terrain) && tdLocal && !terrainInfo) {
323
+ const td = tdLocal
324
+ terrainOrigin = [ m[12], m[13], m[14] ]
325
+ const origin: Vec3 = handedness === "x" ? [ -(m[12] + td.size[0]), m[13], m[14] ] : [ m[12], m[13], m[14] ]
326
+ terrainInfo = {
327
+ name: td.name, dataPath: tdPath!, position: origin.map(r6) as Vec3, resolution: td.resolution, size: td.size,
328
+ 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 } }),
329
+ }
330
+ }
331
+ if (comp(g, "MeshFilter") && comp(g, "MeshRenderer") && !terrain) {
332
+ // a loose mesh object: its own kind, children visited separately
333
+ const k = kindOf({ ...g, children: [] }, null, name, [])
334
+ if (k && place(k, name, groups.join("/"), m)) stats.sceneMeshes++
335
+ }
336
+ for (const ch of g.children) visit(ch, m, [ ...groups, name ])
298
337
  }
338
+ for (const r of tree.roots) visit(r, I4(), [])
299
339
 
300
- const prefabs: Record<string, ScenePrefab> = {}
301
- const instances: SceneInstance[] = []
302
- const stats = { prefabInstances: 0, meshObjects: 0, inactive: 0, approximate: 0, unresolvedPrefabs: 0 }
303
- const inRegion = (w: TRS): boolean => !opts.region || (w.t[0] >= opts.region[0] && w.t[0] <= opts.region[2] && w.t[2] >= opts.region[1] && w.t[2] <= opts.region[3])
304
- const push = (name: string, prefab: string | null, res: { w: TRS, approx: boolean, path: string[] }, prefabPath: string | null): void => {
305
- const group = res.path.slice(0, -1).join("/")
306
- if (opts.group && !res.path.join("/").includes(opts.group)) return
307
- if (opts.filter && !(opts.filter.test(name) || opts.filter.test(group) || (prefabPath !== null && opts.filter.test(prefabPath)))) return
308
- if (!inRegion(res.w)) return
309
- const w = toLeCodes(res.w, handedness)
310
- const inst: SceneInstance = { name, prefab, group, position: w.t.map(r6) as Vec3, rotation: w.r.map(r6) as Quat, scale: w.s.map(r6) as Vec3 }
311
- if (res.approx) { inst.approximate = true; stats.approximate++ }
312
- instances.push(inst)
313
- }
314
- for (const [ id, p ] of prefabInstances) {
315
- stats.prefabInstances++
316
- if (!p.active && !opts.includeInactive) { stats.inactive++; continue }
317
- const guid = p.guid ?? "?"
318
- const path = guidPath.get(guid) ?? null
319
- if (!prefabs[guid]) {
320
- const name = prefabName(p.guid)
321
- if (!path) stats.unresolvedPrefabs++
322
- const fbx = path ? guessFbx(path, name) : null
323
- prefabs[guid] = { path, name, fbx, node: fbx ? name : null, count: 0 }
340
+ // ---- terrain trees + details: placements of their prefabs ----
341
+ const td = terrainInfo ? tdLocal : null
342
+ if (td) {
343
+ const protos = new Map<string, UGO | null>()
344
+ const protoRoot = (guid: string): UGO | null => {
345
+ if (!protos.has(guid)) protos.set(guid, loader.prefab(guid)?.roots[0] ?? null)
346
+ return protos.get(guid)!
324
347
  }
325
- prefabs[guid].count++
326
- push(p.name ?? prefabs[guid].name, guid, resolvePI(id), path)
327
- }
328
- for (const [ goId, mf ] of meshFilters) {
329
- const go = gameObjects.get(goId)
330
- if (!go) continue
331
- const tId = go.components.find((c) => transforms.has(c))
332
- if (!tId) continue
333
- if (!go.active && !opts.includeInactive) { stats.inactive++; continue }
334
- if (mf.mesh.guid === "0000000000000000e000000000000000") continue // Unity built-in primitives
335
- stats.meshObjects++
336
- const key = mf.mesh.guid ? `mesh:${mf.mesh.guid}` : `mesh:${goId}`
337
- const fbx = mf.mesh.guid ? guidPath.get(mf.mesh.guid) ?? null : null
338
- if (!prefabs[key]) prefabs[key] = { path: null, name: go.name, fbx, node: go.name.replace(/\s*\(\d+\)$/, ""), count: 0 }
339
- prefabs[key].count++
340
- push(go.name, key, resolveT(tId), fbx)
348
+ const emit = (list: UnityTerrain["trees"], group: string): number => {
349
+ let n = 0
350
+ for (const p of list) {
351
+ const root = protoRoot(p.prefab)
352
+ if (!root) continue
353
+ root.prefab ??= p.prefab
354
+ const h = p.rotation / 2
355
+ const pos: Vec3 = [ terrainOrigin[0] + p.position[0], terrainOrigin[1] + p.position[1], terrainOrigin[2] + p.position[2] ]
356
+ 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)
357
+ }
358
+ return n
359
+ }
360
+ if (!opts.noTrees) stats.trees = emit(td.trees, "Terrain/Trees")
361
+ if (!opts.noDetails) stats.details = emit(td.details, "Terrain/Details")
341
362
  }
342
- // counts = surviving instances (filters / region applied); prefabs nothing uses are dropped
343
- for (const p of Object.values(prefabs)) p.count = 0
344
- for (const i of instances) if (i.prefab && prefabs[i.prefab]) prefabs[i.prefab].count++
345
- for (const k of Object.keys(prefabs)) if (!prefabs[k].count) delete prefabs[k]
363
+
364
+ // kinds nothing uses (filtered out) are dropped
365
+ for (const k of Object.keys(kinds)) if (!kinds[k].count) delete kinds[k]
366
+ stats.unresolvedMods = tstats.unresolvedMods
367
+ stats.guessedTransforms = tstats.guessedTransforms
368
+ stats.missingPrefabs = tstats.missingPrefabs.size
369
+ stats.binaryPrefabs = [ ...tstats.binaryPrefabs ]
346
370
 
347
371
  return {
348
372
  source: basename(scenePath),
349
- frame: handedness === "none" ? "unity (left-handed, untouched)" : `lecodes: Unity mirrored on ${handedness.toUpperCase()} (position −${handedness}, quaternion ${handedness === "x" ? "(x, −y, −z, w)" : "(−x, −y, z, w)"}), metres, Y up`,
350
- prefabs, instances, stats,
373
+ frame: handedness === "none" ? "unity (left-handed, untouched)" : "lecodes: Unity mirrored on X (position −x, quaternion (x, −y, −z, w)), metres, Y up",
374
+ kinds, instances, lights, cameras, terrain: terrainInfo, terrainData: terrainInfo ? tdLocal : null, stats,
351
375
  }
352
376
  }
353
377
 
354
- const r6 = (v: number): number => Math.round(v * 1e6) / 1e6
355
-
356
- /** The prefab table as printable lines, most used first. */
378
+ /** The import as printable lines: kinds most used first, then what was skipped. */
357
379
  export const describeSceneImport = (r: SceneImport, max = 20): string[] => {
358
- const rows = Object.entries(r.prefabs).sort((a, b) => b[1].count - a[1].count)
359
- const lines = [ `${r.instances.length} instances of ${rows.length} prefab(s)${r.stats.inactive ? ` · ${r.stats.inactive} inactive skipped` : ""}${r.stats.approximate ? ` · ${r.stats.approximate} placed at a binary prefab's root (inner offset unknown)` : ""}${r.stats.unresolvedPrefabs ? ` · ${r.stats.unresolvedPrefabs} prefab GUID(s) not in the index` : ""}` ]
360
- for (const [ , p ] of rows.slice(0, max)) lines.push(` ${String(p.count).padStart(5)} × ${p.name.padEnd(36)} ${p.fbx ? `${basename(p.fbx)} → --keep ${p.node}` : p.path ? "(no FBX next to the prefab)" : "(unresolved)"}`)
380
+ const rows = Object.values(r.kinds).sort((a, b) => b.count - a.count)
381
+ const s = r.stats
382
+ 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` : ""}` ]
383
+ 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))}` : ""}`)
361
384
  if (rows.length > max) lines.push(` … ${rows.length - max} more`)
385
+ 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(", ")}`)
386
+ 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(", ")}`)
387
+ const areaKinds = Object.values(r.kinds).filter((k) => k.lights.some((l) => l.area))
388
+ 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 })`)
389
+ const notes: string[] = []
390
+ if (s.unresolvedMods) notes.push(`${s.unresolvedMods} prefab modification(s) with no target`)
391
+ if (s.guessedTransforms) notes.push(`${s.guessedTransforms} unaddressable child transform(s) matched by position`)
392
+ if (s.missingPrefabs) notes.push(`${s.missingPrefabs} prefab GUID(s) not found`)
393
+ if (s.binaryPrefabs.length) notes.push(`${s.binaryPrefabs.length} binary-serialised prefab(s) skipped`)
394
+ if (s.particles) notes.push(`${s.particles} particle system(s) not converted`)
395
+ if (s.prefabLights) notes.push(`${s.prefabLights} light(s) inside prefabs not converted`)
396
+ if (notes.length) lines.push(` ℹ ${notes.join(" · ")}`)
362
397
  return lines
363
398
  }