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
@@ -1,363 +1,384 @@
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
+ /** LODGroup level (0 when the renderer is in no LODGroup). */
36
+ lod: number
37
+ castShadows: boolean
111
38
  }
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
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
144
61
  }
145
-
146
62
  export type SceneInstance = {
63
+ kind: string
147
64
  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 */
65
+ /** `Vegetation/Grass` — the scene groups above the instance; `Terrain/Trees`, `Terrain/Details`. */
151
66
  group: string
152
67
  position: Vec3
153
68
  rotation: Quat
154
69
  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
70
  }
158
- export type ScenePrefab = { path: string | null, name: string, fbx: string | null, node: string | null, count: number }
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
+ }
159
90
  export type SceneImport = {
160
91
  source: string
161
92
  frame: string
162
- prefabs: Record<string, ScenePrefab>
93
+ kinds: Record<string, SceneKind>
163
94
  instances: SceneInstance[]
164
- stats: { prefabInstances: number, meshObjects: number, inactive: number, approximate: number, unresolvedPrefabs: number }
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 }
165
99
  }
100
+ /** The terrain arrays that do not belong in JSON (the `.terrain` writer reads them). */
101
+ export type SceneImportFull = SceneImport & { terrainData: UnityTerrain | null }
166
102
 
167
103
  export type ImportSceneOptions = {
168
- /** `unity-index.txt` for GUID → path (default: found walking up from the scene file) */
104
+ /** `unity-index.txt` for GUID → path (default: found walking up from the scene, else the Unity
105
+ * project's `.meta` sidecars). */
169
106
  indexFile?: string | null
170
107
  handedness?: Handedness
171
- /** keep only instances whose prefab path / name / group matches */
108
+ /** keep only instances whose kind name / group matches */
172
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][]
173
118
  /** Unity-space X/Z rectangle [x0, z0, x1, z1] — instances outside are dropped */
174
119
  region?: [number, number, number, number]
175
120
  /** only instances under a group whose path contains this */
176
121
  group?: string
177
122
  includeInactive?: boolean
123
+ /** Skip the terrain's painted trees / detail meshes. */
124
+ noTrees?: boolean
125
+ noDetails?: boolean
178
126
  }
179
127
 
180
- export const importUnityScene = (scenePath: string, opts: ImportSceneOptions = {}): SceneImport => {
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> => {
181
192
  const text = readFileSync(scenePath, "utf8")
182
193
  if (!text.startsWith("%YAML")) throw new Error(`${basename(scenePath)} is not text-serialised (Unity: Edit ▸ Project Settings ▸ Editor ▸ Asset Serialization = Force Text)`)
183
194
  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
- }
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)
189
205
 
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
- }
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 }
203
213
 
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
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
244
  }
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 })
245
+ for (const ch of g.children) walk(ch, m, p, false)
247
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
248
261
  }
249
262
 
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
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 }
269
267
  }
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
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
281
279
  }
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")
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
285
288
  }
286
289
 
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)
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 ])
298
323
  }
324
+ for (const r of tree.roots) visit(r, I4(), [])
299
325
 
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 }
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)!
324
333
  }
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)
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")
341
348
  }
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]
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 ]
346
356
 
347
357
  return {
348
358
  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,
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,
351
361
  }
352
362
  }
353
363
 
354
- const r6 = (v: number): number => Math.round(v * 1e6) / 1e6
355
-
356
- /** The prefab table as printable lines, most used first. */
364
+ /** The import as printable lines: kinds most used first, then what was skipped. */
357
365
  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)"}`)
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))}` : ""}`)
361
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(" · ")}`)
362
383
  return lines
363
384
  }