lecodes-assets 0.2.0 → 2.4.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/README.md +738 -449
  2. package/dist/cli.js +19760 -8612
  3. package/dist/index.js +19334 -8167
  4. package/package.json +15 -3
  5. package/src/bvh/fromDocument.ts +136 -0
  6. package/src/bvh/toDocument.ts +202 -0
  7. package/src/cli-kit/command.ts +100 -0
  8. package/src/cli-kit/help.ts +72 -0
  9. package/src/cli-kit/index.ts +4 -0
  10. package/src/cli-kit/parse.ts +72 -0
  11. package/src/cli-kit/run.ts +52 -0
  12. package/src/cli.ts +24 -0
  13. package/src/commands/avatar.ts +77 -0
  14. package/src/commands/bvh.ts +26 -0
  15. package/src/commands/convert.ts +156 -0
  16. package/src/commands/doctor.ts +67 -0
  17. package/src/commands/geometry.ts +45 -0
  18. package/src/commands/index.ts +24 -0
  19. package/src/commands/marks.ts +38 -0
  20. package/src/commands/mirror.ts +35 -0
  21. package/src/commands/probe.ts +43 -0
  22. package/src/commands/retarget/donor.ts +63 -0
  23. package/src/commands/retarget/index.ts +169 -0
  24. package/src/commands/retarget/map.ts +81 -0
  25. package/src/commands/retarget/report.ts +77 -0
  26. package/src/commands/scene.ts +103 -0
  27. package/src/commands/shared.ts +121 -0
  28. package/src/commands/sky.ts +35 -0
  29. package/src/commands/terrainPack.ts +40 -0
  30. package/src/commands/unpack.ts +43 -0
  31. package/src/commands/unreal.ts +109 -0
  32. package/src/commands/unwrap.ts +56 -0
  33. package/src/doctor/cardTrim.ts +286 -0
  34. package/src/doctor/doctor.ts +305 -0
  35. package/src/doctor/lod.ts +177 -0
  36. package/src/doctor/merge/accessors.ts +30 -0
  37. package/src/doctor/merge/atlasLayout.ts +94 -0
  38. package/src/doctor/merge/atlasPixels.ts +114 -0
  39. package/src/doctor/merge/bindSpace.ts +59 -0
  40. package/src/doctor/merge/classify.ts +136 -0
  41. package/src/doctor/merge/geometry.ts +167 -0
  42. package/src/doctor/merge/index.ts +98 -0
  43. package/src/doctor/merge/matrix.ts +60 -0
  44. package/src/doctor/merge/plan.ts +88 -0
  45. package/src/doctor/merge/textures.ts +60 -0
  46. package/src/doctor/merge/types.ts +77 -0
  47. package/src/doctor/normalizeScale.ts +220 -0
  48. package/src/doctor/reduceGlbBones.ts +218 -0
  49. package/src/doctor/shading.ts +95 -0
  50. package/src/doctor/transcodeKtx2.ts +275 -0
  51. package/src/doctor/transcodeTextures.ts +101 -0
  52. package/src/doctor/validateGlb.ts +264 -0
  53. package/src/doctor/vertexAo.ts +321 -0
  54. package/src/fbx/convert.ts +327 -0
  55. package/src/fbx/read.ts +111 -0
  56. package/src/fbx/toDocument.ts +227 -0
  57. package/src/filmstrip.ts +263 -0
  58. package/src/geometry.ts +222 -0
  59. package/src/index.ts +19 -0
  60. package/src/mergeSkins.ts +117 -0
  61. package/src/probe.ts +167 -0
  62. package/src/props.ts +137 -0
  63. package/src/retarget/automap.ts +276 -0
  64. package/src/retarget/ghost.ts +23 -0
  65. package/src/retarget/heading.ts +238 -0
  66. package/src/retarget/humanoid.ts +572 -0
  67. package/src/retarget/inPlace.ts +130 -0
  68. package/src/retarget/marks.ts +581 -0
  69. package/src/retarget/mirror.ts +0 -0
  70. package/src/retarget/retarget.ts +2375 -0
  71. package/src/retarget/rig.ts +545 -0
  72. package/src/retarget/unityAnim.ts +171 -0
  73. package/src/retarget/writeMarks.ts +134 -0
  74. package/src/sky/analyze.ts +136 -0
  75. package/src/sky/cmgen.ts +71 -0
  76. package/src/sky/index.ts +200 -0
  77. package/src/terrainPack.ts +208 -0
  78. package/src/textures/resolveImage.ts +202 -0
  79. package/src/unity/colliderFit.ts +287 -0
  80. package/src/unity/fbxModel.ts +194 -0
  81. package/src/unity/foliage.ts +63 -0
  82. package/src/unity/gameObjects.ts +81 -0
  83. package/src/unity/guid.ts +104 -0
  84. package/src/unity/image.ts +58 -0
  85. package/src/unity/kindGlb.ts +364 -0
  86. package/src/unity/lecodesLevel.ts +381 -0
  87. package/src/unity/levelTerrain.ts +116 -0
  88. package/src/unity/matFile.ts +179 -0
  89. package/src/unity/materialSlots.ts +53 -0
  90. package/src/unity/materialWriter.ts +306 -0
  91. package/src/unity/materials.ts +198 -0
  92. package/src/unity/math.ts +82 -0
  93. package/src/unity/png.ts +75 -0
  94. package/src/unity/prefabTree.ts +285 -0
  95. package/src/unity/prefabs.ts +54 -0
  96. package/src/unity/propertyPath.ts +43 -0
  97. package/src/unity/scene.ts +384 -0
  98. package/src/unity/serialized.ts +242 -0
  99. package/src/unity/terrain.ts +221 -0
  100. package/src/unity/triBudget.ts +77 -0
  101. package/src/unity/triplanar.ts +122 -0
  102. package/src/unity/unityMesh.ts +151 -0
  103. package/src/unity/unpack.ts +149 -0
  104. package/src/unity/yaml.ts +131 -0
  105. package/src/unreal/anim.ts +284 -0
  106. package/src/unreal/clipSource.ts +25 -0
  107. package/src/unreal/convert.ts +67 -0
  108. package/src/unreal/convertAsset.ts +81 -0
  109. package/src/unreal/index.ts +115 -0
  110. package/src/unreal/list.ts +113 -0
  111. package/src/unreal/materialGraph.ts +224 -0
  112. package/src/unreal/materials.ts +399 -0
  113. package/src/unreal/mesh.ts +205 -0
  114. package/src/unreal/oodle.ts +153 -0
  115. package/src/unreal/package.ts +677 -0
  116. package/src/unreal/probe.ts +144 -0
  117. package/src/unreal/resolve.ts +53 -0
  118. package/src/unreal/rig.ts +149 -0
  119. package/src/unreal/sequencer.ts +193 -0
  120. package/src/unreal/skeleton.ts +74 -0
  121. package/src/unreal/sound.ts +128 -0
  122. package/src/unreal/texture.ts +269 -0
  123. package/src/unreal/toDocument.ts +367 -0
  124. package/src/unreal/versions.ts +108 -0
  125. package/src/unwrap/index.ts +167 -0
  126. package/src/unwrap/lightmapUv.ts +761 -0
  127. package/src/wasm/fbxReader.js +14 -0
  128. package/src/wasm/lightmapUv.js +14 -0
