lecodes-assets 0.1.2 → 0.2.1

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 (65) hide show
  1. package/README.md +240 -5
  2. package/dist/cli.js +7757 -683
  3. package/dist/index.js +7866 -675
  4. package/package.json +13 -3
  5. package/src/bvh/fromDocument.ts +136 -0
  6. package/src/bvh/toDocument.ts +202 -0
  7. package/src/cli.ts +1039 -0
  8. package/src/doctor/doctor.ts +241 -0
  9. package/src/doctor/lightmapUv.ts +452 -0
  10. package/src/doctor/lod.ts +177 -0
  11. package/src/doctor/normalizeScale.ts +220 -0
  12. package/src/doctor/reduceGlbBones.ts +218 -0
  13. package/src/doctor/shading.ts +95 -0
  14. package/src/doctor/transcodeKtx2.ts +124 -0
  15. package/src/doctor/transcodeTextures.ts +101 -0
  16. package/src/doctor/validateGlb.ts +264 -0
  17. package/src/fbx/convert.ts +327 -0
  18. package/src/fbx/read.ts +109 -0
  19. package/src/fbx/toDocument.ts +209 -0
  20. package/src/filmstrip.ts +263 -0
  21. package/src/index.ts +17 -0
  22. package/src/mergeSkins.ts +117 -0
  23. package/src/probe.ts +167 -0
  24. package/src/props.ts +137 -0
  25. package/src/retarget/automap.ts +276 -0
  26. package/src/retarget/ghost.ts +23 -0
  27. package/src/retarget/heading.ts +171 -0
  28. package/src/retarget/humanoid.ts +561 -0
  29. package/src/retarget/inPlace.ts +130 -0
  30. package/src/retarget/marks.ts +581 -0
  31. package/src/retarget/mirror.ts +0 -0
  32. package/src/retarget/retarget.ts +2272 -0
  33. package/src/retarget/rig.ts +542 -0
  34. package/src/retarget/unityAnim.ts +171 -0
  35. package/src/retarget/writeMarks.ts +132 -0
  36. package/src/sky/analyze.ts +136 -0
  37. package/src/sky/cmgen.ts +71 -0
  38. package/src/sky/index.ts +151 -0
  39. package/src/terrainPack.ts +169 -0
  40. package/src/textures/resolveImage.ts +202 -0
  41. package/src/unity/materials.ts +593 -0
  42. package/src/unity/png.ts +75 -0
  43. package/src/unity/prefabs.ts +80 -0
  44. package/src/unity/scene.ts +363 -0
  45. package/src/unity/unpack.ts +130 -0
  46. package/src/unreal/anim.ts +284 -0
  47. package/src/unreal/clipSource.ts +25 -0
  48. package/src/unreal/convert.ts +67 -0
  49. package/src/unreal/convertAsset.ts +81 -0
  50. package/src/unreal/index.ts +114 -0
  51. package/src/unreal/list.ts +113 -0
  52. package/src/unreal/materials.ts +291 -0
  53. package/src/unreal/mesh.ts +205 -0
  54. package/src/unreal/oodle.ts +153 -0
  55. package/src/unreal/package.ts +659 -0
  56. package/src/unreal/probe.ts +144 -0
  57. package/src/unreal/resolve.ts +51 -0
  58. package/src/unreal/rig.ts +149 -0
  59. package/src/unreal/sequencer.ts +193 -0
  60. package/src/unreal/skeleton.ts +74 -0
  61. package/src/unreal/texture.ts +202 -0
  62. package/src/unreal/toDocument.ts +360 -0
  63. package/src/unreal/versions.ts +100 -0
  64. package/src/wasm/fbxReader.js +14 -0
  65. package/src/wasm/lightmapUv.js +14 -0
