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