@@ -0,0 +1,242 @@
1
+ // Unity's BINARY serialised files (`SerializedFile`: a `.asset` / `.prefab` / `.unity` written with
2
+ // "Mixed" or "Force Binary" serialisation, TerrainData always) → plain objects, driven by the TYPE TREE
3
+ // the editor stores next to the data. No per-class knowledge: every field comes back under its
4
+ // serialised name (`m_Heightmap.m_Heights`, `m_SplatDatabase.m_AlphaTextures` …), vectors of
5
+ // primitives as typed arrays (a 1025² heightmap is one Int16Array, not a million numbers), strings
6
+ // decoded, `TypelessData` (texture pixels) as bytes.
7
+ //
8
+ // Files without a type tree (player builds) are refused — the editor always writes one. Format
9
+ // versions 9 … 22 (Unity 5 … 2022+); layout per UnityPy's SerializedFile reader.
10
+
11
+ import { readFileSync } from "node:fs"
12
+
13
+ export type UValue = number | bigint | boolean | string | UValue[] | Uint8Array | Int8Array | Int16Array | Uint16Array | Int32Array | Uint32Array | Float32Array | Float64Array | { [k: string]: UValue }
14
+ export type UObject = { [k: string]: UValue }
15
+
16
+ type TypeNode = { type: string, name: string, byteSize: number, level: number, isArray: boolean, align: boolean, children: TypeNode[] }
17
+ type SType = { classID: number, root: TypeNode | null }
18
+ export type SObjectInfo = { pathID: bigint, byteStart: number, byteSize: number, classID: number, type: SType }
19
+ export type SExternal = { guid: string, type: number, path: string }
20
+
21
+ // Unity's built-in string table: a type-tree string offset with the high bit set indexes into this.
22
+ const COMMON = "AABB\0AnimationClip\0AnimationCurve\0AnimationState\0Array\0Base\0BitField\0bitset\0bool\0char\0ColorRGBA\0Component\0data\0deque\0double\0dynamic_array\0FastPropertyName\0first\0float\0Font\0GameObject\0Generic Mono\0GradientNEW\0GUID\0GUIStyle\0int\0list\0long long\0map\0Matrix4x4f\0MdFour\0MonoBehaviour\0MonoScript\0m_ByteSize\0m_Curve\0m_EditorClassIdentifier\0m_EditorHideFlags\0m_Enabled\0m_ExtensionPtr\0m_GameObject\0m_Index\0m_IsArray\0m_IsStatic\0m_MetaFlag\0m_Name\0m_ObjectHideFlags\0m_PrefabInternal\0m_PrefabParentObject\0m_Script\0m_StaticEditorFlags\0m_Type\0m_Version\0Object\0pair\0PPtr<Component>\0PPtr<GameObject>\0PPtr<Material>\0PPtr<MonoBehaviour>\0PPtr<MonoScript>\0PPtr<Object>\0PPtr<Prefab>\0PPtr<Sprite>\0PPtr<TextAsset>\0PPtr<Texture>\0PPtr<Texture2D>\0PPtr<Transform>\0Prefab\0Quaternionf\0Rectf\0RectInt\0RectOffset\0second\0set\0short\0size\0SInt16\0SInt32\0SInt64\0SInt8\0staticvector\0string\0TextAsset\0TextMesh\0Texture\0Texture2D\0Transform\0TypelessData\0UInt16\0UInt32\0UInt64\0UInt8\0unsigned int\0unsigned long long\0unsigned short\0vector\0Vector2f\0Vector3f\0Vector4f\0m_ScriptingClassIdentifier\0Gradient\0Type*\0int2_storage\0int3_storage\0BoundsInt\0m_CorrespondingSourceObject\0m_PrefabInstance\0m_PrefabAsset\0FileSize\0Hash128\0"
23
+ const cstrAt = (s: string, off: number): string => { const e = s.indexOf("\0", off); return s.slice(off, e < 0 ? undefined : e) }
24
+
25
+ class Reader {
26
+ pos = 0
27
+ readonly dv: DataView
28
+ constructor(readonly bytes: Uint8Array, public little = false) { this.dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) }
29
+ u8(): number { return this.dv.getUint8(this.pos++) }
30
+ i16(): number { const v = this.dv.getInt16(this.pos, this.little); this.pos += 2; return v }
31
+ u16(): number { const v = this.dv.getUint16(this.pos, this.little); this.pos += 2; return v }
32
+ i32(): number { const v = this.dv.getInt32(this.pos, this.little); this.pos += 4; return v }
33
+ u32(): number { const v = this.dv.getUint32(this.pos, this.little); this.pos += 4; return v }
34
+ i64(): bigint { const v = this.dv.getBigInt64(this.pos, this.little); this.pos += 8; return v }
35
+ u64(): bigint { const v = this.dv.getBigUint64(this.pos, this.little); this.pos += 8; return v }
36
+ f32(): number { const v = this.dv.getFloat32(this.pos, this.little); this.pos += 4; return v }
37
+ f64(): number { const v = this.dv.getFloat64(this.pos, this.little); this.pos += 8; return v }
38
+ align(n = 4): void { this.pos = (this.pos + n - 1) & ~(n - 1) }
39
+ cstr(): string { let e = this.pos; while (e < this.bytes.length && this.bytes[e] !== 0) e++; const s = new TextDecoder().decode(this.bytes.subarray(this.pos, e)); this.pos = e + 1; return s }
40
+ take(n: number): Uint8Array { const b = this.bytes.subarray(this.pos, this.pos + n); this.pos += n; return b }
41
+ }
42
+
43
+ // ---- the file -------------------------------------------------------------------------------------
44
+
45
+ export type SerializedFile = {
46
+ version: number
47
+ unityVersion: string
48
+ objects: SObjectInfo[]
49
+ externals: SExternal[]
50
+ /** Deserialize one object through its type tree. */
51
+ read: (o: SObjectInfo) => UObject
52
+ byPathID: (id: bigint | number | string) => SObjectInfo | undefined
53
+ }
54
+
55
+ export const isSerializedFile = (bytes: Uint8Array): boolean => {
56
+ if (bytes.length < 20 || (bytes[0] === 0x25 && bytes[1] === 0x59)) return false // "%YAML"
57
+ const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
58
+ const version = dv.getUint32(8)
59
+ return version >= 9 && version <= 30
60
+ }
61
+
62
+ export const openSerializedFile = (pathOrBytes: string | Uint8Array): SerializedFile => {
63
+ const bytes = typeof pathOrBytes === "string" ? new Uint8Array(readFileSync(pathOrBytes)) : pathOrBytes
64
+ const r = new Reader(bytes, false)
65
+ let metadataSize = r.u32(); let fileSize: number = r.u32()
66
+ const version = r.u32()
67
+ let dataOffset: number = r.u32()
68
+ if (version < 9) throw new Error(`SerializedFile version ${version} is too old (Unity < 5)`)
69
+ const endian = r.u8(); r.take(3)
70
+ if (version >= 22) { metadataSize = r.u32(); fileSize = Number(r.i64()); dataOffset = Number(r.i64()); r.i64() }
71
+ void metadataSize; void fileSize
72
+ r.little = endian === 0
73
+
74
+ const unityVersion = r.cstr()
75
+ r.i32() // target platform
76
+ const typeTree = r.u8() !== 0
77
+ if (!typeTree) throw new Error("SerializedFile has no type tree (a player build?) — only editor-written assets are readable")
78
+
79
+ const readType = (isRef: boolean): SType => {
80
+ const classID = r.i32()
81
+ if (version >= 16) r.u8() // stripped
82
+ const scriptIndex = version >= 17 ? r.i16() : -1
83
+ if (version >= 13) {
84
+ if ((isRef && scriptIndex >= 0) || (version < 16 && classID < 0) || (version >= 16 && classID === 114)) r.take(16)
85
+ r.take(16)
86
+ }
87
+ let root: TypeNode | null = null
88
+ if (version >= 12 || version === 10) root = readTreeBlob()
89
+ else throw new Error(`SerializedFile version ${version}: legacy type tree not supported`)
90
+ if (version >= 21) {
91
+ if (isRef) { r.cstr(); r.cstr(); r.cstr() } else { const n = r.i32(); r.take(n * 4) }
92
+ }
93
+ return { classID, root }
94
+ }
95
+ const readTreeBlob = (): TypeNode => {
96
+ const count = r.i32(), strLen = r.i32()
97
+ const raw: { ver: number, level: number, flags: number, typeOff: number, nameOff: number, byteSize: number, meta: number }[] = []
98
+ for (let i = 0; i < count; i++) {
99
+ const ver = r.u16(), level = r.u8(), flags = r.u8(), typeOff = r.u32(), nameOff = r.u32(), byteSize = r.i32()
100
+ r.i32() // index
101
+ const meta = r.i32()
102
+ if (version >= 19) r.u64() // ref type hash
103
+ raw.push({ ver, level, flags, typeOff, nameOff, byteSize, meta })
104
+ }
105
+ const strs = new TextDecoder().decode(r.take(strLen))
106
+ const str = (off: number): string => (off & 0x80000000 ? cstrAt(COMMON, off & 0x7fffffff) : cstrAt(strs, off))
107
+ const nodes: TypeNode[] = raw.map((n) => ({ type: str(n.typeOff), name: str(n.nameOff), byteSize: n.byteSize, level: n.level, isArray: (n.flags & 1) !== 0, align: (n.meta & 0x4000) !== 0, children: [] }))
108
+ const stack: TypeNode[] = []
109
+ for (const n of nodes) {
110
+ while (stack.length && stack[stack.length - 1].level >= n.level) stack.pop()
111
+ if (stack.length) stack[stack.length - 1].children.push(n)
112
+ stack.push(n)
113
+ }
114
+ return nodes[0]
115
+ }
116
+
117
+ const types: SType[] = []
118
+ const typeCount = r.i32()
119
+ for (let i = 0; i < typeCount; i++) types.push(readType(false))
120
+
121
+ if (version >= 7 && version < 14) r.i32() // big ID enabled
122
+ const objects: SObjectInfo[] = []
123
+ const objCount = r.i32()
124
+ for (let i = 0; i < objCount; i++) {
125
+ if (version >= 14) r.align(4)
126
+ const pathID = version >= 14 ? r.i64() : BigInt(r.i32())
127
+ const byteStart = version >= 22 ? Number(r.i64()) : r.u32()
128
+ const byteSize = r.u32()
129
+ const typeID = r.i32()
130
+ let classID: number
131
+ let type: SType
132
+ if (version < 16) { classID = r.u16(); type = types.find((t) => t.classID === typeID) ?? { classID, root: null } }
133
+ else { type = types[typeID]; classID = type.classID }
134
+ if (version < 11) r.u16()
135
+ if (version >= 11 && version < 17) r.i16()
136
+ if (version === 15 || version === 16) r.u8()
137
+ objects.push({ pathID, byteStart: dataOffset + byteStart, byteSize, classID, type })
138
+ }
139
+ if (version >= 11) {
140
+ const n = r.i32()
141
+ for (let i = 0; i < n; i++) { r.i32(); if (version >= 14) { r.align(4); r.i64() } else r.i32() }
142
+ }
143
+ const externals: SExternal[] = []
144
+ const extCount = r.i32()
145
+ for (let i = 0; i < extCount; i++) {
146
+ if (version >= 6) r.cstr()
147
+ let guid = ""
148
+ let type = 0
149
+ if (version >= 5) {
150
+ // GUID bytes → Unity's text form: each byte's nibbles swapped (low nibble first)
151
+ const g = r.take(16)
152
+ for (let k = 0; k < 16; k++) guid += (g[k] & 15).toString(16) + (g[k] >> 4).toString(16)
153
+ type = r.i32()
154
+ }
155
+ externals.push({ guid, type, path: r.cstr() })
156
+ }
157
+
158
+ const byId = new Map<string, SObjectInfo>()
159
+ for (const o of objects) byId.set(o.pathID.toString(), o)
160
+
161
+ const read = (o: SObjectInfo): UObject => {
162
+ if (!o.type.root) throw new Error(`object ${o.pathID}: no type tree for class ${o.classID}`)
163
+ const dr = new Reader(bytes, r.little)
164
+ dr.pos = o.byteStart
165
+ const v = readNode(dr, o.type.root)
166
+ return v as UObject
167
+ }
168
+ return { version, unityVersion, objects, externals, read, byPathID: (id) => byId.get(String(id)) }
169
+ }
170
+
171
+ // ---- values ---------------------------------------------------------------------------------------
172
+
173
+ const PRIM: Record<string, (r: Reader) => UValue> = {
174
+ bool: (r) => r.u8() !== 0, SInt8: (r) => (r.u8() << 24) >> 24, UInt8: (r) => r.u8(), char: (r) => r.u8(),
175
+ short: (r) => r.i16(), SInt16: (r) => r.i16(), UInt16: (r) => r.u16(), "unsigned short": (r) => r.u16(),
176
+ int: (r) => r.i32(), SInt32: (r) => r.i32(), UInt32: (r) => r.u32(), "unsigned int": (r) => r.u32(), "Type*": (r) => r.u32(),
177
+ "long long": (r) => r.i64(), SInt64: (r) => r.i64(), UInt64: (r) => r.u64(), "unsigned long long": (r) => r.u64(), FileSize: (r) => r.u64(),
178
+ float: (r) => r.f32(), double: (r) => r.f64(),
179
+ }
180
+ // Typed-array fast path for vectors of primitives (heightmaps, index buffers …). Copies, so the
181
+ // result stays valid and aligned whatever the source offset.
182
+ const TYPED: Record<string, (b: Uint8Array, n: number, little: boolean) => UValue> = {
183
+ UInt8: (b) => b.slice(), char: (b) => b.slice(), bool: (b) => b.slice(), SInt8: (b) => new Int8Array(b.slice().buffer),
184
+ SInt16: (b, n, l) => fill(new Int16Array(n), b, 2, (dv, o) => dv.getInt16(o, l)), short: (b, n, l) => fill(new Int16Array(n), b, 2, (dv, o) => dv.getInt16(o, l)),
185
+ UInt16: (b, n, l) => fill(new Uint16Array(n), b, 2, (dv, o) => dv.getUint16(o, l)), "unsigned short": (b, n, l) => fill(new Uint16Array(n), b, 2, (dv, o) => dv.getUint16(o, l)),
186
+ SInt32: (b, n, l) => fill(new Int32Array(n), b, 4, (dv, o) => dv.getInt32(o, l)), int: (b, n, l) => fill(new Int32Array(n), b, 4, (dv, o) => dv.getInt32(o, l)),
187
+ UInt32: (b, n, l) => fill(new Uint32Array(n), b, 4, (dv, o) => dv.getUint32(o, l)), "unsigned int": (b, n, l) => fill(new Uint32Array(n), b, 4, (dv, o) => dv.getUint32(o, l)),
188
+ float: (b, n, l) => fill(new Float32Array(n), b, 4, (dv, o) => dv.getFloat32(o, l)),
189
+ double: (b, n, l) => fill(new Float64Array(n), b, 8, (dv, o) => dv.getFloat64(o, l)),
190
+ }
191
+ const fill = <T extends { length: number, [i: number]: number }>(out: T, b: Uint8Array, size: number, get: (dv: DataView, o: number) => number): T => {
192
+ const dv = new DataView(b.buffer, b.byteOffset, b.byteLength)
193
+ for (let i = 0; i < out.length; i++) out[i] = get(dv, i * size)
194
+ return out
195
+ }
196
+
197
+ const readArray = (r: Reader, node: TypeNode): UValue => {
198
+ // node = the "Array" node: children [size, data]
199
+ const n = r.i32()
200
+ const elem = node.children[1]
201
+ let out: UValue
202
+ if (elem.children.length === 0 && TYPED[elem.type] && elem.byteSize > 0) out = TYPED[elem.type](r.take(n * elem.byteSize), n, r.little)
203
+ else { const a: UValue[] = []; for (let i = 0; i < n; i++) a.push(readNode(r, elem)); out = a }
204
+ if (node.align) r.align(4)
205
+ return out
206
+ }
207
+
208
+ const readNode = (r: Reader, node: TypeNode): UValue => {
209
+ let v: UValue
210
+ if (node.isArray) return readArray(r, node)
211
+ if (node.type === "string") {
212
+ const n = r.i32()
213
+ v = new TextDecoder().decode(r.take(n))
214
+ if (node.align || node.children[0]?.align) r.align(4)
215
+ return v
216
+ }
217
+ if (node.type === "TypelessData") {
218
+ const n = r.i32()
219
+ v = r.take(n).slice()
220
+ if (node.align) r.align(4)
221
+ return v
222
+ }
223
+ if (node.children.length === 0) {
224
+ const p = PRIM[node.type]
225
+ if (!p) throw new Error(`unknown primitive '${node.type}' (${node.name})`)
226
+ v = p(r)
227
+ } else if (node.children.length === 1 && node.children[0].isArray) {
228
+ v = readArray(r, node.children[0])
229
+ } else {
230
+ const o: UObject = {}
231
+ for (const c of node.children) o[c.name] = readNode(r, c)
232
+ v = o
233
+ }
234
+ if (node.align) r.align(4)
235
+ return v
236
+ }
237
+
238
+ /** A PPtr field → { fileID, pathID } (fileID 0 = this file, n = externals[n − 1]). */
239
+ export const pptr = (v: UValue | undefined): { fileID: number, pathID: bigint } => {
240
+ const o = (v && typeof v === "object" && !ArrayBuffer.isView(v) && !Array.isArray(v) ? v : {}) as UObject
241
+ return { fileID: Number(o.m_FileID ?? 0), pathID: BigInt((o.m_PathID as number | bigint | undefined) ?? 0) }
242
+ }
@@ -0,0 +1,221 @@
1
+ // Unity TerrainData (`.asset`, binary-serialised — the editor always writes it binary) → what a
2
+ // LeCodes terrain and the level around it need: the heightmap in metres, the splat weights per sample,
3
+ // the terrain layers (textures + tiling from the `.terrainlayer` files), and the TREES and DETAIL
4
+ // objects painted on it as ordinary placements of their prefabs.
5
+ //
6
+ // Frames: Unity's terrain grows +X across columns and +Z across rows from its transform. Everything
7
+ // here stays in UNITY's frame (the heights row-major, row = z); the caller mirrors — see
8
+ // `toLeCodesGrid`.
9
+
10
+ import { readFileSync } from "node:fs"
11
+ import { isSerializedFile, openSerializedFile, pptr, type SerializedFile, type UObject, type UValue } from "./serialized"
12
+ import { num, readUnityObjects, ref, vec3, isMap, type Vec3 } from "./yaml"
13
+ import type { GuidResolver } from "./guid"
14
+
15
+ export type UnityTerrainLayer = {
16
+ name: string
17
+ /** Absolute paths (null = not found). */
18
+ diffuse: string | null
19
+ normal: string | null
20
+ mask: string | null
21
+ /** Metres per texture repeat (Unity m_TileSize). */
22
+ tileSize: [number, number]
23
+ normalScale: number
24
+ metallic: number
25
+ smoothness: number
26
+ /** Unity `m_DiffuseRemapMax` RGB — a per-channel scale on the sampled albedo (a tint the artist dialled on the layer;
27
+ * remap-min is taken as 0). 1 = untouched. */
28
+ albedoScale: [number, number, number]
29
+ }
30
+
31
+ export type UnityTreePlacement = {
32
+ prefab: string // prefab GUID
33
+ /** Unity world position (terrain origin applied). */
34
+ position: Vec3
35
+ /** Yaw in radians (Unity: around +Y). */
36
+ rotation: number
37
+ widthScale: number
38
+ heightScale: number
39
+ }
40
+
41
+ export type UnityTerrain = {
42
+ name: string
43
+ /** Samples per side. */
44
+ resolution: number
45
+ /** Terrain size in metres (x, height, z). */
46
+ size: Vec3
47
+ /** Transform position of the terrain object (Unity world). */
48
+ origin: Vec3
49
+ /** Heights in metres above `origin.y`, row-major (row = z), resolution². */
50
+ heights: Float32Array
51
+ /** Per-layer weight maps at `alphaResolution`², 0–255, row-major (row = z); one array per layer. */
52
+ weights: Uint8Array[]
53
+ alphaResolution: number
54
+ layers: UnityTerrainLayer[]
55
+ trees: UnityTreePlacement[]
56
+ details: UnityTreePlacement[]
57
+ /** Prototypes the importer could not use (texture-billboard grass, missing prefabs). */
58
+ skipped: string[]
59
+ }
60
+
61
+ const obj = (v: UValue | undefined): UObject => (v && typeof v === "object" && !Array.isArray(v) && !ArrayBuffer.isView(v) ? v as UObject : {})
62
+ const arr = (v: UValue | undefined): UValue[] => (Array.isArray(v) ? v : [])
63
+
64
+ /** The GUID of an external PPtr (fileID ≥ 1), null for a local / empty one. */
65
+ const extGuid = (f: SerializedFile, v: UValue | undefined): string | null => {
66
+ const p = pptr(v)
67
+ if (p.fileID <= 0) return null
68
+ return f.externals[p.fileID - 1]?.guid ?? null
69
+ }
70
+
71
+ const readLayer = (resolver: GuidResolver, guid: string): UnityTerrainLayer | null => {
72
+ const path = resolver.resolve(guid)
73
+ if (!path) return null
74
+ const bytes = new Uint8Array(readFileSync(path))
75
+ let body: Record<string, unknown> = {}
76
+ let name = path.replace(/^.*[\\/]/, "").replace(/\.terrainlayer$/i, "")
77
+ if (isSerializedFile(bytes)) {
78
+ const f = openSerializedFile(bytes)
79
+ const o = f.objects[0]
80
+ if (!o) return null
81
+ const v = f.read(o)
82
+ const g = (k: string): string | null => extGuid(f, v[k])
83
+ const ts = obj(v.m_TileSize)
84
+ return {
85
+ name: String(v.m_Name ?? name), diffuse: resolvePath(resolver, g("m_DiffuseTexture")), normal: resolvePath(resolver, g("m_NormalMapTexture")),
86
+ mask: resolvePath(resolver, g("m_MaskMapTexture")), tileSize: [ Number(ts.x ?? 15), Number(ts.y ?? 15) ],
87
+ normalScale: Number(v.m_NormalScale ?? 1), metallic: Number(v.m_Metallic ?? 0), smoothness: Number(v.m_Smoothness ?? 0),
88
+ albedoScale: [ Number(obj(v.m_DiffuseRemapMax).x ?? 1), Number(obj(v.m_DiffuseRemapMax).y ?? 1), Number(obj(v.m_DiffuseRemapMax).z ?? 1) ],
89
+ }
90
+ }
91
+ const objs = readUnityObjects(new TextDecoder().decode(bytes))
92
+ body = objs.find((o) => o.cls === "TerrainLayer")?.body ?? {}
93
+ const b = body as Record<string, never>
94
+ name = String(b.m_Name || name)
95
+ const g = (k: string): string | null => ref(b[k]).guid ?? null
96
+ const ts = vec3(b.m_TileSize, [ 15, 15, 0 ])
97
+ return {
98
+ name, diffuse: resolvePath(resolver, g("m_DiffuseTexture")), normal: resolvePath(resolver, g("m_NormalMapTexture")),
99
+ mask: resolvePath(resolver, g("m_MaskMapTexture")), tileSize: [ ts[0], ts[1] ],
100
+ normalScale: num(b.m_NormalScale, 1), metallic: num(b.m_Metallic, 0), smoothness: num(b.m_Smoothness, 0),
101
+ albedoScale: (() => { const r = vec3(b.m_DiffuseRemapMax, [ 1, 1, 1 ]); return [ r[0], r[1], r[2] ] as [number, number, number] })(),
102
+ }
103
+ }
104
+ const resolvePath = (r: GuidResolver, g: string | null): string | null => (g ? r.resolve(g) : null)
105
+
106
+ /** Unity's heightmap integers are 0 … 32766 for 0 … size.y. */
107
+ const HEIGHT_MAX = 32766
108
+
109
+ // Unity scatters detail objects inside each cell with its own RNG; a seeded jitter is the honest
110
+ // stand-in (same counts per cell, positions deterministic across runs).
111
+ const mulberry = (seed: number): (() => number) => () => {
112
+ seed |= 0; seed = (seed + 0x6d2b79f5) | 0
113
+ let t = Math.imul(seed ^ (seed >>> 15), 1 | seed)
114
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
115
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
116
+ }
117
+
118
+ export const readUnityTerrain = (terrainDataPath: string, origin: Vec3, resolver: GuidResolver): UnityTerrain => {
119
+ const bytes = new Uint8Array(readFileSync(terrainDataPath))
120
+ if (!isSerializedFile(bytes)) throw new Error(`${terrainDataPath}: a text-serialised TerrainData is not supported (Unity writes them binary)`)
121
+ const f = openSerializedFile(bytes)
122
+ const tdInfo = f.objects.find((o) => o.classID === 156)
123
+ if (!tdInfo) throw new Error(`${terrainDataPath}: no TerrainData inside`)
124
+ const td = f.read(tdInfo)
125
+ const hm = obj(td.m_Heightmap)
126
+ const res = Number(hm.m_Resolution)
127
+ const scale = obj(hm.m_Scale)
128
+ const size: Vec3 = [ Number(scale.x) * (res - 1), Number(scale.y), Number(scale.z) * (res - 1) ]
129
+ const raw = hm.m_Heights as Int16Array
130
+ const heights = new Float32Array(res * res)
131
+ for (let i = 0; i < heights.length; i++) heights[i] = (raw[i] / HEIGHT_MAX) * size[1]
132
+
133
+ // splat weights: every alpha texture carries four layers in R, G, B, A
134
+ const splat = obj(td.m_SplatDatabase)
135
+ const layerGuids = arr(splat.m_TerrainLayers).map((v) => extGuid(f, v))
136
+ const layers: UnityTerrainLayer[] = []
137
+ const skipped: string[] = []
138
+ for (const g of layerGuids) {
139
+ const l = g ? readLayer(resolver, g) : null
140
+ if (l) layers.push(l)
141
+ else { skipped.push(`terrain layer ${g ?? "?"}`); layers.push({ name: "missing", diffuse: null, normal: null, mask: null, tileSize: [ 15, 15 ], normalScale: 1, metallic: 0, smoothness: 0, albedoScale: [ 1, 1, 1 ] }) }
142
+ }
143
+ const weights: Uint8Array[] = []
144
+ let alphaRes = Number(splat.m_AlphamapResolution ?? 0)
145
+ arr(splat.m_AlphaTextures).forEach((v) => {
146
+ const info = f.byPathID(pptr(v).pathID)
147
+ const tex = info ? f.read(info) : null
148
+ if (!tex) return
149
+ const w = Number(tex.m_Width), h = Number(tex.m_Height), fmt = Number(tex.m_TextureFormat)
150
+ const img = tex["image data"] as Uint8Array
151
+ alphaRes = w
152
+ // TextureFormat: 4 = RGBA32 (bytes R G B A), 5 = ARGB32 (bytes A R G B)
153
+ const chan = fmt === 5 ? [ 1, 2, 3, 0 ] : fmt === 4 ? [ 0, 1, 2, 3 ] : null
154
+ if (!chan || !img || img.length < w * h * 4) { skipped.push(`splat ${String(tex.m_Name)} (format ${fmt})`); for (let c = 0; c < 4; c++) weights.push(new Uint8Array(w * h)); return }
155
+ for (let c = 0; c < 4; c++) {
156
+ const out = new Uint8Array(w * h)
157
+ for (let i = 0; i < w * h; i++) out[i] = img[i * 4 + chan[c]]
158
+ weights.push(out)
159
+ }
160
+ })
161
+ weights.length = layers.length
162
+
163
+ // trees: normalised positions on the terrain, one prefab per prototype
164
+ const dd = obj(td.m_DetailDatabase)
165
+ const protos = arr(dd.m_TreePrototypes).map((p) => extGuid(f, obj(p).prefab))
166
+ const trees: UnityTreePlacement[] = []
167
+ for (const t of arr(dd.m_TreeInstances)) {
168
+ const o = obj(t)
169
+ const g = protos[Number(o.index)]
170
+ if (!g) continue
171
+ const p = obj(o.position)
172
+ trees.push({
173
+ prefab: g,
174
+ position: [ origin[0] + Number(p.x) * size[0], origin[1] + Number(p.y) * size[1], origin[2] + Number(p.z) * size[2] ],
175
+ rotation: Number(o.rotation ?? 0), widthScale: Number(o.widthScale ?? 1), heightScale: Number(o.heightScale ?? 1),
176
+ })
177
+ }
178
+
179
+ // details: per patch, per layer, a count per cell — mesh prototypes only (texture grass has no prefab)
180
+ const dprotos = arr(dd.m_DetailPrototypes).map((p) => {
181
+ const o = obj(p)
182
+ const g = Number(o.usePrototypeMesh) ? extGuid(f, o.prototype) : null
183
+ return { guid: g, minW: Number(o.minWidth ?? 1), maxW: Number(o.maxWidth ?? 1), minH: Number(o.minHeight ?? 1), maxH: Number(o.maxHeight ?? 1) }
184
+ })
185
+ const patchCount = Number(dd.m_PatchCount ?? 0), patchSamples = Number(dd.m_PatchSamples ?? 0)
186
+ const details: UnityTreePlacement[] = []
187
+ const rnd = mulberry(0x7e11a1)
188
+ const cellX = size[0] / (patchCount * patchSamples), cellZ = size[2] / (patchCount * patchSamples)
189
+ const heightAt = (x: number, z: number): number => {
190
+ const fx = Math.min(res - 1.0001, Math.max(0, x / size[0] * (res - 1))), fz = Math.min(res - 1.0001, Math.max(0, z / size[2] * (res - 1)))
191
+ const x0 = Math.floor(fx), z0 = Math.floor(fz), tx = fx - x0, tz = fz - z0
192
+ const h = (i: number, j: number): number => heights[j * res + i]
193
+ return (h(x0, z0) * (1 - tx) + h(x0 + 1, z0) * tx) * (1 - tz) + (h(x0, z0 + 1) * (1 - tx) + h(x0 + 1, z0 + 1) * tx) * tz
194
+ }
195
+ arr(dd.m_Patches).forEach((patch, pi) => {
196
+ const o = obj(patch)
197
+ const li = o.layerIndices as Uint8Array
198
+ const counts = o.numberOfObjects as Uint8Array
199
+ if (!li?.length) return
200
+ const px = pi % patchCount, pz = Math.floor(pi / patchCount)
201
+ for (let k = 0; k < li.length; k++) {
202
+ const proto = dprotos[li[k]]
203
+ if (!proto?.guid) { skipped.push(`detail layer ${li[k]} (texture grass)`); continue }
204
+ for (let cz = 0; cz < patchSamples; cz++) for (let cx = 0; cx < patchSamples; cx++) {
205
+ const n = counts[k * patchSamples * patchSamples + cz * patchSamples + cx] ?? 0
206
+ for (let i = 0; i < n; i++) {
207
+ const x = (px * patchSamples + cx + rnd()) * cellX, z = (pz * patchSamples + cz + rnd()) * cellZ
208
+ const w = proto.minW + rnd() * (proto.maxW - proto.minW)
209
+ const hsc = proto.minH + rnd() * (proto.maxH - proto.minH)
210
+ details.push({ prefab: proto.guid, position: [ origin[0] + x, origin[1] + heightAt(x, z), origin[2] + z ], rotation: rnd() * Math.PI * 2, widthScale: w, heightScale: hsc })
211
+ }
212
+ }
213
+ }
214
+ })
215
+
216
+ return { name: String(td.m_Name ?? "terrain"), resolution: res, size, origin, heights, weights, alphaResolution: alphaRes, layers, trees, details, skipped: [ ...new Set(skipped) ] }
217
+ }
218
+
219
+ /** The terrain component of a scene: its TerrainData GUID and transform position. */
220
+ export const terrainDataRef = (body: Record<string, unknown>): string | null => ref((body as { m_TerrainData?: never }).m_TerrainData).guid ?? null
221
+ export { isMap }
@@ -0,0 +1,77 @@
1
+ // A TRIANGLE BUDGET PER KIND. Packs and hand-assembled projects carry photogrammetry scans as props:
2
+ // Peremena's open herbarium is 1 057 824 triangles (81 MB of GLB) and a single book 170 000 — on a
3
+ // school desk, next to 500-triangle furniture. Imported as they are, they are not merely heavy: their
4
+ // MESH COLLIDER (the scene file's `Shape { mesh: true }` is built from the GLB) took the headless
5
+ // renderer's physics down with an out-of-bounds abort before the first frame.
6
+ //
7
+ // So a kind over the budget is simplified at import (meshoptimizer, the same weld + simplify the doctor's
8
+ // LOD pass runs), all of its primitives by one ratio. A scan's look lives in its normal map, which the
9
+ // simplified surface keeps (the UVs are preserved), and the collider shrinks with it. Skinned meshes are
10
+ // left alone: collapsing vertices under a skin moves weights the budget knows nothing about.
11
+
12
+ import { PropertyType, type Document, type Primitive } from "@gltf-transform/core"
13
+ import { prune, simplifyPrimitive, weldPrimitive } from "@gltf-transform/functions"
14
+ import { MeshoptSimplifier } from "meshoptimizer"
15
+
16
+ export const DEFAULT_MAX_TRIS = 50_000
17
+
18
+ const trisOf = (p: Primitive): number => p.getMode() !== 4 ? 0 : Math.floor((p.getIndices()?.getCount() ?? p.getAttribute("POSITION")?.getCount() ?? 0) / 3)
19
+
20
+ /** Simplify the document's meshes down to about `maxTris` triangles in total. Null when it already fits. */
21
+ export const fitTriangleBudget = async (doc: Document, maxTris: number): Promise<{ before: number, after: number } | null> => {
22
+ // a Set: a kind's parts that share a mesh share its primitives (kindGlb clones meshes shallowly)
23
+ const prims = [ ...new Set(doc.getRoot().listMeshes().flatMap((m) => m.listPrimitives())) ].filter((p) => p.getMode() === 4 && !p.getAttribute("JOINTS_0"))
24
+ const before = prims.reduce((n, p) => n + trisOf(p), 0)
25
+ if (maxTris <= 0 || before <= maxTris) return null
26
+ await MeshoptSimplifier.ready
27
+ const ratio = maxTris / before
28
+ for (const p of prims) {
29
+ const want = trisOf(p) * ratio
30
+ weldPrimitive(p)
31
+ // `error` is the ceiling on how far the surface may move, relative to the mesh's size: a scan has
32
+ // to go down 20× here, which a tight 1 % stops short of
33
+ simplifyPrimitive(p, { simplifier: MeshoptSimplifier, ratio, error: 0.05, lockBorder: false })
34
+ // A FACETED SCAN DOES NOT WELD: every triangle carries its own normals, so no two faces share a
35
+ // vertex and every edge is a seam the simplifier must keep (the herbarium: 2.1 M vertices on 0.53 M
36
+ // positions, 1 057 824 triangles in, 1 057 824 out). Its shading is in the normal map anyway, so the
37
+ // normals go, the mesh welds on position + UV and simplifies, and smooth ones are rebuilt. Tried
38
+ // SECOND, so hard-surface props that simplify with their normals keep their hard edges.
39
+ if (trisOf(p) > want * 1.5 && p.getAttribute("NORMAL")) {
40
+ p.setAttribute("NORMAL", null).setAttribute("TANGENT", null)
41
+ weldPrimitive(p)
42
+ simplifyPrimitive(p, { simplifier: MeshoptSimplifier, ratio: Math.min(1, want / Math.max(1, trisOf(p))), error: 0.05, lockBorder: false })
43
+ smoothNormals(doc, p)
44
+ }
45
+ }
46
+ // weld and simplify write NEW accessors: the million-triangle originals stay in the file until pruned
47
+ // (the herbarium was 27 MB at 50 000 triangles without this)
48
+ await doc.transform(prune({ propertyTypes: [ PropertyType.ACCESSOR ] }))
49
+ return { before, after: prims.reduce((n, p) => n + trisOf(p), 0) }
50
+ }
51
+
52
+ /** Area-weighted vertex normals, summed per POSITION so a UV seam (two vertices, one point) shades as one. */
53
+ const smoothNormals = (doc: Document, p: Primitive): void => {
54
+ const pos = p.getAttribute("POSITION")!, idx = p.getIndices()
55
+ const n = pos.getCount()
56
+ const P = pos.getArray()!, I = idx ? idx.getArray()! : Uint32Array.from({ length: n }, (_, i) => i)
57
+ const keyOf = new Int32Array(n), sums: number[] = [], byPos = new Map<string, number>()
58
+ for (let i = 0; i < n; i++) {
59
+ const k = `${P[i * 3]},${P[i * 3 + 1]},${P[i * 3 + 2]}`
60
+ let s = byPos.get(k)
61
+ if (s === undefined) { s = byPos.size; byPos.set(k, s); sums.push(0, 0, 0) }
62
+ keyOf[i] = s
63
+ }
64
+ for (let t = 0; t + 2 < I.length; t += 3) {
65
+ const a = I[t] * 3, b = I[t + 1] * 3, c = I[t + 2] * 3
66
+ const ux = P[b] - P[a], uy = P[b + 1] - P[a + 1], uz = P[b + 2] - P[a + 2]
67
+ const vx = P[c] - P[a], vy = P[c + 1] - P[a + 1], vz = P[c + 2] - P[a + 2]
68
+ const nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx // |n| = 2 × area
69
+ for (const v of [ I[t], I[t + 1], I[t + 2] ]) { const s = keyOf[v] * 3; sums[s] += nx; sums[s + 1] += ny; sums[s + 2] += nz }
70
+ }
71
+ const out = new Float32Array(n * 3)
72
+ for (let i = 0; i < n; i++) {
73
+ const s = keyOf[i] * 3, l = Math.hypot(sums[s], sums[s + 1], sums[s + 2]) || 1
74
+ out[i * 3] = sums[s] / l; out[i * 3 + 1] = sums[s + 1] / l; out[i * 3 + 2] = sums[s + 2] / l
75
+ }
76
+ p.setAttribute("NORMAL", doc.createAccessor("NORMAL", pos.getBuffer()).setType("VEC3").setArray(out))
77
+ }
@@ -0,0 +1,122 @@
1
+ // TRIPLANAR SHADER GRAPHS → REAL UVs.
2
+ //
3
+ // A Unity Shader Graph whose textures go through a Triplanar node samples them by POSITION, not by the
4
+ // mesh's UVs: `uv = position × Tile`, the X face reading `.zy`, the Y face `.xz`, the Z face `.xy`,
5
+ // blended by the normal. The mesh's own UVs are then whatever the modeller left — Peremena's school
6
+ // shell (walls, ceiling) is one such mesh, and imported with its UVs it came out as garbage or, with
7
+ // the graph's `_Diffuse_Tex` / `_Normal_Tex` / `_Roughness_Tex` names unknown, untextured.
8
+ //
9
+ // There is no triplanar material for models in the engine, and a custom one would take the surface out
10
+ // of the lightmap (baked materials are the stock lit PBR). So the projection is BAKED into TEXCOORD_0
11
+ // at import: per vertex, the face the vertex normal points along picks the plane, exactly the node's
12
+ // planes. For the flat, axis-aligned surfaces triplanar is used on (walls, floors, ceilings) that IS
13
+ // the node's picture; the blend zone on a slanted face and a smooth-shaded corner are where it
14
+ // differs. Positions are the KIND's frame: a kind placed without rotation or scale (the school's shell)
15
+ // matches Unity up to where the texture's tiling starts, a rotated or scaled one tiles in its own axes.
16
+
17
+ import { existsSync, readFileSync } from "node:fs"
18
+ import type { Accessor, Document, Primitive } from "@gltf-transform/core"
19
+ import type { GuidResolver } from "./guid"
20
+ import type { UnityMaterial } from "./matFile"
21
+ import type { Mat4 } from "./math"
22
+
23
+ export type Triplanar = { tile: number, space: "world" | "object" }
24
+
25
+ type GraphObject = Record<string, any>
26
+
27
+ /** A `.shadergraph` file: a run of JSON objects back to back (Unity ≥ 2021's multi-JSON format). */
28
+ const graphObjects = (text: string): GraphObject[] => {
29
+ const out: GraphObject[] = []
30
+ let depth = 0, start = -1, inStr = false, esc = false
31
+ for (let i = 0; i < text.length; i++) {
32
+ const ch = text[i]
33
+ if (inStr) { if (esc) esc = false; else if (ch === "\\") esc = true; else if (ch === "\"") inStr = false; continue }
34
+ if (ch === "\"") inStr = true
35
+ else if (ch === "{") { if (depth++ === 0) start = i }
36
+ else if (ch === "}" && --depth === 0) { try { out.push(JSON.parse(text.slice(start, i + 1))) } catch { /* not a graph object */ } }
37
+ }
38
+ return out
39
+ }
40
+
41
+ const isType = (o: GraphObject, t: string): boolean => typeof o.m_Type === "string" && o.m_Type.endsWith(t)
42
+
43
+ /** What a graph's Triplanar node samples with: its Tile (a number, or the reference name of the
44
+ * material property wired into it) and its space. Null = no Triplanar node. */
45
+ type GraphTriplanar = { tileProperty: string | null, tileDefault: number, space: "world" | "object" }
46
+
47
+ const readGraph = (file: string): GraphTriplanar | null => {
48
+ const objs = graphObjects(readFileSync(file, "utf8"))
49
+ const byId = new Map<string, GraphObject>()
50
+ for (const o of objs) if (typeof o.m_ObjectId === "string") byId.set(o.m_ObjectId, o)
51
+ const node = objs.find((o) => isType(o, ".TriplanarNode"))
52
+ if (!node) return null
53
+ const slots = (node.m_Slots ?? []).map((s: GraphObject) => byId.get(s.m_Id)).filter(Boolean) as GraphObject[]
54
+ const tileSlot = slots.find((s) => s.m_DisplayName === "Tile")
55
+ const tileDefault = typeof tileSlot?.m_Value === "number" ? tileSlot.m_Value : 1
56
+ let tileProperty: string | null = null
57
+ const graph = objs.find((o) => isType(o, ".GraphData"))
58
+ for (const e of graph?.m_Edges ?? []) {
59
+ if (e.m_InputSlot?.m_Node?.m_Id !== node.m_ObjectId || e.m_InputSlot?.m_SlotId !== tileSlot?.m_Id) continue
60
+ const from = byId.get(e.m_OutputSlot?.m_Node?.m_Id)
61
+ const prop = from && isType(from, ".PropertyNode") ? byId.get(from.m_Property?.m_Id) : undefined
62
+ if (prop) tileProperty = prop.m_OverrideReferenceName || prop.m_DefaultReferenceName || null
63
+ }
64
+ // CoordinateSpace: 0 Object, 1 View, 2 World, 3 Tangent, 4 AbsoluteWorld
65
+ const space = node.m_InputSpace === 0 ? "object" : "world"
66
+ return { tileProperty, tileDefault, space }
67
+ }
68
+
69
+ /** Per resolver: shader guid → its triplanar read (null = none / not a graph). */
70
+ const graphs = new WeakMap<GuidResolver, Map<string, GraphTriplanar | null>>()
71
+
72
+ /** The triplanar projection a material's shader samples its textures with, or null. */
73
+ export const triplanarOf = (um: UnityMaterial, resolver: GuidResolver): Triplanar | null => {
74
+ if (!um.shader.startsWith("guid:")) return null
75
+ const guid = um.shader.slice(5)
76
+ let cache = graphs.get(resolver)
77
+ if (!cache) graphs.set(resolver, cache = new Map())
78
+ if (!cache.has(guid)) {
79
+ const file = resolver.resolve(guid)
80
+ cache.set(guid, file && /\.shadergraph$/i.test(file) && existsSync(file) ? readGraph(file) : null)
81
+ }
82
+ const g = cache.get(guid)
83
+ if (!g) return null
84
+ const tile = g.tileProperty !== null ? um.floats[g.tileProperty] ?? g.tileDefault : g.tileDefault
85
+ return { tile: tile > 0 ? tile : 1, space: g.space }
86
+ }
87
+
88
+ const apply = (m: Mat4, p: number[], w: number): number[] => [
89
+ m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12] * w,
90
+ m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13] * w,
91
+ m[2] * p[0] + m[6] * p[1] + m[10] * p[2] + m[14] * w,
92
+ ]
93
+
94
+ /**
95
+ * The projection's UVs for one primitive, as a new TEXCOORD_0 — `matrix` takes the primitive's
96
+ * positions into the frame the projection runs in (the part's node matrix: kind space). Our frame is
97
+ * Unity's mirrored on X, so Unity's `x` is our `−x`; glTF's V runs down where Unity's runs up.
98
+ * TANGENT is dropped: it belonged to the old UVs, and the engine derives one from the new ones.
99
+ */
100
+ export const projectTriplanar = (doc: Document, prim: Primitive, matrix: Mat4, tile: number): boolean => {
101
+ const pos = prim.getAttribute("POSITION"), nrm = prim.getAttribute("NORMAL")
102
+ if (!pos || !nrm) return false
103
+ const n = pos.getCount()
104
+ const uv = new Float32Array(n * 2)
105
+ const p: number[] = [], q: number[] = []
106
+ // normals go through the inverse transpose; the kind matrices here are rotation × uniform-ish
107
+ // scale, where the matrix itself points the same way — the length is normalised away
108
+ for (let i = 0; i < n; i++) {
109
+ const [ x, y, z ] = apply(matrix, pos.getElement(i, p), 1)
110
+ const [ nx, ny, nz ] = apply(matrix, nrm.getElement(i, q), 0)
111
+ const ax = Math.abs(nx), ay = Math.abs(ny), az = Math.abs(nz)
112
+ const ux = -x // Unity's x
113
+ const [ a, b ] = ax >= ay && ax >= az ? [ z, y ] : ay >= az ? [ ux, z ] : [ ux, y ]
114
+ uv[i * 2] = a * tile
115
+ uv[i * 2 + 1] = 1 - b * tile
116
+ }
117
+ const acc: Accessor = doc.createAccessor(prim.getName() ? `${prim.getName()}_triplanar` : "triplanar")
118
+ .setType("VEC2").setArray(uv).setBuffer(pos.getBuffer())
119
+ prim.setAttribute("TEXCOORD_0", acc)
120
+ prim.setAttribute("TANGENT", null)
121
+ return true
122
+ }