lecodes-assets 0.2.1 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +203 -26
- package/dist/cli.js +18157 -11441
- package/dist/index.js +16603 -9878
- package/package.json +3 -1
- 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 -1039
- 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 +79 -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 +108 -0
- package/src/commands/shared.ts +140 -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 +288 -0
- package/src/doctor/doctor.ts +94 -30
- package/src/doctor/impostor.ts +614 -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/transcodeKtx2.ts +186 -31
- package/src/doctor/transcodeTextures.ts +6 -1
- package/src/doctor/validateGlb.ts +2 -2
- package/src/doctor/vertexAo.ts +333 -0
- package/src/fbx/read.ts +2 -0
- package/src/fbx/toDocument.ts +227 -209
- package/src/geometry.ts +222 -0
- package/src/index.ts +2 -0
- package/src/retarget/heading.ts +238 -171
- package/src/retarget/humanoid.ts +572 -561
- package/src/retarget/retarget.ts +2375 -2272
- package/src/retarget/rig.ts +4 -1
- package/src/retarget/writeMarks.ts +134 -132
- package/src/sky/cmgen.ts +3 -3
- package/src/sky/index.ts +57 -8
- package/src/terrainPack.ts +61 -22
- 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 +90 -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 +414 -0
- package/src/unity/levelTerrain.ts +125 -0
- package/src/unity/matFile.ts +179 -0
- package/src/unity/materialSlots.ts +53 -0
- package/src/unity/materialWriter.ts +316 -0
- package/src/unity/materials.ts +34 -429
- package/src/unity/math.ts +82 -0
- package/src/unity/prefabTree.ts +285 -0
- package/src/unity/prefabs.ts +10 -36
- package/src/unity/propertyPath.ts +43 -0
- package/src/unity/scene.ts +339 -304
- 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 +24 -5
- package/src/unity/yaml.ts +131 -0
- package/src/unreal/index.ts +6 -5
- package/src/unreal/materialGraph.ts +224 -0
- package/src/unreal/materials.ts +117 -9
- package/src/unreal/package.ts +30 -12
- package/src/unreal/resolve.ts +6 -4
- package/src/unreal/sound.ts +128 -0
- package/src/unreal/texture.ts +70 -3
- package/src/unreal/toDocument.ts +7 -0
- package/src/unreal/versions.ts +8 -0
- package/src/unwrap/index.ts +167 -0
- package/src/unwrap/lightmapUv.ts +761 -0
- package/src/wasm/fbxReader.js +1 -1
- package/src/wasm/lightmapUv.js +1 -1
- package/src/doctor/lightmapUv.ts +0 -452
package/src/unity/materials.ts
CHANGED
|
@@ -1,292 +1,26 @@
|
|
|
1
|
-
// Unity materials as the texture source of truth.
|
|
1
|
+
// Unity materials as the texture source of truth for an FBX conversion.
|
|
2
2
|
//
|
|
3
3
|
// An FBX exported for Unity usually references only a base colour (often a legacy file name) and a
|
|
4
4
|
// normal map; the real shading inputs live in the Unity `.mat` next to it — metallic, roughness /
|
|
5
5
|
// smoothness, occlusion (separate maps or an ORM / MaskMap / MetallicGloss pack), emission, and the
|
|
6
6
|
// alpha mode. Textures are referenced by GUID, resolved through a `unity-index.txt` (written by
|
|
7
|
-
// `lecodes assets unpack`) or the `.meta` sidecars of a real Unity project.
|
|
7
|
+
// `lecodes assets unpack`) or the `.meta` sidecars of a real Unity project (guid.ts).
|
|
8
8
|
//
|
|
9
9
|
// What this does: for every FBX material, find the `.mat` of the same name (modulo M_/MI_/MAT_
|
|
10
|
-
// prefixes
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"
|
|
23
|
-
import { basename, dirname, join, resolve } from "node:path"
|
|
24
|
-
import type { Document, Material, Texture } from "@gltf-transform/core"
|
|
25
|
-
import { sniffMime, decodeTga, decodeBmp, encodePng, type Rgba } from "../textures/resolveImage"
|
|
26
|
-
import { decodePng } from "./png"
|
|
10
|
+
// prefixes; matFile.ts reads it, a BINARY one through the pack's texture naming convention), and
|
|
11
|
+
// rewrite the glTF material from it (materialWriter.ts). `.mat` files are also looked up across the
|
|
12
|
+
// whole unpacked package through `unity-index.txt`: packs keep shared materials (asphalt, concrete)
|
|
13
|
+
// in a Textures/ tree far from the FBX that uses them. Then the material overrides of the `.prefab`
|
|
14
|
+
// files around the FBX are honoured (prefabs.ts).
|
|
15
|
+
|
|
16
|
+
import { existsSync } from "node:fs"
|
|
17
|
+
import { basename, dirname, resolve } from "node:path"
|
|
18
|
+
import type { Document, Material, Mesh, Node } from "@gltf-transform/core"
|
|
19
|
+
import { findUnityIndex, guidOfPath, indexedMaterialFiles, resolverFromIndex, resolverFromMeta, type GuidResolver } from "./guid"
|
|
20
|
+
import { collectUnityMaterials, materialKey, type UnityMaterial } from "./matFile"
|
|
21
|
+
import { SLOTS, pick, unityMaterialWriter, type Warn } from "./materialWriter"
|
|
27
22
|
import { collectPrefabOverrides, type PrefabOverride } from "./prefabs"
|
|
28
23
|
|
|
29
|
-
export type UnityMaterial = {
|
|
30
|
-
name: string
|
|
31
|
-
file: string
|
|
32
|
-
/** Shader asset path (resolved) or the builtin id. */
|
|
33
|
-
shader: string
|
|
34
|
-
/** Texture slot → GUID (only slots that reference a texture). */
|
|
35
|
-
textures: Record<string, string>
|
|
36
|
-
floats: Record<string, number>
|
|
37
|
-
colors: Record<string, [number, number, number, number]>
|
|
38
|
-
keywords: string[]
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
type Warn = (w: { level: "warn" | "info", message: string }) => void
|
|
42
|
-
|
|
43
|
-
// ---- .mat parsing (YAML) ------------------------------------------------------------------------
|
|
44
|
-
|
|
45
|
-
/** Parse a YAML-serialised Unity material. Returns null for binary serialisation / not a material. */
|
|
46
|
-
export const parseUnityMaterial = (text: string, file: string): UnityMaterial | null => {
|
|
47
|
-
if (!text.startsWith("%YAML") || !/\nMaterial:\n/.test(text)) return null
|
|
48
|
-
const block = text.slice(text.indexOf("\nMaterial:\n"))
|
|
49
|
-
const name = /\n\s*m_Name: (.*)/.exec(block)?.[1]?.trim() || basename(file, ".mat")
|
|
50
|
-
const sh = /m_Shader: \{fileID: (-?\d+)(?:, guid: ([0-9a-f]{32}))?/.exec(text)
|
|
51
|
-
const shader = sh ? (sh[2] ? `guid:${sh[2]}` : `builtin:${sh[1]}`) : ""
|
|
52
|
-
const textures: Record<string, string> = {}
|
|
53
|
-
const texRe = /\n\s+- (\w+):\n\s+m_Texture: \{fileID: -?\d+, guid: ([0-9a-f]{32}), type: \d+\}/g
|
|
54
|
-
for (let m = texRe.exec(text); m; m = texRe.exec(text)) textures[m[1]] = m[2]
|
|
55
|
-
const floats: Record<string, number> = {}
|
|
56
|
-
const floatSection = /m_Floats:\n([\s\S]*?)\n\s+m_Colors:/.exec(text)?.[1] ?? ""
|
|
57
|
-
const fRe = /- (\w+): (-?[\d.eE+-]+)/g
|
|
58
|
-
for (let m = fRe.exec(floatSection); m; m = fRe.exec(floatSection)) floats[m[1]] = Number(m[2])
|
|
59
|
-
const colors: Record<string, [number, number, number, number]> = {}
|
|
60
|
-
const cRe = /- (\w+): \{r: (-?[\d.eE+-]+), g: (-?[\d.eE+-]+), b: (-?[\d.eE+-]+), a: (-?[\d.eE+-]+)\}/g
|
|
61
|
-
for (let m = cRe.exec(text); m; m = cRe.exec(text)) colors[m[1]] = [ Number(m[2]), Number(m[3]), Number(m[4]), Number(m[5]) ]
|
|
62
|
-
const keywords = (/m_ShaderKeywords: (.*)/.exec(text)?.[1] ?? "").trim().split(/\s+/).filter(Boolean)
|
|
63
|
-
return { name, file, shader, textures, floats, colors, keywords }
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export const isBinaryUnityAsset = (bytes: Uint8Array): boolean => !(bytes[0] === 0x25 && bytes[1] === 0x59 && bytes[2] === 0x41) // "%YA"
|
|
67
|
-
|
|
68
|
-
// ---- locating materials + resolving GUIDs ----------------------------------------------------------
|
|
69
|
-
|
|
70
|
-
const walkFiles = (dir: string, depth: number, out: string[], pred: (name: string) => boolean): void => {
|
|
71
|
-
if (depth < 0 || !existsSync(dir)) return
|
|
72
|
-
let entries
|
|
73
|
-
try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
|
|
74
|
-
for (const e of entries) {
|
|
75
|
-
const p = join(dir, e.name)
|
|
76
|
-
if (e.isDirectory()) { if (e.name !== "node_modules" && !e.name.startsWith(".")) walkFiles(p, depth - 1, out, pred) }
|
|
77
|
-
else if (pred(e.name)) out.push(p)
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** Normalise a material name for matching: case, and the M_/MI_/MAT_ prefixes packs sprinkle. */
|
|
82
|
-
export const materialKey = (n: string): string => n.trim().toLowerCase().replace(/^(mi|m|mat|mtl)_/, "").replace(/\.\d+$/, "")
|
|
83
|
-
|
|
84
|
-
/** Texture references an inferred material carries: a pseudo-GUID naming the file directly. */
|
|
85
|
-
export const PATH_GUID = "path:"
|
|
86
|
-
const IMAGE_RE = /\.(png|jpe?g|tga|bmp|tiff?|psd|exr)$/i
|
|
87
|
-
/** Suffix conventions (after the material name, case-insensitive) → Unity slot. The base colour is
|
|
88
|
-
* the bare name or one of the albedo suffixes. */
|
|
89
|
-
const SUFFIX_SLOTS: [RegExp, string][] = [
|
|
90
|
-
[ /^$|^_(d|bc|albedo|diffuse|diff|basecolor|base|col|color)$/i, "_MainTex" ],
|
|
91
|
-
[ /^_(nm|n|nrm|normal|normalmap|norm)$/i, "_BumpMap" ],
|
|
92
|
-
[ /^_(ms|metallicsmoothness|metallicgloss|mg)$/i, "_MetallicGlossMap" ],
|
|
93
|
-
[ /^_(m|metallic|metal|met)$/i, "_Metallic" ],
|
|
94
|
-
[ /^_(r|rough|roughness)$/i, "_Roughness" ],
|
|
95
|
-
[ /^_(s|smoothness|gloss)$/i, "_Smoothness" ],
|
|
96
|
-
[ /^_(ao|occlusion|ambientocclusion)$/i, "_OcclusionMap" ],
|
|
97
|
-
[ /^_(e|em|emission|emissive|emit)$/i, "_EmissionMap" ],
|
|
98
|
-
[ /^_(orm)$/i, "_ORM" ],
|
|
99
|
-
[ /^_(mask|maskmap)$/i, "_MaskMap" ],
|
|
100
|
-
]
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* A binary-serialised `.mat` can't be parsed, but asset-store packs name their textures after the
|
|
104
|
-
* material: `Seamless_asphalt_v1.mat` next to `Source/Seamless_asphalt_v1.tga` + `…_NM.tga`. Scan the
|
|
105
|
-
* .mat's folder (and its subfolders, two deep) for images whose stem is the material name plus a
|
|
106
|
-
* known suffix and build a material whose texture refs point at those files (`path:` pseudo-GUIDs,
|
|
107
|
-
* resolved without an index). Null when no base colour is found — a wrong guess is worse than none.
|
|
108
|
-
*/
|
|
109
|
-
export const inferBinaryMaterial = (file: string): UnityMaterial | null => {
|
|
110
|
-
const name = basename(file, ".mat")
|
|
111
|
-
const dir = dirname(file)
|
|
112
|
-
const images: string[] = []
|
|
113
|
-
walkFiles(dir, 2, images, (n) => IMAGE_RE.test(n))
|
|
114
|
-
const textures: Record<string, string> = {}
|
|
115
|
-
const lname = name.toLowerCase()
|
|
116
|
-
for (const img of images.sort()) {
|
|
117
|
-
const stem = basename(img).replace(/\.[^.]+$/, "")
|
|
118
|
-
if (!stem.toLowerCase().startsWith(lname)) continue
|
|
119
|
-
const suffix = stem.slice(name.length)
|
|
120
|
-
const slot = SUFFIX_SLOTS.find(([ re ]) => re.test(suffix))?.[1]
|
|
121
|
-
if (slot && !textures[slot]) textures[slot] = PATH_GUID + img
|
|
122
|
-
}
|
|
123
|
-
if (!textures._MainTex) return null
|
|
124
|
-
return { name, file, shader: "binary", textures, floats: {}, colors: {}, keywords: [] }
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/** Every readable `.mat` under `dirs` (recursive) plus `extraFiles`, keyed by normalised name.
|
|
128
|
-
* Binary ones are listed in `binary`; those whose textures could be inferred from their neighbours
|
|
129
|
-
* (`inferBinaryMaterial`) are ALSO used and listed in `inferred`. */
|
|
130
|
-
export const collectUnityMaterials = (dirs: string[], depth = 6, extraFiles: string[] = []): { byKey: Map<string, UnityMaterial>, byFile: Map<string, UnityMaterial>, binary: string[], inferred: string[] } => {
|
|
131
|
-
const files: string[] = []
|
|
132
|
-
for (const d of dirs) walkFiles(d, depth, files, (n) => /\.mat$/i.test(n))
|
|
133
|
-
const seen = new Set(files.map((f) => resolve(f)))
|
|
134
|
-
for (const f of extraFiles) { const a = resolve(f); if (!seen.has(a) && existsSync(a)) { seen.add(a); files.push(a) } }
|
|
135
|
-
const byKey = new Map<string, UnityMaterial>()
|
|
136
|
-
// Keyed by ABSOLUTE PATH as well: `materialKey` strips the M_/MI_ prefix, so a pack that ships a
|
|
137
|
-
// real `Materials/M_Wall.mat` beside the FBX's placeholder `Assets/Materials/MI_Wall.mat` collapses
|
|
138
|
-
// both onto one key and whichever was walked first wins. A prefab override names its material by
|
|
139
|
-
// GUID — an exact file — and must be looked up as one.
|
|
140
|
-
const byFile = new Map<string, UnityMaterial>()
|
|
141
|
-
const binary: string[] = []
|
|
142
|
-
const inferred: string[] = []
|
|
143
|
-
const put = (m: UnityMaterial, f: string) => {
|
|
144
|
-
byFile.set(resolve(f), m)
|
|
145
|
-
const k = materialKey(m.name)
|
|
146
|
-
if (!byKey.has(k)) byKey.set(k, m)
|
|
147
|
-
const fk = materialKey(basename(f, ".mat"))
|
|
148
|
-
if (!byKey.has(fk)) byKey.set(fk, m)
|
|
149
|
-
}
|
|
150
|
-
for (const f of files) {
|
|
151
|
-
const bytes = new Uint8Array(readFileSync(f))
|
|
152
|
-
if (isBinaryUnityAsset(bytes)) {
|
|
153
|
-
binary.push(f)
|
|
154
|
-
const m = inferBinaryMaterial(f)
|
|
155
|
-
if (m) { inferred.push(f); put(m, f) }
|
|
156
|
-
continue
|
|
157
|
-
}
|
|
158
|
-
const m = parseUnityMaterial(new TextDecoder().decode(bytes), f)
|
|
159
|
-
if (!m) continue
|
|
160
|
-
put(m, f)
|
|
161
|
-
}
|
|
162
|
-
return { byKey, byFile, binary, inferred }
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/** Every `.mat` the index knows (absolute paths) — shared materials live far from the FBX. */
|
|
166
|
-
export const indexedMaterialFiles = (indexFile: string): string[] => {
|
|
167
|
-
const root = dirname(indexFile)
|
|
168
|
-
const out: string[] = []
|
|
169
|
-
for (const line of readFileSync(indexFile, "utf8").split(/\r?\n/)) {
|
|
170
|
-
const t = line.indexOf("\t")
|
|
171
|
-
if (t > 0 && /\.mat$/i.test(line)) out.push(join(root, line.slice(t + 1).trim()))
|
|
172
|
-
}
|
|
173
|
-
return out
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
export type GuidResolver = { resolve: (guid: string) => string | null, source: string }
|
|
177
|
-
|
|
178
|
-
/** Find `unity-index.txt` (written by `lecodes assets unpack`) walking up from `start`. */
|
|
179
|
-
export const findUnityIndex = (start: string): string | null => {
|
|
180
|
-
let dir = resolve(start)
|
|
181
|
-
for (let i = 0; i < 12; i++) {
|
|
182
|
-
const f = join(dir, "unity-index.txt")
|
|
183
|
-
if (existsSync(f)) return f
|
|
184
|
-
const up = dirname(dir)
|
|
185
|
-
if (up === dir) break
|
|
186
|
-
dir = up
|
|
187
|
-
}
|
|
188
|
-
return null
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/** The GUID the index lists for an absolute asset path (the FBX itself), or null. */
|
|
192
|
-
export const guidOfPath = (indexFile: string, absPath: string): string | null => {
|
|
193
|
-
const root = resolve(dirname(indexFile))
|
|
194
|
-
const want = resolve(absPath).toLowerCase().replace(/\\/g, "/")
|
|
195
|
-
for (const line of readFileSync(indexFile, "utf8").split(/\r?\n/)) {
|
|
196
|
-
const t = line.indexOf("\t")
|
|
197
|
-
if (t < 0) continue
|
|
198
|
-
if (resolve(root, line.slice(t + 1).trim()).toLowerCase().replace(/\\/g, "/") === want) return line.slice(0, t)
|
|
199
|
-
}
|
|
200
|
-
return null
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/** GUID → absolute path through an index file (`<guid>\t<path relative to the index's folder>`). */
|
|
204
|
-
export const resolverFromIndex = (indexFile: string): GuidResolver => {
|
|
205
|
-
const root = dirname(indexFile)
|
|
206
|
-
const map = new Map<string, string>()
|
|
207
|
-
for (const line of readFileSync(indexFile, "utf8").split(/\r?\n/)) {
|
|
208
|
-
const t = line.indexOf("\t")
|
|
209
|
-
if (t > 0) map.set(line.slice(0, t), line.slice(t + 1).trim())
|
|
210
|
-
}
|
|
211
|
-
return { source: indexFile, resolve: (g) => { const p = map.get(g); if (!p) return null; const abs = join(root, p); return existsSync(abs) ? abs : null } }
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/** GUID → path through `.meta` sidecars: scans up to the folder that holds `Assets` (or `depthUp` levels). */
|
|
215
|
-
export const resolverFromMeta = (start: string, depthUp = 6): GuidResolver | null => {
|
|
216
|
-
let root = resolve(start)
|
|
217
|
-
for (let i = 0; i < depthUp; i++) {
|
|
218
|
-
if (existsSync(join(root, "Assets")) || basename(root) === "Assets") break
|
|
219
|
-
const up = dirname(root)
|
|
220
|
-
if (up === root) break
|
|
221
|
-
root = up
|
|
222
|
-
}
|
|
223
|
-
const metas: string[] = []
|
|
224
|
-
walkFiles(root, 12, metas, (n) => n.endsWith(".meta"))
|
|
225
|
-
if (metas.length === 0) return null
|
|
226
|
-
const map = new Map<string, string>()
|
|
227
|
-
for (const f of metas) {
|
|
228
|
-
const head = readFileSync(f, "utf8").slice(0, 300)
|
|
229
|
-
const g = /\nguid: ([0-9a-f]{32})/.exec(head)?.[1]
|
|
230
|
-
if (g) map.set(g, f.slice(0, -5))
|
|
231
|
-
}
|
|
232
|
-
return { source: `${metas.length} .meta files under ${root}`, resolve: (g) => { const p = map.get(g); return p && existsSync(p) ? p : null } }
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
// ---- textures: decode / pack / encode -------------------------------------------------------------
|
|
236
|
-
|
|
237
|
-
export const decodeImage = async (bytes: Uint8Array, hint: string): Promise<Rgba | null> => {
|
|
238
|
-
const mime = sniffMime(bytes, hint)
|
|
239
|
-
if (mime === "image/png") { const r = decodePng(bytes); if (r) return r }
|
|
240
|
-
if (mime === "image/x-tga") return decodeTga(bytes)
|
|
241
|
-
if (mime === "image/bmp") return decodeBmp(bytes)
|
|
242
|
-
try {
|
|
243
|
-
const sharp = (await import("sharp")).default
|
|
244
|
-
const { data, info } = await sharp(Buffer.from(bytes)).ensureAlpha().raw().toBuffer({ resolveWithObject: true })
|
|
245
|
-
return { width: info.width, height: info.height, data: new Uint8Array(data) }
|
|
246
|
-
} catch { return null }
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
/** Nearest-neighbour sample of one channel at (u, v) in 0..1. */
|
|
250
|
-
const sampleCh = (img: Rgba, ch: number, x: number, y: number, w: number, h: number): number => {
|
|
251
|
-
const sx = Math.min(img.width - 1, Math.floor((x + 0.5) * img.width / w))
|
|
252
|
-
const sy = Math.min(img.height - 1, Math.floor((y + 0.5) * img.height / h))
|
|
253
|
-
return img.data[(sy * img.width + sx) * 4 + ch]
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
export type PackInput = { img: Rgba, channel: number, invert?: boolean }
|
|
257
|
-
/** Pack occlusion / roughness / metallic sources into one RGB image (glTF layout). Missing = constant. */
|
|
258
|
-
export const packOrm = (ao: PackInput | null, rough: PackInput | null, metal: PackInput | null, defaults = { ao: 255, rough: 255, metal: 0 }): Rgba => {
|
|
259
|
-
const srcs = [ ao, rough, metal ].filter((s): s is PackInput => !!s)
|
|
260
|
-
const w = Math.max(1, ...srcs.map((s) => s.img.width)), h = Math.max(1, ...srcs.map((s) => s.img.height))
|
|
261
|
-
const out = new Uint8Array(w * h * 4)
|
|
262
|
-
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
|
|
263
|
-
const d = (y * w + x) * 4
|
|
264
|
-
const get = (s: PackInput | null, dflt: number): number => { if (!s) return dflt; const v = sampleCh(s.img, s.channel, x, y, w, h); return s.invert ? 255 - v : v }
|
|
265
|
-
out[d] = get(ao, defaults.ao); out[d + 1] = get(rough, defaults.rough); out[d + 2] = get(metal, defaults.metal); out[d + 3] = 255
|
|
266
|
-
}
|
|
267
|
-
return { width: w, height: h, data: out }
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
// ---- slot vocabulary --------------------------------------------------------------------------------
|
|
271
|
-
|
|
272
|
-
// `_Main*` is the vertex-PAINT convention: a shadergraph that blends a base layer with per-vertex-
|
|
273
|
-
// colour layers (R/G/B channels) names the base one "Main". Nothing here can run the graph, so the
|
|
274
|
-
// main layer IS the material — far better than the white nothing those slot names used to produce.
|
|
275
|
-
const SLOTS = {
|
|
276
|
-
base: [ "_BaseMap", "_MainTex", "_BaseColor", "_BaseColorMap", "_BC", "_Albedo", "_AlbedoMap", "_Diffuse", "_DiffuseMap", "_Color", "_MainBC" ],
|
|
277
|
-
normal: [ "_BumpMap", "_NormalMap", "_Normal", "_NormalTex", "_MainNormal" ],
|
|
278
|
-
orm: [ "_ORM", "_ORMMap", "_OcclusionRoughnessMetallic", "_ORMTex", "_MainORM" ],
|
|
279
|
-
maskMap: [ "_MaskMap" ],
|
|
280
|
-
metallicGloss: [ "_MetallicGlossMap" ],
|
|
281
|
-
metallic: [ "_Metallic", "_MetallicMap", "_MetallicTex", "_MetalMap" ],
|
|
282
|
-
roughness: [ "_Roughness", "_RoughnessMap", "_RoughnessTex" ],
|
|
283
|
-
smoothness: [ "_Smoothness", "_SmoothnessMap", "_SmoothnessTex", "_Gloss", "_GlossMap" ],
|
|
284
|
-
occlusion: [ "_OcclusionMap", "_Occlusion", "_AO", "_AOMap", "_AmbientOcclusion" ],
|
|
285
|
-
emission: [ "_EmissionMap", "_Emission", "_Emissive", "_EmissiveMap", "_EmissiveColorMap" ],
|
|
286
|
-
alpha: [ "_Alpha", "_Opacity", "_OpacityMap", "_AlphaMap", "_OpacityMask" ],
|
|
287
|
-
}
|
|
288
|
-
const pick = (m: UnityMaterial, names: string[]): string | null => { for (const n of names) { const g = m.textures[n]; if (g) return g } return null }
|
|
289
|
-
|
|
290
24
|
export type ApplyUnityOptions = {
|
|
291
25
|
/** Folder(s) to look for `.mat` files in (default: the FBX's folder and its parent, recursively). */
|
|
292
26
|
materialDirs?: string[]
|
|
@@ -368,148 +102,7 @@ export const applyUnityMaterials = async (doc: Document, fbxPath: string, opts:
|
|
|
368
102
|
return null
|
|
369
103
|
}
|
|
370
104
|
|
|
371
|
-
const
|
|
372
|
-
const imgCache = new Map<string, Rgba | null>()
|
|
373
|
-
const loadBytes = (guid: string): { bytes: Uint8Array, file: string } | null => {
|
|
374
|
-
const f = guid.startsWith(PATH_GUID) ? guid.slice(PATH_GUID.length) : resolver!.resolve(guid)
|
|
375
|
-
if (!f) { warn({ level: "warn", message: `texture ${guid}: not in the index / not extracted — skipped` }); return null }
|
|
376
|
-
return { bytes: new Uint8Array(readFileSync(f)), file: f }
|
|
377
|
-
}
|
|
378
|
-
const texFromGuid = async (guid: string): Promise<Texture | null> => {
|
|
379
|
-
if (texCache.has(guid)) return texCache.get(guid)!
|
|
380
|
-
const src = loadBytes(guid)
|
|
381
|
-
let tex: Texture | null = null
|
|
382
|
-
if (src) {
|
|
383
|
-
const mime = sniffMime(src.bytes, src.file)
|
|
384
|
-
let data = src.bytes, outMime: "image/png" | "image/jpeg" = "image/png"
|
|
385
|
-
if (mime === "image/png" || mime === "image/jpeg") outMime = mime
|
|
386
|
-
else {
|
|
387
|
-
const img = await decodeImage(src.bytes, src.file)
|
|
388
|
-
if (!img) { warn({ level: "warn", message: `texture ${basename(src.file)}: unsupported format (${mime}) — skipped` }); texCache.set(guid, null); return null }
|
|
389
|
-
data = encodePng(img)
|
|
390
|
-
}
|
|
391
|
-
tex = doc.createTexture(basename(src.file).replace(/\.[^.]+$/, "")).setImage(data).setMimeType(outMime)
|
|
392
|
-
}
|
|
393
|
-
texCache.set(guid, tex)
|
|
394
|
-
return tex
|
|
395
|
-
}
|
|
396
|
-
const imgFromGuid = async (guid: string): Promise<Rgba | null> => {
|
|
397
|
-
if (imgCache.has(guid)) return imgCache.get(guid)!
|
|
398
|
-
const src = loadBytes(guid)
|
|
399
|
-
const img = src ? await decodeImage(src.bytes, src.file) : null
|
|
400
|
-
if (src && !img) warn({ level: "warn", message: `texture ${basename(src.file)}: could not decode for packing — skipped` })
|
|
401
|
-
imgCache.set(guid, img)
|
|
402
|
-
return img
|
|
403
|
-
}
|
|
404
|
-
const nameOf = (guid: string): string => { const f = guid.startsWith(PATH_GUID) ? guid.slice(PATH_GUID.length) : resolver!.resolve(guid); return f ? basename(f) : guid }
|
|
405
|
-
|
|
406
|
-
/** Rewrite one glTF material from a Unity material; returns the slot summary. */
|
|
407
|
-
const applyTo = async (gm: Material, um: UnityMaterial, how: string): Promise<string[]> => {
|
|
408
|
-
const slots: string[] = how === "name" ? [] : [ `(matched by ${how})` ]
|
|
409
|
-
if (um.shader === "binary") slots.push("(binary .mat — slots inferred from sibling textures)")
|
|
410
|
-
// base colour (+ the FBX's base when the .mat has none — keep whatever is there)
|
|
411
|
-
const baseG = pick(um, SLOTS.base)
|
|
412
|
-
if (baseG) { const t = await texFromGuid(baseG); if (t) { gm.setBaseColorTexture(t); slots.push(`base=${nameOf(baseG)}`) } }
|
|
413
|
-
const baseColor = um.colors._BaseColor ?? um.colors._Color
|
|
414
|
-
if (baseColor) gm.setBaseColorFactor([ baseColor[0], baseColor[1], baseColor[2], gm.getBaseColorFactor()[3] ])
|
|
415
|
-
const nG = pick(um, SLOTS.normal)
|
|
416
|
-
if (nG) { const t = await texFromGuid(nG); if (t) { gm.setNormalTexture(t); gm.setNormalScale(um.floats._BumpScale ?? 1); slots.push(`normal=${nameOf(nG)}`) } }
|
|
417
|
-
|
|
418
|
-
// metallic / roughness / occlusion
|
|
419
|
-
const ormG = pick(um, SLOTS.orm), maskG = pick(um, SLOTS.maskMap), mgG = pick(um, SLOTS.metallicGloss)
|
|
420
|
-
const metG = pick(um, SLOTS.metallic), roughG = pick(um, SLOTS.roughness), smoothG = pick(um, SLOTS.smoothness), aoG = pick(um, SLOTS.occlusion)
|
|
421
|
-
// A shader's SCALE on top of the maps. glTF's metallicFactor / roughnessFactor multiply their
|
|
422
|
-
// texture exactly the way these do, so they carry over one-to-one — and dropping them is not a
|
|
423
|
-
// cosmetic loss: KINEMATION's weapon shader ships `_RoughnessScale: 0.65`, so a gun imported
|
|
424
|
-
// without it renders 54 % rougher than the artist set it, which reads as plastic instead of
|
|
425
|
-
// gunmetal. Every weapon in that pack sets 0.6-0.8; only characters leave it at 1.
|
|
426
|
-
// Names differ per shader, so accept the ones seen in the wild.
|
|
427
|
-
const scaleOf = (...keys: string[]): number => {
|
|
428
|
-
for (const k of keys) { const v = um.floats[k]; if (v !== undefined && v >= 0 && v <= 1) return v }
|
|
429
|
-
return 1
|
|
430
|
-
}
|
|
431
|
-
const roughScale = scaleOf("_RoughnessScale", "_RoughnessMultiplier", "_RoughnessStrength")
|
|
432
|
-
const metalScale = scaleOf("_MetallicScale", "_MetallicMultiplier", "_MetallicStrength")
|
|
433
|
-
const setMr = (t: Texture, withAo: boolean, label: string): void => {
|
|
434
|
-
gm.setMetallicRoughnessTexture(t).setMetallicFactor(metalScale).setRoughnessFactor(roughScale)
|
|
435
|
-
if (withAo) gm.setOcclusionTexture(t).setOcclusionStrength(um.floats._OcclusionStrength ?? 1)
|
|
436
|
-
if (roughScale !== 1 || metalScale !== 1) {
|
|
437
|
-
slots.push(`scale metallic=${metalScale} roughness=${roughScale}`)
|
|
438
|
-
}
|
|
439
|
-
slots.push(label)
|
|
440
|
-
}
|
|
441
|
-
if (ormG) {
|
|
442
|
-
const t = await texFromGuid(ormG)
|
|
443
|
-
if (t) setMr(t, true, `orm=${nameOf(ormG)}`)
|
|
444
|
-
} else if (maskG || mgG || metG || roughG || smoothG || aoG) {
|
|
445
|
-
// pack: R = AO, G = roughness, B = metallic
|
|
446
|
-
let ao: PackInput | null = null, rough: PackInput | null = null, metal: PackInput | null = null
|
|
447
|
-
const parts: string[] = []
|
|
448
|
-
if (maskG) { const img = await imgFromGuid(maskG); if (img) { metal = { img, channel: 0 }; ao = { img, channel: 1 }; rough = { img, channel: 3, invert: true }; parts.push(`MaskMap ${nameOf(maskG)}`) } }
|
|
449
|
-
if (mgG) { const img = await imgFromGuid(mgG); if (img) { metal = { img, channel: 0 }; rough = { img, channel: 3, invert: true }; parts.push(`MetallicGloss ${nameOf(mgG)}`) } }
|
|
450
|
-
if (metG) { const img = await imgFromGuid(metG); if (img) { metal = { img, channel: 0 }; parts.push(`metallic ${nameOf(metG)}`) } }
|
|
451
|
-
if (roughG) { const img = await imgFromGuid(roughG); if (img) { rough = { img, channel: 0 }; parts.push(`roughness ${nameOf(roughG)}`) } }
|
|
452
|
-
else if (smoothG) { const img = await imgFromGuid(smoothG); if (img) { rough = { img, channel: 0, invert: true }; parts.push(`smoothness ${nameOf(smoothG)}`) } }
|
|
453
|
-
if (aoG) { const img = await imgFromGuid(aoG); if (img) { ao = { img, channel: 0 }; parts.push(`ao ${nameOf(aoG)}`) } }
|
|
454
|
-
if (metal || rough || ao) {
|
|
455
|
-
// scalar fallbacks for the channels no map covers
|
|
456
|
-
const smooth = um.floats._Smoothness ?? um.floats._Glossiness
|
|
457
|
-
const dflt = { ao: 255, rough: Math.round((1 - (smooth ?? 0.5)) * 255), metal: Math.round((um.floats._Metallic ?? 0) * 255) }
|
|
458
|
-
const packed = packOrm(ao, rough, metal, dflt)
|
|
459
|
-
const t = doc.createTexture(`${um.name}_ORM`).setImage(encodePng(packed)).setMimeType("image/png")
|
|
460
|
-
setMr(t, !!ao, `packed ORM ← ${parts.join(" + ")}`)
|
|
461
|
-
}
|
|
462
|
-
} else {
|
|
463
|
-
// no maps: scalar metallic / roughness from the material
|
|
464
|
-
if (um.floats._Metallic !== undefined) gm.setMetallicFactor(um.floats._Metallic)
|
|
465
|
-
const smooth = um.floats._Smoothness ?? um.floats._Glossiness
|
|
466
|
-
if (smooth !== undefined) gm.setRoughnessFactor(1 - smooth)
|
|
467
|
-
if (um.floats._Metallic !== undefined || smooth !== undefined) slots.push(`metallic=${gm.getMetallicFactor().toFixed(2)} roughness=${gm.getRoughnessFactor().toFixed(2)}`)
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
// emission: only when the material actually emits (keyword or a non-black colour)
|
|
471
|
-
const emG = pick(um, SLOTS.emission)
|
|
472
|
-
// `_EmissiveColor` FIRST: HDRP's Lit shader emits by that one and leaves the Standard shader's
|
|
473
|
-
// `_EmissionColor` sitting in the property block at white, so reading `_EmissionColor` made every
|
|
474
|
-
// surface of an HDRP pack a self-lit white card — a whole imported level rendered as blank
|
|
475
|
-
// cardboard. URP / Standard materials carry no `_EmissiveColor`, so they are unaffected.
|
|
476
|
-
const emC = um.colors._EmissiveColor ?? um.colors._EmissionColor
|
|
477
|
-
// An explicitly BLACK _EmissionColor is a veto, not a missing value: Unity multiplies the
|
|
478
|
-
// emission map by that colour, so black means the surface does not emit however many maps are
|
|
479
|
-
// bound. Without this the "a map and no keywords, so assume it emits" fallback below fires on
|
|
480
|
-
// custom-shader materials that keep a texture in the slot and switch it off with the colour —
|
|
481
|
-
// which lit a whole rifle body through a magazine's emission map.
|
|
482
|
-
const emissionVetoed = emC !== undefined && emC[0] + emC[1] + emC[2] <= 0.01
|
|
483
|
-
const emits = um.keywords.includes("_EMISSION")
|
|
484
|
-
|| (emC ? emC[0] + emC[1] + emC[2] > 0.01 : false)
|
|
485
|
-
|| (!!emG && !um.keywords.length && !emissionVetoed)
|
|
486
|
-
if (emG && emits) {
|
|
487
|
-
const t = await texFromGuid(emG)
|
|
488
|
-
if (t) { gm.setEmissiveTexture(t).setEmissiveFactor(emC && emC[0] + emC[1] + emC[2] > 0.01 ? [ Math.min(1, emC[0]), Math.min(1, emC[1]), Math.min(1, emC[2]) ] : [ 1, 1, 1 ]); slots.push(`emissive=${nameOf(emG)}`) }
|
|
489
|
-
} else if (emC && emits && !emG) gm.setEmissiveFactor([ Math.min(1, emC[0]), Math.min(1, emC[1]), Math.min(1, emC[2]) ])
|
|
490
|
-
else if (!emits) {
|
|
491
|
-
// The Unity material is the authority and it says this surface does NOT emit — so CLEAR what
|
|
492
|
-
// the FBX bound (toDocument.ts binds fbx emissiveTex with factor 1,1,1 whenever the mesh file
|
|
493
|
-
// names one). DCC exports routinely carry an emission slot the Unity material later overrode
|
|
494
|
-
// to none; leaving it produced a rifle body glowing through a magazine's emission map, which
|
|
495
|
-
// only became visible once the textures stopped being mushy.
|
|
496
|
-
if (gm.getEmissiveTexture()) { gm.setEmissiveTexture(null); slots.push("emissive=off") }
|
|
497
|
-
const ef = gm.getEmissiveFactor()
|
|
498
|
-
if (ef[0] + ef[1] + ef[2] > 0) gm.setEmissiveFactor([ 0, 0, 0 ])
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
// alpha: URP `_Surface` 1 = transparent, `_AlphaClip` 1 = cutout; Standard `_Mode` 1 cutout, 2/3 blend
|
|
502
|
-
const mode = um.floats._Mode, surface = um.floats._Surface, clip = um.floats._AlphaClip
|
|
503
|
-
const cutout = clip === 1 || mode === 1 || um.keywords.includes("_ALPHATEST_ON")
|
|
504
|
-
const blend = surface === 1 || mode === 2 || mode === 3 || um.keywords.includes("_ALPHABLEND_ON") || um.keywords.includes("_ALPHAPREMULTIPLY_ON")
|
|
505
|
-
if (cutout) { gm.setAlphaMode("MASK").setAlphaCutoff(um.floats._Cutoff ?? 0.5); slots.push("cutout") }
|
|
506
|
-
else if (blend) { gm.setAlphaMode("BLEND"); slots.push("blend") }
|
|
507
|
-
else if (gm.getAlphaMode() !== "OPAQUE" && !pick(um, SLOTS.alpha)) gm.setAlphaMode("OPAQUE")
|
|
508
|
-
const cull = um.floats._Cull
|
|
509
|
-
if (cull === 0) gm.setDoubleSided(true)
|
|
510
|
-
|
|
511
|
-
return slots
|
|
512
|
-
}
|
|
105
|
+
const { applyTo } = unityMaterialWriter(doc, resolver, warn)
|
|
513
106
|
|
|
514
107
|
const appliedFrom = new Map<Material, string>()
|
|
515
108
|
for (const gm of mats) {
|
|
@@ -532,15 +125,28 @@ export const applyUnityMaterials = async (doc: Document, fbxPath: string, opts:
|
|
|
532
125
|
for (const o of overrides) if (!byNode.has(o.node)) byNode.set(o.node, o)
|
|
533
126
|
const matCache = new Map<string, Material>()
|
|
534
127
|
// the FBX importer hangs the mesh on an unnamed child of the named node — take the subtree
|
|
535
|
-
const meshesUnder = (n:
|
|
536
|
-
const out:
|
|
537
|
-
const walk = (x:
|
|
128
|
+
const meshesUnder = (n: Node): Mesh[] => {
|
|
129
|
+
const out: Mesh[] = []
|
|
130
|
+
const walk = (x: Node): void => { const m = x.getMesh(); if (m) out.push(m); x.listChildren().forEach(walk) }
|
|
538
131
|
walk(n)
|
|
539
132
|
return out
|
|
540
133
|
}
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
134
|
+
const targets: [ Node, PrefabOverride ][] = []
|
|
135
|
+
for (const node of doc.getRoot().listNodes()) { const o = byNode.get(node.getName()); if (o) targets.push([ node, o ]) }
|
|
136
|
+
// a SINGLE-mesh prop whose prefab GameObject is named after the prefab, not the FBX node
|
|
137
|
+
// (`P_stone_fence` showing `stone_fence_LP.014`): no name matches, but the prefab names THIS FBX
|
|
138
|
+
// by GUID and there is only one mesh it could mean. The prefab named like the FBX wins over
|
|
139
|
+
// its variants (`P_Tent_1` over `P_Tent_1B`).
|
|
140
|
+
if (!targets.length && fbxGuid) {
|
|
141
|
+
const meshNodes = doc.getRoot().listNodes().filter((n) => n.getMesh())
|
|
142
|
+
const mine = overrides.filter((o) => o.meshGuid === fbxGuid)
|
|
143
|
+
if (meshNodes.length === 1 && mine.length) {
|
|
144
|
+
const stem = basename(fbxPath).replace(/\.fbx$/i, "").toLowerCase()
|
|
145
|
+
const o = mine.find((x) => basename(x.prefab, ".prefab").toLowerCase().replace(/^p_/, "") === stem) ?? mine[0]!
|
|
146
|
+
targets.push([ meshNodes[0]!, o ])
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
for (const [ node, o ] of targets) {
|
|
544
150
|
const prims = meshesUnder(node).flatMap((m) => m.listPrimitives())
|
|
545
151
|
for (let i = 0; i < prims.length; i++) {
|
|
546
152
|
const g = o.materials[i]
|
|
@@ -590,4 +196,3 @@ export const describeUnityReport = (r: ApplyUnityReport): string[] => {
|
|
|
590
196
|
return lines
|
|
591
197
|
}
|
|
592
198
|
|
|
593
|
-
export type { Material }
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// The Unity importer's linear algebra: column-major 4×4 matrices, (x, y, z, w) quaternions, and the
|
|
2
|
+
// ONE frame conversion everything here shares.
|
|
3
|
+
//
|
|
4
|
+
// Frame: Unity is left-handed (+Z forward); glTF / LeCodes are right-handed. Unity's FBX importer
|
|
5
|
+
// mirrors X, so undoing it is a conjugation by S = diag(−1, 1, 1): position (−x, y, z), quaternion
|
|
6
|
+
// (x, −y, −z, w), scale unchanged. It is applied to placements AND inside kinds (part matrices), so a
|
|
7
|
+
// kind's meshes — the FBX's own geometry, which is Unity's mesh mirrored back — land where Unity
|
|
8
|
+
// drew them.
|
|
9
|
+
|
|
10
|
+
import type { Quat, Vec3 } from "./yaml"
|
|
11
|
+
|
|
12
|
+
export type Mat4 = number[] // column-major
|
|
13
|
+
export type TRS = { t: Vec3, r: Quat, s: Vec3 }
|
|
14
|
+
|
|
15
|
+
export const I4 = (): Mat4 => [ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 ]
|
|
16
|
+
|
|
17
|
+
export const mmul = (a: Mat4, b: Mat4): Mat4 => {
|
|
18
|
+
const o = new Array(16).fill(0)
|
|
19
|
+
for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++) o[c * 4 + r] = a[r] * b[c * 4] + a[4 + r] * b[c * 4 + 1] + a[8 + r] * b[c * 4 + 2] + a[12 + r] * b[c * 4 + 3]
|
|
20
|
+
return o
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const scaleMatrix = (s: number): Mat4 => [ s, 0, 0, 0, 0, s, 0, 0, 0, 0, s, 0, 0, 0, 0, 1 ]
|
|
24
|
+
|
|
25
|
+
export const trsMatrix = (t: Vec3, q: Quat, s: Vec3): Mat4 => {
|
|
26
|
+
const [ x, y, z, w ] = q
|
|
27
|
+
const xx = x * x, yy = y * y, zz = z * z, xy = x * y, xz = x * z, yz = y * z, wx = w * x, wy = w * y, wz = w * z
|
|
28
|
+
return [
|
|
29
|
+
(1 - 2 * (yy + zz)) * s[0], 2 * (xy + wz) * s[0], 2 * (xz - wy) * s[0], 0,
|
|
30
|
+
2 * (xy - wz) * s[1], (1 - 2 * (xx + zz)) * s[1], 2 * (yz + wx) * s[1], 0,
|
|
31
|
+
2 * (xz + wy) * s[2], 2 * (yz - wx) * s[2], (1 - 2 * (xx + yy)) * s[2], 0,
|
|
32
|
+
t[0], t[1], t[2], 1,
|
|
33
|
+
]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Conjugate by the X mirror: S·M·S with S = diag(−1, 1, 1). */
|
|
37
|
+
export const mirrorX = (m: Mat4): Mat4 => m.map((v, i) => ((Math.floor(i / 4) === 0) !== (i % 4 === 0) ? -v : v))
|
|
38
|
+
|
|
39
|
+
/** The same conjugation on a quaternion and on a position. */
|
|
40
|
+
export const mirrorQuat = (q: number[]): Quat => [ q[0], -q[1], -q[2], q[3] ]
|
|
41
|
+
export const mirrorPos = (p: number[]): Vec3 => [ -p[0], p[1], p[2] ]
|
|
42
|
+
|
|
43
|
+
export const decompose = (m: Mat4): TRS => {
|
|
44
|
+
const sx = Math.hypot(m[0], m[1], m[2]), sy = Math.hypot(m[4], m[5], m[6]), sz0 = Math.hypot(m[8], m[9], m[10])
|
|
45
|
+
const det = m[0] * (m[5] * m[10] - m[6] * m[9]) - m[4] * (m[1] * m[10] - m[2] * m[9]) + m[8] * (m[1] * m[6] - m[2] * m[5])
|
|
46
|
+
const sz = det < 0 ? -sz0 : sz0
|
|
47
|
+
const r00 = m[0] / sx, r10 = m[1] / sx, r20 = m[2] / sx, r01 = m[4] / sy, r11 = m[5] / sy, r21 = m[6] / sy, r02 = m[8] / sz, r12 = m[9] / sz, r22 = m[10] / sz
|
|
48
|
+
const tr = r00 + r11 + r22
|
|
49
|
+
let q: Quat
|
|
50
|
+
if (tr > 0) { const s = Math.sqrt(tr + 1) * 2; q = [ (r21 - r12) / s, (r02 - r20) / s, (r10 - r01) / s, s / 4 ] }
|
|
51
|
+
else if (r00 > r11 && r00 > r22) { const s = Math.sqrt(1 + r00 - r11 - r22) * 2; q = [ s / 4, (r01 + r10) / s, (r02 + r20) / s, (r21 - r12) / s ] }
|
|
52
|
+
else if (r11 > r22) { const s = Math.sqrt(1 + r11 - r00 - r22) * 2; q = [ (r01 + r10) / s, s / 4, (r12 + r21) / s, (r02 - r20) / s ] }
|
|
53
|
+
else { const s = Math.sqrt(1 + r22 - r00 - r11) * 2; q = [ (r02 + r20) / s, (r12 + r21) / s, s / 4, (r10 - r01) / s ] }
|
|
54
|
+
const l = Math.hypot(...q) || 1
|
|
55
|
+
return { t: [ m[12], m[13], m[14] ], r: q.map((v) => v / l) as Quat, s: [ sx, sy, sz ] }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Hamilton product a · b (apply b, then a). */
|
|
59
|
+
export const qmul = (a: number[], b: number[]): Quat => [
|
|
60
|
+
a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1],
|
|
61
|
+
a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0],
|
|
62
|
+
a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3],
|
|
63
|
+
a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2],
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
const DEG = 180 / Math.PI
|
|
67
|
+
/** YXZ euler angles (DEGREES) from a unit quaternion — the SDK's `Node.eulerAngles` convention. */
|
|
68
|
+
export const eulerYXZ = (q: number[]): [number, number, number] => {
|
|
69
|
+
const [ x, y, z, w ] = q
|
|
70
|
+
const x2 = x + x, y2 = y + y, z2 = z + z
|
|
71
|
+
const xx = x * x2, xy = x * y2, xz = x * z2, yy = y * y2, yz = y * z2, zz = z * z2, wx = w * x2, wy = w * y2, wz = w * z2
|
|
72
|
+
const m13 = xz + wy, m21 = xy + wz, m22 = 1 - (xx + zz), m23 = yz - wx, m31 = xz - wy, m33 = 1 - (xx + yy), m11 = 1 - (yy + zz)
|
|
73
|
+
const ex = Math.asin(-Math.max(-1, Math.min(1, m23)))
|
|
74
|
+
let ey: number, ez: number
|
|
75
|
+
if (Math.abs(m23) < 0.9999999) { ey = Math.atan2(m13, m33); ez = Math.atan2(m21, m22) } else { ey = Math.atan2(-m31, m11); ez = 0 }
|
|
76
|
+
return [ ex * DEG, ey * DEG, ez * DEG ]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Rounded for output (and `+ 0` so a −0 never reaches a file). */
|
|
80
|
+
export const r6 = (v: number): number => Math.round(v * 1e6) / 1e6 + 0
|
|
81
|
+
export const r3 = (v: number): number => Math.round(v * 1000) / 1000 + 0
|
|
82
|
+
export const r2 = (v: number): number => Math.round(v * 100) / 100 + 0
|