@@ -0,0 +1,75 @@
1
+ // Minimal PNG decoder — 8-bit (and 16-bit, truncated) grey / grey+alpha / RGB / RGBA / palette,
2
+ // non-interlaced, all five scanline filters. Enough for every texture a game asset pack ships, so
3
+ // ORM packing does not need `sharp` (an optional native dependency). Interlaced or <8-bit images
4
+ // return null and the caller falls back to sharp / a warning.
5
+
6
+ import { inflateSync } from "node:zlib"
7
+ import type { Rgba } from "../textures/resolveImage"
8
+
9
+ const u32 = (b: Uint8Array, o: number): number => ((b[o] << 24) | (b[o + 1] << 16) | (b[o + 2] << 8) | b[o + 3]) >>> 0
10
+
11
+ export const decodePng = (b: Uint8Array): Rgba | null => {
12
+ if (b.length < 33 || b[0] !== 0x89 || b[1] !== 0x50 || b[2] !== 0x4e || b[3] !== 0x47) return null
13
+ let o = 8
14
+ let width = 0, height = 0, depth = 0, color = 0, interlace = 0
15
+ const idat: Uint8Array[] = []
16
+ let palette: Uint8Array | null = null
17
+ let trns: Uint8Array | null = null
18
+ while (o + 8 <= b.length) {
19
+ const len = u32(b, o)
20
+ const type = String.fromCharCode(b[o + 4], b[o + 5], b[o + 6], b[o + 7])
21
+ const data = b.subarray(o + 8, o + 8 + len)
22
+ if (type === "IHDR") { width = u32(data, 0); height = u32(data, 4); depth = data[8]; color = data[9]; interlace = data[12] }
23
+ else if (type === "PLTE") palette = data
24
+ else if (type === "tRNS") trns = data
25
+ else if (type === "IDAT") idat.push(data)
26
+ else if (type === "IEND") break
27
+ o += 12 + len
28
+ }
29
+ if (!width || !height || interlace !== 0 || (depth !== 8 && depth !== 16)) return null
30
+ const channels = color === 0 ? 1 : color === 2 ? 3 : color === 3 ? 1 : color === 4 ? 2 : color === 6 ? 4 : 0
31
+ if (!channels) return null
32
+ const bps = depth / 8
33
+ const bpp = channels * bps // bytes per pixel (filter unit)
34
+ const stride = width * bpp
35
+ let raw: Uint8Array
36
+ try {
37
+ const total = idat.reduce((n, c) => n + c.length, 0)
38
+ const joined = new Uint8Array(total)
39
+ let p = 0
40
+ for (const c of idat) { joined.set(c, p); p += c.length }
41
+ raw = new Uint8Array(inflateSync(joined))
42
+ } catch { return null }
43
+ if (raw.length < (stride + 1) * height) return null
44
+ const out = new Uint8Array(width * height * 4)
45
+ const prev = new Uint8Array(stride)
46
+ const cur = new Uint8Array(stride)
47
+ for (let y = 0; y < height; y++) {
48
+ const f = raw[y * (stride + 1)]
49
+ const line = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1))
50
+ for (let x = 0; x < stride; x++) {
51
+ const a = x >= bpp ? cur[x - bpp] : 0
52
+ const bb = prev[x]
53
+ const c = x >= bpp ? prev[x - bpp] : 0
54
+ let v = line[x]
55
+ if (f === 1) v += a
56
+ else if (f === 2) v += bb
57
+ else if (f === 3) v += (a + bb) >> 1
58
+ else if (f === 4) { const p = a + bb - c; const pa = Math.abs(p - a), pb = Math.abs(p - bb), pc = Math.abs(p - c); v += pa <= pb && pa <= pc ? a : pb <= pc ? bb : c }
59
+ cur[x] = v & 0xff
60
+ }
61
+ for (let x = 0; x < width; x++) {
62
+ const s = x * bpp, d = (y * width + x) * 4
63
+ if (color === 3) {
64
+ const i = cur[s]
65
+ out[d] = palette ? palette[i * 3] : i; out[d + 1] = palette ? palette[i * 3 + 1] : i; out[d + 2] = palette ? palette[i * 3 + 2] : i
66
+ out[d + 3] = trns && i < trns.length ? trns[i] : 255
67
+ } else if (channels === 1) { out[d] = out[d + 1] = out[d + 2] = cur[s]; out[d + 3] = 255 }
68
+ else if (channels === 2) { out[d] = out[d + 1] = out[d + 2] = cur[s]; out[d + 3] = cur[s + bps] }
69
+ else if (channels === 3) { out[d] = cur[s]; out[d + 1] = cur[s + bps]; out[d + 2] = cur[s + 2 * bps]; out[d + 3] = 255 }
70
+ else { out[d] = cur[s]; out[d + 1] = cur[s + bps]; out[d + 2] = cur[s + 2 * bps]; out[d + 3] = cur[s + 3 * bps] }
71
+ }
72
+ prev.set(cur)
73
+ }
74
+ return { width, height, data: out }
75
+ }
@@ -0,0 +1,80 @@
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 { existsSync, readFileSync, readdirSync } from "node:fs"
10
+ import { join } from "node:path"
11
+ import { parseUnityYaml } from "./scene"
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
+ type Y = string | Y[] | { [k: string]: Y }
25
+ const isMap = (v: Y | undefined): v is { [k: string]: Y } => typeof v === "object" && v !== null && !Array.isArray(v)
26
+ const guidOf = (v: Y | undefined): string | undefined => (isMap(v) && typeof v.guid === "string" ? v.guid : undefined)
27
+ const idOf = (v: Y | undefined): string => (isMap(v) ? String(v.fileID ?? "0") : "0")
28
+
29
+ /** Material overrides declared by one text-serialised prefab (empty for binary / non-prefab files). */
30
+ export const readPrefabOverrides = (file: string): PrefabOverride[] => {
31
+ const text = readFileSync(file, "utf8")
32
+ if (!text.startsWith("%YAML")) return []
33
+ const names = new Map<string, string>() // GameObject id → name
34
+ const meshes = new Map<string, string | undefined>() // GameObject id → mesh guid
35
+ const renderers = new Map<string, (string | undefined)[]>() // GameObject id → material guids
36
+ const re = /^--- !u!(\d+) &(-?\d+)( stripped)?\s*$/gm
37
+ const starts: { idx: number, end: number }[] = []
38
+ for (let m = re.exec(text); m; m = re.exec(text)) starts.push({ idx: m.index, end: m.index + m[0].length })
39
+ for (let i = 0; i < starts.length; i++) {
40
+ const chunk = text.slice(starts[i].end, i + 1 < starts.length ? starts[i + 1].idx : text.length)
41
+ const doc = parseUnityYaml(chunk)
42
+ const cls = Object.keys(doc)[0] ?? ""
43
+ const body = doc[cls]
44
+ if (!isMap(body)) continue
45
+ const id = /&(-?\d+)/.exec(text.slice(starts[i].idx, starts[i].end))?.[1] ?? ""
46
+ if (cls === "GameObject" && typeof body.m_Name === "string") names.set(id, body.m_Name)
47
+ else if (cls === "MeshFilter") meshes.set(idOf(body.m_GameObject), guidOf(body.m_Mesh))
48
+ else if (cls === "MeshRenderer" || cls === "SkinnedMeshRenderer") {
49
+ const mats = Array.isArray(body.m_Materials) ? body.m_Materials.map((m) => guidOf(m)) : []
50
+ renderers.set(idOf(body.m_GameObject), mats)
51
+ if (cls === "SkinnedMeshRenderer") meshes.set(idOf(body.m_GameObject), guidOf(body.m_Mesh))
52
+ }
53
+ }
54
+ const out: PrefabOverride[] = []
55
+ for (const [ go, materials ] of renderers) {
56
+ const node = names.get(go)
57
+ if (!node || materials.every((m) => !m)) continue
58
+ out.push({ prefab: file, node, meshGuid: meshes.get(go), materials })
59
+ }
60
+ return out
61
+ }
62
+
63
+ /** Every `.prefab` under `dirs` (recursive to `depth`), read for overrides. */
64
+ export const collectPrefabOverrides = (dirs: string[], depth = 3): PrefabOverride[] => {
65
+ const files: string[] = []
66
+ const walk = (dir: string, d: number): void => {
67
+ if (d < 0 || !existsSync(dir)) return
68
+ let entries
69
+ try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
70
+ for (const e of entries) {
71
+ const p = join(dir, e.name)
72
+ if (e.isDirectory()) { if (!e.name.startsWith(".")) walk(p, d - 1) }
73
+ else if (/\.prefab$/i.test(e.name)) files.push(p)
74
+ }
75
+ }
76
+ for (const d of dirs) walk(d, depth)
77
+ const out: PrefabOverride[] = []
78
+ for (const f of [ ...new Set(files) ]) out.push(...readPrefabOverrides(f))
79
+ return out
80
+ }
@@ -0,0 +1,363 @@
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.
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`).
12
+ //
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.
17
+
18
+ import { existsSync, readFileSync } from "node:fs"
19
+ import { basename, dirname, resolve } from "node:path"
20
+ import { findUnityIndex } from "./materials"
21
+
22
+ // ---- a YAML subset parser (what Unity writes: block maps/sequences, flow maps, scalars) ----------
23
+
24
+ export type Y = string | Y[] | { [k: string]: Y }
25
+ type Line = { indent: number, text: string }
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]) ]
111
+ }
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
144
+ }
145
+
146
+ export type SceneInstance = {
147
+ 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 */
151
+ group: string
152
+ position: Vec3
153
+ rotation: Quat
154
+ 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
+ }
158
+ export type ScenePrefab = { path: string | null, name: string, fbx: string | null, node: string | null, count: number }
159
+ export type SceneImport = {
160
+ source: string
161
+ frame: string
162
+ prefabs: Record<string, ScenePrefab>
163
+ instances: SceneInstance[]
164
+ stats: { prefabInstances: number, meshObjects: number, inactive: number, approximate: number, unresolvedPrefabs: number }
165
+ }
166
+
167
+ export type ImportSceneOptions = {
168
+ /** `unity-index.txt` for GUID → path (default: found walking up from the scene file) */
169
+ indexFile?: string | null
170
+ handedness?: Handedness
171
+ /** keep only instances whose prefab path / name / group matches */
172
+ filter?: RegExp
173
+ /** Unity-space X/Z rectangle [x0, z0, x1, z1] — instances outside are dropped */
174
+ region?: [number, number, number, number]
175
+ /** only instances under a group whose path contains this */
176
+ group?: string
177
+ includeInactive?: boolean
178
+ }
179
+
180
+ export const importUnityScene = (scenePath: string, opts: ImportSceneOptions = {}): SceneImport => {
181
+ const text = readFileSync(scenePath, "utf8")
182
+ if (!text.startsWith("%YAML")) throw new Error(`${basename(scenePath)} is not text-serialised (Unity: Edit ▸ Project Settings ▸ Editor ▸ Asset Serialization = Force Text)`)
183
+ 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
+ }
189
+
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
+ }
203
+
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
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 })
247
+ }
248
+ }
249
+
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
269
+ }
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
281
+ }
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")
285
+ }
286
+
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)
298
+ }
299
+
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 }
324
+ }
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)
341
+ }
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]
346
+
347
+ return {
348
+ 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,
351
+ }
352
+ }
353
+
354
+ const r6 = (v: number): number => Math.round(v * 1e6) / 1e6
355
+
356
+ /** The prefab table as printable lines, most used first. */
357
+ 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)"}`)
361
+ if (rows.length > max) lines.push(` … ${rows.length - max} more`)
362
+ return lines
363
+ }
@@ -0,0 +1,130 @@
1
+ // `.unitypackage` reader. The file is a gzip'd tar of one folder per asset GUID holding `asset`
2
+ // (the bytes), `asset.meta` and `pathname` (its path under Assets/). Two streaming passes keep
3
+ // memory flat on multi-GB packs: pass 1 indexes GUID → path (written as `unity-index.txt` at the
4
+ // output root — `convert` resolves Unity material textures through it), pass 2 writes the files
5
+ // that match the requested patterns (everything by default), `.meta` sidecars included so the
6
+ // result is also a valid Unity project fragment.
7
+
8
+ import { createReadStream, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"
9
+ import { dirname, join } from "node:path"
10
+ import { createGunzip } from "node:zlib"
11
+
12
+ type Entry = { name: string, size: number }
13
+
14
+ const tarStream = (file: string, onEntry: (e: Entry, data: Uint8Array) => void): Promise<void> => new Promise((resolveP, rejectP) => {
15
+ const gunzip = createGunzip()
16
+ createReadStream(file, { highWaterMark: 1 << 22 }).on("error", rejectP).pipe(gunzip)
17
+ let buf: Buffer = Buffer.alloc(0)
18
+ let state: "header" | "body" | "pad" = "header"
19
+ let entry: Entry | null = null
20
+ let remaining = 0, pad = 0
21
+ const chunks: Buffer[] = []
22
+ gunzip.on("data", (chunk: Buffer) => {
23
+ buf = buf.length ? Buffer.concat([ buf, chunk ]) : chunk
24
+ let off = 0
25
+ for (;;) {
26
+ if (state === "header") {
27
+ if (buf.length - off < 512) break
28
+ const h = buf.subarray(off, off + 512)
29
+ off += 512
30
+ if (h[0] === 0) continue
31
+ const name = h.subarray(0, 100).toString("utf8").replace(/\0.*$/s, "")
32
+ const size = parseInt(h.subarray(124, 136).toString("utf8").replace(/[\0 ]/g, ""), 8) || 0
33
+ const prefix = h.subarray(345, 500).toString("utf8").replace(/\0.*$/s, "")
34
+ entry = { name: prefix ? prefix + "/" + name : name, size }
35
+ remaining = size
36
+ pad = (512 - (size % 512)) % 512
37
+ chunks.length = 0
38
+ state = size > 0 ? "body" : "pad"
39
+ } else if (state === "body") {
40
+ const avail = buf.length - off
41
+ if (avail === 0) break
42
+ const take = Math.min(remaining, avail)
43
+ chunks.push(buf.subarray(off, off + take))
44
+ off += take
45
+ remaining -= take
46
+ if (remaining === 0) state = "pad"
47
+ } else {
48
+ if (buf.length - off < pad) break
49
+ off += pad
50
+ pad = 0
51
+ if (entry && entry.size > 0) onEntry(entry, Buffer.concat(chunks))
52
+ chunks.length = 0
53
+ entry = null
54
+ state = "header"
55
+ }
56
+ }
57
+ buf = buf.subarray(off)
58
+ })
59
+ gunzip.on("end", () => resolveP())
60
+ gunzip.on("error", rejectP)
61
+ })
62
+
63
+ export const INDEX_NAME = "unity-index.txt"
64
+
65
+ /** Pass 1: GUID → path for every asset in the package. */
66
+ export const indexUnityPackage = async (pkg: string, progress?: (entries: number, assets: number) => void): Promise<Map<string, string>> => {
67
+ const map = new Map<string, string>()
68
+ let n = 0
69
+ await tarStream(pkg, (e, data) => {
70
+ n++
71
+ if (e.name.endsWith("/pathname")) {
72
+ const guid = e.name.split("/")[0]
73
+ const p = Buffer.from(data).toString("utf8").split("\n")[0].trim()
74
+ if (/^[0-9a-f]{32}$/.test(guid) && p) map.set(guid, p)
75
+ }
76
+ if (n % 20000 === 0) progress?.(n, map.size)
77
+ })
78
+ return map
79
+ }
80
+
81
+ export const writeUnityIndex = (outDir: string, index: Map<string, string>): string => {
82
+ mkdirSync(outDir, { recursive: true })
83
+ const f = join(outDir, INDEX_NAME)
84
+ writeFileSync(f, [ ...index ].map(([ g, p ]) => `${g}\t${p}`).join("\n") + "\n")
85
+ return f
86
+ }
87
+
88
+ export const readUnityIndex = (file: string): Map<string, string> => {
89
+ const map = new Map<string, string>()
90
+ for (const line of readFileSync(file, "utf8").split(/\r?\n/)) {
91
+ const t = line.indexOf("\t")
92
+ if (t > 0) map.set(line.slice(0, t), line.slice(t + 1).trim())
93
+ }
94
+ return map
95
+ }
96
+
97
+ export type ExtractOptions = {
98
+ /** Regexes (case-insensitive) a path must match; none = everything. */
99
+ only?: RegExp[]
100
+ /** Also write the `.meta` sidecars (default true). */
101
+ meta?: boolean
102
+ /** Skip files that already exist with the same size (default true — re-runs are cheap). */
103
+ skipExisting?: boolean
104
+ progress?: (files: number, bytes: number) => void
105
+ }
106
+
107
+ /** Pass 2: write the matching assets under `outDir/<pathname>`. */
108
+ export const extractUnityPackage = async (pkg: string, index: Map<string, string>, outDir: string, opts: ExtractOptions = {}): Promise<{ files: number, bytes: number, matched: number }> => {
109
+ const want = new Map<string, string>()
110
+ for (const [ g, p ] of index) if (!opts.only?.length || opts.only.some((r) => r.test(p))) want.set(g, p)
111
+ let files = 0, bytes = 0
112
+ await tarStream(pkg, (e, data) => {
113
+ const parts = e.name.split("/")
114
+ if (parts.length < 2) return
115
+ const leaf = parts[parts.length - 1]
116
+ const isAsset = leaf === "asset", isMeta = leaf === "asset.meta"
117
+ if (!isAsset && !(isMeta && (opts.meta ?? true))) return
118
+ const p = want.get(parts[0])
119
+ if (!p) return
120
+ const dest = join(outDir, p) + (isMeta ? ".meta" : "")
121
+ if ((opts.skipExisting ?? true) && existsSync(dest)) {
122
+ try { if (statSync(dest).size === data.length) return } catch { /* fall through */ }
123
+ }
124
+ mkdirSync(dirname(dest), { recursive: true })
125
+ writeFileSync(dest, data)
126
+ if (isAsset) { files++; bytes += data.length }
127
+ if (files % 50 === 0) opts.progress?.(files, bytes)
128
+ })
129
+ return { files, bytes, matched: want.size }
130
+ }