lecodes-assets 0.2.0 → 2.4.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +738 -449
- package/dist/cli.js +19760 -8612
- package/dist/index.js +19334 -8167
- package/package.json +15 -3
- package/src/bvh/fromDocument.ts +136 -0
- package/src/bvh/toDocument.ts +202 -0
- package/src/cli-kit/command.ts +100 -0
- package/src/cli-kit/help.ts +72 -0
- package/src/cli-kit/index.ts +4 -0
- package/src/cli-kit/parse.ts +72 -0
- package/src/cli-kit/run.ts +52 -0
- package/src/cli.ts +24 -0
- package/src/commands/avatar.ts +77 -0
- package/src/commands/bvh.ts +26 -0
- package/src/commands/convert.ts +156 -0
- package/src/commands/doctor.ts +67 -0
- package/src/commands/geometry.ts +45 -0
- package/src/commands/index.ts +24 -0
- package/src/commands/marks.ts +38 -0
- package/src/commands/mirror.ts +35 -0
- package/src/commands/probe.ts +43 -0
- package/src/commands/retarget/donor.ts +63 -0
- package/src/commands/retarget/index.ts +169 -0
- package/src/commands/retarget/map.ts +81 -0
- package/src/commands/retarget/report.ts +77 -0
- package/src/commands/scene.ts +103 -0
- package/src/commands/shared.ts +121 -0
- package/src/commands/sky.ts +35 -0
- package/src/commands/terrainPack.ts +40 -0
- package/src/commands/unpack.ts +43 -0
- package/src/commands/unreal.ts +109 -0
- package/src/commands/unwrap.ts +56 -0
- package/src/doctor/cardTrim.ts +286 -0
- package/src/doctor/doctor.ts +305 -0
- package/src/doctor/lod.ts +177 -0
- package/src/doctor/merge/accessors.ts +30 -0
- package/src/doctor/merge/atlasLayout.ts +94 -0
- package/src/doctor/merge/atlasPixels.ts +114 -0
- package/src/doctor/merge/bindSpace.ts +59 -0
- package/src/doctor/merge/classify.ts +136 -0
- package/src/doctor/merge/geometry.ts +167 -0
- package/src/doctor/merge/index.ts +98 -0
- package/src/doctor/merge/matrix.ts +60 -0
- package/src/doctor/merge/plan.ts +88 -0
- package/src/doctor/merge/textures.ts +60 -0
- package/src/doctor/merge/types.ts +77 -0
- package/src/doctor/normalizeScale.ts +220 -0
- package/src/doctor/reduceGlbBones.ts +218 -0
- package/src/doctor/shading.ts +95 -0
- package/src/doctor/transcodeKtx2.ts +275 -0
- package/src/doctor/transcodeTextures.ts +101 -0
- package/src/doctor/validateGlb.ts +264 -0
- package/src/doctor/vertexAo.ts +321 -0
- package/src/fbx/convert.ts +327 -0
- package/src/fbx/read.ts +111 -0
- package/src/fbx/toDocument.ts +227 -0
- package/src/filmstrip.ts +263 -0
- package/src/geometry.ts +222 -0
- package/src/index.ts +19 -0
- package/src/mergeSkins.ts +117 -0
- package/src/probe.ts +167 -0
- package/src/props.ts +137 -0
- package/src/retarget/automap.ts +276 -0
- package/src/retarget/ghost.ts +23 -0
- package/src/retarget/heading.ts +238 -0
- package/src/retarget/humanoid.ts +572 -0
- package/src/retarget/inPlace.ts +130 -0
- package/src/retarget/marks.ts +581 -0
- package/src/retarget/mirror.ts +0 -0
- package/src/retarget/retarget.ts +2375 -0
- package/src/retarget/rig.ts +545 -0
- package/src/retarget/unityAnim.ts +171 -0
- package/src/retarget/writeMarks.ts +134 -0
- package/src/sky/analyze.ts +136 -0
- package/src/sky/cmgen.ts +71 -0
- package/src/sky/index.ts +200 -0
- package/src/terrainPack.ts +208 -0
- package/src/textures/resolveImage.ts +202 -0
- package/src/unity/colliderFit.ts +287 -0
- package/src/unity/fbxModel.ts +194 -0
- package/src/unity/foliage.ts +63 -0
- package/src/unity/gameObjects.ts +81 -0
- package/src/unity/guid.ts +104 -0
- package/src/unity/image.ts +58 -0
- package/src/unity/kindGlb.ts +364 -0
- package/src/unity/lecodesLevel.ts +381 -0
- package/src/unity/levelTerrain.ts +116 -0
- package/src/unity/matFile.ts +179 -0
- package/src/unity/materialSlots.ts +53 -0
- package/src/unity/materialWriter.ts +306 -0
- package/src/unity/materials.ts +198 -0
- package/src/unity/math.ts +82 -0
- package/src/unity/png.ts +75 -0
- package/src/unity/prefabTree.ts +285 -0
- package/src/unity/prefabs.ts +54 -0
- package/src/unity/propertyPath.ts +43 -0
- package/src/unity/scene.ts +384 -0
- package/src/unity/serialized.ts +242 -0
- package/src/unity/terrain.ts +221 -0
- package/src/unity/triBudget.ts +77 -0
- package/src/unity/triplanar.ts +122 -0
- package/src/unity/unityMesh.ts +151 -0
- package/src/unity/unpack.ts +149 -0
- package/src/unity/yaml.ts +131 -0
- package/src/unreal/anim.ts +284 -0
- package/src/unreal/clipSource.ts +25 -0
- package/src/unreal/convert.ts +67 -0
- package/src/unreal/convertAsset.ts +81 -0
- package/src/unreal/index.ts +115 -0
- package/src/unreal/list.ts +113 -0
- package/src/unreal/materialGraph.ts +224 -0
- package/src/unreal/materials.ts +399 -0
- package/src/unreal/mesh.ts +205 -0
- package/src/unreal/oodle.ts +153 -0
- package/src/unreal/package.ts +677 -0
- package/src/unreal/probe.ts +144 -0
- package/src/unreal/resolve.ts +53 -0
- package/src/unreal/rig.ts +149 -0
- package/src/unreal/sequencer.ts +193 -0
- package/src/unreal/skeleton.ts +74 -0
- package/src/unreal/sound.ts +128 -0
- package/src/unreal/texture.ts +269 -0
- package/src/unreal/toDocument.ts +367 -0
- package/src/unreal/versions.ts +108 -0
- package/src/unwrap/index.ts +167 -0
- package/src/unwrap/lightmapUv.ts +761 -0
- package/src/wasm/fbxReader.js +14 -0
- package/src/wasm/lightmapUv.js +14 -0
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// DOES A PACK'S BOX COLLIDER SIT ON THE GEOMETRY IT COVERS?
|
|
2
|
+
//
|
|
3
|
+
// A Unity pack authors its own colliders, and for anything but a crate they are an approximation:
|
|
4
|
+
// the Modular Shooting Range's wall module is flat at ±0.100 m and only its edge trim reaches the
|
|
5
|
+
// ±0.138 m its box collider was fitted to, so the box's face floats 3.8 cm in front of the wall the
|
|
6
|
+
// player sees. Everything that lands ON the hit point then lands in mid-air: a bullet stops short,
|
|
7
|
+
// its impact particles hang off the surface, and a projected decal (fps-demo's bullet holes, box
|
|
8
|
+
// depth ±3 cm) never reaches the wall at all — the hole is simply not there. The same pack's TABLE,
|
|
9
|
+
// by contrast, carries five hand-placed boxes that hug the top, the side panels and the brackets,
|
|
10
|
+
// and swapping those for a mesh would buy nothing.
|
|
11
|
+
//
|
|
12
|
+
// So the verdict is per KIND and it is measured, not guessed: shoot a grid of rays from each box
|
|
13
|
+
// face inward and look at what they meet.
|
|
14
|
+
//
|
|
15
|
+
// • gap — the median distance from the face to the first surface under it. The face is a lie
|
|
16
|
+
// by this much: 0 on a crate, 0.038 on that wall module.
|
|
17
|
+
// • covered — the share of the face's samples that meet anything at all inside the box. A face
|
|
18
|
+
// over a void (the ceiling-panel box, 6 m of air under its underside) is the worst
|
|
19
|
+
// case of the same fault, so it counts as a miss whatever its gap.
|
|
20
|
+
//
|
|
21
|
+
// A kind whose every box passes keeps its boxes (cheap, and a convex box is the better collider for
|
|
22
|
+
// a character to slide along); one box failing sends the whole kind to a mesh collider, since a mix
|
|
23
|
+
// would collide twice. A mesh shape is built once per GLB and shared by every placement
|
|
24
|
+
// (creator-gl physicsBuildMeshFromEntity caches per asset), so the cost is per kind, not per copy.
|
|
25
|
+
|
|
26
|
+
/** One box as the prefab file will spell it: the node's frame plus the Shape's own half extents and origin. */
|
|
27
|
+
export type FitBox = {
|
|
28
|
+
/** Shape `box` — half extents along the box's own axes. */
|
|
29
|
+
half: [number, number, number]
|
|
30
|
+
/** Shape `origin` — the box's centre in the node's frame. */
|
|
31
|
+
origin: [number, number, number]
|
|
32
|
+
/** The collider node's translation in the kind's frame. */
|
|
33
|
+
t: [number, number, number]
|
|
34
|
+
/** The collider node's rotation (quaternion xyzw). */
|
|
35
|
+
rot: [number, number, number, number]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type FitTriangles = { positions: Float32Array, indices: Uint32Array }
|
|
39
|
+
|
|
40
|
+
export type FaceFit = {
|
|
41
|
+
/** "x+", "y-", … — which face of which box (`box` is its index in the kind's list). */
|
|
42
|
+
face: string
|
|
43
|
+
box: number
|
|
44
|
+
/** Metres from the face to the surface under it (median over the samples that met one). */
|
|
45
|
+
gap: number
|
|
46
|
+
/** 0…1 share of the face's samples that met a surface inside the box. */
|
|
47
|
+
covered: number
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type FitVerdict = {
|
|
51
|
+
/** true = the boxes lie, give the kind a mesh collider. */
|
|
52
|
+
mesh: boolean
|
|
53
|
+
/** The face that decided it (the worst one), or null when there was nothing to measure. */
|
|
54
|
+
worst: FaceFit | null
|
|
55
|
+
faces: FaceFit[]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type FitOptions = {
|
|
59
|
+
/** Metres a box face may stand off the surface under it (default 0.015). */
|
|
60
|
+
tolerance?: number
|
|
61
|
+
/** Least share of a face's samples that must meet a surface (default 0.25). */
|
|
62
|
+
minCovered?: number
|
|
63
|
+
/** Rays per face edge (default 8 → 64 samples a face). */
|
|
64
|
+
samples?: number
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const EPS = 1e-9
|
|
68
|
+
|
|
69
|
+
/** Rotate `v` by the quaternion `q` (xyzw). */
|
|
70
|
+
const rotate = (q: readonly number[], v: readonly number[]): [number, number, number] => {
|
|
71
|
+
const [ x, y, z, w ] = q
|
|
72
|
+
const tx = 2 * (y * v[2] - z * v[1]), ty = 2 * (z * v[0] - x * v[2]), tz = 2 * (x * v[1] - y * v[0])
|
|
73
|
+
return [
|
|
74
|
+
v[0] + w * tx + (y * tz - z * ty),
|
|
75
|
+
v[1] + w * ty + (z * tx - x * tz),
|
|
76
|
+
v[2] + w * tz + (x * ty - y * tx),
|
|
77
|
+
]
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** …by its inverse. */
|
|
81
|
+
const unrotate = (q: readonly number[], v: readonly number[]): [number, number, number] =>
|
|
82
|
+
rotate([ -q[0], -q[1], -q[2], q[3] ], v)
|
|
83
|
+
|
|
84
|
+
/** Möller–Trumbore: the ray's distance to the triangle, or -1. Both sides count — a one-sided test
|
|
85
|
+
* would miss a wall whose back face is the one inside the box. */
|
|
86
|
+
const rayTriangle = (
|
|
87
|
+
ox: number, oy: number, oz: number, dx: number, dy: number, dz: number,
|
|
88
|
+
ax: number, ay: number, az: number, bx: number, by: number, bz: number, cx: number, cy: number, cz: number,
|
|
89
|
+
): number => {
|
|
90
|
+
const e1x = bx - ax, e1y = by - ay, e1z = bz - az
|
|
91
|
+
const e2x = cx - ax, e2y = cy - ay, e2z = cz - az
|
|
92
|
+
const px = dy * e2z - dz * e2y, py = dz * e2x - dx * e2z, pz = dx * e2y - dy * e2x
|
|
93
|
+
const det = e1x * px + e1y * py + e1z * pz
|
|
94
|
+
if (det > -EPS && det < EPS) return -1
|
|
95
|
+
const inv = 1 / det
|
|
96
|
+
const tx = ox - ax, ty = oy - ay, tz = oz - az
|
|
97
|
+
const u = (tx * px + ty * py + tz * pz) * inv
|
|
98
|
+
if (u < -1e-6 || u > 1 + 1e-6) return -1
|
|
99
|
+
const qx = ty * e1z - tz * e1y, qy = tz * e1x - tx * e1z, qz = tx * e1y - ty * e1x
|
|
100
|
+
const v = (dx * qx + dy * qy + dz * qz) * inv
|
|
101
|
+
if (v < -1e-6 || u + v > 1 + 1e-6) return -1
|
|
102
|
+
return (e2x * qx + e2y * qy + e2z * qz) * inv
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** How well one box covers the geometry: a FaceFit per face, worst first. */
|
|
106
|
+
export const boxFit = (tris: FitTriangles, box: FitBox, opts: FitOptions = {}): FaceFit[] => {
|
|
107
|
+
const n = Math.max(2, opts.samples ?? 8)
|
|
108
|
+
const centre: [number, number, number] = [
|
|
109
|
+
box.t[0] + rotate(box.rot, box.origin)[0],
|
|
110
|
+
box.t[1] + rotate(box.rot, box.origin)[1],
|
|
111
|
+
box.t[2] + rotate(box.rot, box.origin)[2],
|
|
112
|
+
]
|
|
113
|
+
// the triangles in the BOX's frame, keeping only those that reach inside it (a 1 cm skin, so a
|
|
114
|
+
// surface sitting exactly on a face still counts)
|
|
115
|
+
const h = box.half
|
|
116
|
+
const keep: number[] = []
|
|
117
|
+
const P = tris.positions, I = tris.indices
|
|
118
|
+
const v = new Float32Array(9)
|
|
119
|
+
for (let i = 0; i + 2 < I.length; i += 3) {
|
|
120
|
+
let inside = false
|
|
121
|
+
const lo = [ Infinity, Infinity, Infinity ], hi = [ -Infinity, -Infinity, -Infinity ]
|
|
122
|
+
for (let k = 0; k < 3; k++) {
|
|
123
|
+
const j = I[i + k] * 3
|
|
124
|
+
const p = unrotate(box.rot, [ P[j] - centre[0], P[j + 1] - centre[1], P[j + 2] - centre[2] ])
|
|
125
|
+
v[k * 3] = p[0]; v[k * 3 + 1] = p[1]; v[k * 3 + 2] = p[2]
|
|
126
|
+
for (let a = 0; a < 3; a++) { lo[a] = Math.min(lo[a], p[a]); hi[a] = Math.max(hi[a], p[a]) }
|
|
127
|
+
}
|
|
128
|
+
inside = [ 0, 1, 2 ].every((a) => hi[a] >= -h[a] - 0.01 && lo[a] <= h[a] + 0.01)
|
|
129
|
+
if (inside) for (let k = 0; k < 9; k++) keep.push(v[k])
|
|
130
|
+
}
|
|
131
|
+
const T = Float32Array.from(keep)
|
|
132
|
+
|
|
133
|
+
const out: FaceFit[] = []
|
|
134
|
+
for (let axis = 0; axis < 3; axis++) {
|
|
135
|
+
for (const sign of [ 1, -1 ] as const) {
|
|
136
|
+
const u = (axis + 1) % 3, w = (axis + 2) % 3
|
|
137
|
+
const depth = 2 * h[axis]
|
|
138
|
+
if (depth <= 0) continue
|
|
139
|
+
const hits: number[] = []
|
|
140
|
+
let total = 0
|
|
141
|
+
for (let a = 0; a < n; a++) {
|
|
142
|
+
for (let b = 0; b < n; b++) {
|
|
143
|
+
// inset, so a ray does not graze the box's own rim
|
|
144
|
+
const fu = ((a + 0.5) / n - 0.5) * 2 * 0.9, fw = ((b + 0.5) / n - 0.5) * 2 * 0.9
|
|
145
|
+
const o = [ 0, 0, 0 ]
|
|
146
|
+
o[axis] = sign * h[axis]; o[u] = fu * h[u]; o[w] = fw * h[w]
|
|
147
|
+
const d = [ 0, 0, 0 ]; d[axis] = -sign
|
|
148
|
+
total++
|
|
149
|
+
let best = Infinity
|
|
150
|
+
for (let i = 0; i + 8 < T.length; i += 9) {
|
|
151
|
+
const t = rayTriangle(o[0], o[1], o[2], d[0], d[1], d[2],
|
|
152
|
+
T[i], T[i + 1], T[i + 2], T[i + 3], T[i + 4], T[i + 5], T[i + 6], T[i + 7], T[i + 8])
|
|
153
|
+
if (t >= -1e-4 && t < best && t <= depth + 1e-4) best = Math.max(0, t)
|
|
154
|
+
}
|
|
155
|
+
if (best < Infinity) hits.push(best)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
hits.sort((x, y) => x - y)
|
|
159
|
+
const gap = hits.length ? hits[Math.floor(hits.length / 2)] : depth
|
|
160
|
+
out.push({ face: "xyz"[axis] + (sign > 0 ? "+" : "-"), box: 0, gap, covered: total ? hits.length / total : 0 })
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return out
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The kind's verdict over all of its boxes. */
|
|
167
|
+
export const fitVerdict = (tris: FitTriangles | null, boxes: FitBox[], opts: FitOptions = {}): FitVerdict => {
|
|
168
|
+
const tol = opts.tolerance ?? 0.015
|
|
169
|
+
const minCovered = opts.minCovered ?? 0.25
|
|
170
|
+
if (!tris || tris.indices.length === 0 || boxes.length === 0) return { mesh: false, worst: null, faces: [] }
|
|
171
|
+
const faces: FaceFit[] = []
|
|
172
|
+
boxes.forEach((b, i) => { for (const f of boxFit(tris, b, opts)) faces.push({ ...f, box: i }) })
|
|
173
|
+
// the worst face: how badly it fails (a face over a void fails by its whole depth)
|
|
174
|
+
const bad = (f: FaceFit): boolean => f.covered < minCovered || f.gap > tol
|
|
175
|
+
const failing = faces.filter(bad)
|
|
176
|
+
failing.sort((a, b) => b.gap - a.gap)
|
|
177
|
+
return { mesh: failing.length > 0, worst: failing[0] ?? null, faces }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---- reading the geometry back out of a kind's GLB -------------------------------------------------
|
|
181
|
+
|
|
182
|
+
type GlbJson = {
|
|
183
|
+
nodes?: { name?: string, mesh?: number, children?: number[], matrix?: number[], translation?: number[], rotation?: number[], scale?: number[] }[]
|
|
184
|
+
meshes?: { primitives: { attributes: Record<string, number>, indices?: number, mode?: number }[] }[]
|
|
185
|
+
accessors?: { bufferView?: number, byteOffset?: number, componentType: number, count: number, type: string }[]
|
|
186
|
+
bufferViews?: { buffer: number, byteOffset?: number, byteLength: number, byteStride?: number }[]
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const mul = (A: number[], B: number[]): number[] => {
|
|
190
|
+
const M = new Array(16).fill(0)
|
|
191
|
+
for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++) {
|
|
192
|
+
let s = 0
|
|
193
|
+
for (let k = 0; k < 4; k++) s += A[k * 4 + r] * B[c * 4 + k]
|
|
194
|
+
M[c * 4 + r] = s
|
|
195
|
+
}
|
|
196
|
+
return M
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const trsMatrix = (t: number[], q: number[], s: number[]): number[] => {
|
|
200
|
+
const [ x, y, z, w ] = q
|
|
201
|
+
const m = [
|
|
202
|
+
(1 - 2 * (y * y + z * z)) * s[0], (2 * (x * y + z * w)) * s[0], (2 * (x * z - y * w)) * s[0], 0,
|
|
203
|
+
(2 * (x * y - z * w)) * s[1], (1 - 2 * (x * x + z * z)) * s[1], (2 * (y * z + x * w)) * s[1], 0,
|
|
204
|
+
(2 * (x * z + y * w)) * s[2], (2 * (y * z - x * w)) * s[2], (1 - 2 * (x * x + y * y)) * s[2], 0,
|
|
205
|
+
t[0], t[1], t[2], 1,
|
|
206
|
+
]
|
|
207
|
+
return m
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** The LOD0 triangles of a kind's GLB, in the file's own frame.
|
|
211
|
+
*
|
|
212
|
+
* LOD levels are left out (`<base>_LOD<n>`, the engine's own convention — creator-gl src/lod.cpp):
|
|
213
|
+
* they are coincident shells of the same surface and only the level 0 one is ever drawn. */
|
|
214
|
+
export const readGlbTriangles = (bytes: Uint8Array): FitTriangles | null => {
|
|
215
|
+
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
216
|
+
if (dv.getUint32(0, true) !== 0x46546c67) return null // "glTF"
|
|
217
|
+
let off = 12, json: GlbJson | null = null, bin: Uint8Array | null = null
|
|
218
|
+
while (off + 8 <= bytes.byteLength) {
|
|
219
|
+
const len = dv.getUint32(off, true), type = dv.getUint32(off + 4, true)
|
|
220
|
+
const chunk = bytes.subarray(off + 8, off + 8 + len)
|
|
221
|
+
if (type === 0x4e4f534a) json = JSON.parse(new TextDecoder().decode(chunk)) as GlbJson
|
|
222
|
+
else if (type === 0x004e4942) bin = chunk
|
|
223
|
+
off += 8 + len + ((4 - (len % 4)) % 4)
|
|
224
|
+
}
|
|
225
|
+
if (!json?.nodes || !json.meshes || !json.accessors || !bin) return null
|
|
226
|
+
const views = json.bufferViews ?? []
|
|
227
|
+
|
|
228
|
+
const read = (index: number, want: number): Float64Array | null => {
|
|
229
|
+
const a = json!.accessors![index]
|
|
230
|
+
if (a.bufferView === undefined) return null
|
|
231
|
+
const bv = views[a.bufferView]
|
|
232
|
+
const comps = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4 }[a.type] ?? 0
|
|
233
|
+
if (comps !== want) return null
|
|
234
|
+
const size = { 5120: 1, 5121: 1, 5122: 2, 5123: 2, 5125: 4, 5126: 4 }[a.componentType] ?? 0
|
|
235
|
+
if (!size) return null
|
|
236
|
+
const stride = bv.byteStride || size * comps
|
|
237
|
+
const base = (bv.byteOffset ?? 0) + (a.byteOffset ?? 0)
|
|
238
|
+
const view = new DataView(bin!.buffer, bin!.byteOffset, bin!.byteLength)
|
|
239
|
+
const out = new Float64Array(a.count * comps)
|
|
240
|
+
for (let i = 0; i < a.count; i++) {
|
|
241
|
+
for (let c = 0; c < comps; c++) {
|
|
242
|
+
const at = base + i * stride + c * size
|
|
243
|
+
out[i * comps + c] = a.componentType === 5126 ? view.getFloat32(at, true)
|
|
244
|
+
: a.componentType === 5125 ? view.getUint32(at, true)
|
|
245
|
+
: a.componentType === 5123 ? view.getUint16(at, true)
|
|
246
|
+
: a.componentType === 5122 ? view.getInt16(at, true)
|
|
247
|
+
: view.getUint8(at)
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return out
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const positions: number[] = []
|
|
254
|
+
const indices: number[] = []
|
|
255
|
+
const walk = (ni: number, parent: number[]): void => {
|
|
256
|
+
const nd = json!.nodes![ni]
|
|
257
|
+
const local = nd.matrix ?? trsMatrix(nd.translation ?? [ 0, 0, 0 ], nd.rotation ?? [ 0, 0, 0, 1 ], nd.scale ?? [ 1, 1, 1 ])
|
|
258
|
+
const world = mul(parent, local)
|
|
259
|
+
const lod = /_LOD(\d+)$/.exec(nd.name ?? "")
|
|
260
|
+
const skip = lod !== null && Number(lod[1]) > 0
|
|
261
|
+
if (nd.mesh !== undefined && !skip) {
|
|
262
|
+
for (const prim of json!.meshes![nd.mesh].primitives) {
|
|
263
|
+
if (prim.mode !== undefined && prim.mode !== 4) continue
|
|
264
|
+
const pos = prim.attributes.POSITION !== undefined ? read(prim.attributes.POSITION, 3) : null
|
|
265
|
+
if (!pos) continue
|
|
266
|
+
const first = positions.length / 3
|
|
267
|
+
for (let i = 0; i < pos.length; i += 3) {
|
|
268
|
+
const x = pos[i], y = pos[i + 1], z = pos[i + 2]
|
|
269
|
+
positions.push(
|
|
270
|
+
world[0] * x + world[4] * y + world[8] * z + world[12],
|
|
271
|
+
world[1] * x + world[5] * y + world[9] * z + world[13],
|
|
272
|
+
world[2] * x + world[6] * y + world[10] * z + world[14],
|
|
273
|
+
)
|
|
274
|
+
}
|
|
275
|
+
const idx = prim.indices !== undefined ? read(prim.indices, 1) : null
|
|
276
|
+
if (idx) for (let i = 0; i < idx.length; i++) indices.push(first + idx[i])
|
|
277
|
+
else for (let i = 0; i < pos.length / 3; i++) indices.push(first + i)
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
for (const c of nd.children ?? []) walk(c, world)
|
|
281
|
+
}
|
|
282
|
+
const child = new Set<number>()
|
|
283
|
+
for (const nd of json.nodes) for (const c of nd.children ?? []) child.add(c)
|
|
284
|
+
const I4 = [ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 ]
|
|
285
|
+
json.nodes.forEach((_, i) => { if (!child.has(i)) walk(i, I4) })
|
|
286
|
+
return { positions: Float32Array.from(positions), indices: Uint32Array.from(indices) }
|
|
287
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// A MODEL FILE AS A PREFAB. Prefabs and scenes instance an FBX directly (`m_SourcePrefab` = the
|
|
2
|
+
// FBX's GUID) and override its objects by fileID, so the importer needs the GameObject tree Unity's
|
|
3
|
+
// own model importer would have built from that file — shape, ids and transforms.
|
|
4
|
+
|
|
5
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
6
|
+
import { basename, extname } from "node:path"
|
|
7
|
+
import { readFbx, type FbxNode } from "../fbx/read"
|
|
8
|
+
import type { Tree, UComp, UGO } from "./gameObjects"
|
|
9
|
+
import { mirrorQuat } from "./math"
|
|
10
|
+
import { isMap, num, parseUnityYaml, ref, str, type YMap } from "./yaml"
|
|
11
|
+
|
|
12
|
+
// Unity names a model's objects in the FBX's `.meta` (`fileIDToRecycleName` — classID·100000 + 2n —
|
|
13
|
+
// or, since 2020, `internalIDToNameTable`), after the FBX nodes. The tree is rebuilt from the file's
|
|
14
|
+
// own nodes the way Unity's importer shapes it: the root takes the file's name and the //RootNode
|
|
15
|
+
// ids, a single top-level GROUP collapses into it, transforms are mirrored into Unity's frame with
|
|
16
|
+
// the importer's `globalScale` on positions.
|
|
17
|
+
|
|
18
|
+
export type FbxMeta = {
|
|
19
|
+
globalScale: number
|
|
20
|
+
/** fileID → object name (`//RootNode`, a node name, a mesh name). */
|
|
21
|
+
names: Map<string, string>
|
|
22
|
+
/** `<classID>:<name>` → fileID. */
|
|
23
|
+
ids: Map<string, string>
|
|
24
|
+
/** FBX material name → the remapped `.mat` (externalObjects). */
|
|
25
|
+
materials: Map<string, string>
|
|
26
|
+
/** The importer's `addColliders`: Unity puts a MeshCollider on every mesh of this model at IMPORT,
|
|
27
|
+
* so the collider is in neither the prefab nor the scene and there is nothing to read but this. */
|
|
28
|
+
addColliders: boolean
|
|
29
|
+
/** The importer's `bakeAxisConversion`: the Z-up→Y-up correction a Blender export keeps on its top
|
|
30
|
+
* nodes is baked into the meshes, so it survives a placement that overwrites the root's rotation. */
|
|
31
|
+
bakeAxisConversion: boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const readFbxMeta = (metaPath: string): FbxMeta => {
|
|
35
|
+
const out: FbxMeta = { globalScale: 1, names: new Map(), ids: new Map(), materials: new Map(), addColliders: false, bakeAxisConversion: false }
|
|
36
|
+
if (!existsSync(metaPath)) return out
|
|
37
|
+
const doc = parseUnityYaml(readFileSync(metaPath, "utf8"))
|
|
38
|
+
const mi = isMap(doc.ModelImporter) ? doc.ModelImporter : {}
|
|
39
|
+
const meshes = isMap(mi.meshes) ? mi.meshes : {}
|
|
40
|
+
out.globalScale = num(meshes.globalScale, 1)
|
|
41
|
+
out.addColliders = num(meshes.addColliders, 0) !== 0
|
|
42
|
+
out.bakeAxisConversion = num(meshes.bakeAxisConversion, 0) !== 0
|
|
43
|
+
const add = (cls: number, id: string, name: string): void => {
|
|
44
|
+
out.names.set(id, name)
|
|
45
|
+
const k = `${cls}:${name}`
|
|
46
|
+
if (!out.ids.has(k)) out.ids.set(k, id)
|
|
47
|
+
}
|
|
48
|
+
if (isMap(mi.fileIDToRecycleName)) for (const [ id, name ] of Object.entries(mi.fileIDToRecycleName)) add(Math.floor(Number(id) / 100000), id, str(name))
|
|
49
|
+
if (Array.isArray(mi.internalIDToNameTable)) for (const e of mi.internalIDToNameTable) {
|
|
50
|
+
if (!isMap(e) || !isMap(e.first)) continue
|
|
51
|
+
for (const [ cls, id ] of Object.entries(e.first)) add(Number(cls), str(id), str(e.second))
|
|
52
|
+
}
|
|
53
|
+
if (Array.isArray(mi.externalObjects)) for (const e of mi.externalObjects) {
|
|
54
|
+
if (!isMap(e) || !isMap(e.first)) continue
|
|
55
|
+
if (!/Material$/.test(str(e.first.type))) continue
|
|
56
|
+
const g = ref(e.second).guid
|
|
57
|
+
if (g) out.materials.set(str(e.first.name), g)
|
|
58
|
+
}
|
|
59
|
+
return out
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type ModelInfo = { stem: string, nodes: FbxNode[], materials: string[][], meta: FbxMeta }
|
|
63
|
+
export const MODEL_EXT = /\.(fbx|obj|dae|blend)$/i
|
|
64
|
+
|
|
65
|
+
export const readModel = async (path: string): Promise<ModelInfo | null> => {
|
|
66
|
+
try {
|
|
67
|
+
const scene = await readFbx(new Uint8Array(readFileSync(path)))
|
|
68
|
+
// per node: the FBX material names its primitives use, in primitive order (= Unity's submeshes)
|
|
69
|
+
const materials = scene.nodes.map((n) => {
|
|
70
|
+
const mesh = n.mesh >= 0 ? scene.meshes[n.mesh] : null
|
|
71
|
+
const names: string[] = []
|
|
72
|
+
for (const p of mesh?.primitives ?? []) { const nm = scene.materials[p.material]?.name ?? ""; if (!names.includes(nm)) names.push(nm) }
|
|
73
|
+
return names
|
|
74
|
+
})
|
|
75
|
+
return { stem: basename(path, extname(path)), nodes: scene.nodes, materials, meta: readFbxMeta(path + ".meta") }
|
|
76
|
+
} catch { return null }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The name the FBX importer gives a model's MESH object: the node's (for the collapsed root too). */
|
|
80
|
+
export const MODEL_ROOT = "//RootNode"
|
|
81
|
+
|
|
82
|
+
// UNITY'S OWN fileIDs FOR AN IMPORTED MODEL'S ROOT OBJECTS, by class id.
|
|
83
|
+
//
|
|
84
|
+
// A model `.meta` usually ships NO id table at all (`internalIDToNameTable: []`), so there is
|
|
85
|
+
// nothing to read the root's ids out of - and yet every prefab that wraps that FBX, and every scene
|
|
86
|
+
// that places such a prefab, addresses them. They are not hashes of anything in the file: these four
|
|
87
|
+
// numbers are the same in every pack (measured across seven vendors' packs here - tactical, gunvfx,
|
|
88
|
+
// both blood packs, mc-core, the school), because Unity assigns a model prefab's ROOT a fixed id set
|
|
89
|
+
// and only its CHILDREN get per-name ids.
|
|
90
|
+
//
|
|
91
|
+
// Getting this wrong is silent and total. A kit whose prefabs are bare wrappers around an FBX keeps
|
|
92
|
+
// every placement's position, rotation and material override on the ROOT, addressed as
|
|
93
|
+
// `xor(thatPrefabInstance, thisId)`; invent the root's id instead and not one override in the level
|
|
94
|
+
// resolves, so the whole thing loads with every piece stacked on the origin.
|
|
95
|
+
const MODEL_ROOT_IDS: Record<number, string> = {
|
|
96
|
+
1: "919132149155446097", // GameObject
|
|
97
|
+
4: "-8679921383154817045", // Transform
|
|
98
|
+
23: "-7511558181221131132", // MeshRenderer
|
|
99
|
+
205: "4742473837712756890", // LODGroup
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** `bakeAxisConversion`'s half turn about the up axis in front of a rotation: RotY(180°) · q. */
|
|
103
|
+
const bakedAxis = (q: number[]): number[] => [ q[2], q[3], -q[0], -q[1] ]
|
|
104
|
+
|
|
105
|
+
/** How much of an FBX node's own transform its GameObject takes:
|
|
106
|
+
* `full` — all of it (an ordinary child);
|
|
107
|
+
* `none` — none (the model's root, which Unity authors at the origin);
|
|
108
|
+
* `axis` — what the ROOT's mesh takes when the FBX's single top node IS the mesh. In Unity that
|
|
109
|
+
* node is the prefab's root, and a root's position and rotation are never the model's: every
|
|
110
|
+
* PrefabInstance overrides them (`SM_Door_01.prefab` writes 0 / identity over the node's −2 m and
|
|
111
|
+
* its 180°), so neither may be applied here. What survives is the importer's `bakeAxisConversion`:
|
|
112
|
+
* it turns the file half a turn about the up axis (FBX front → Unity forward) and bakes the top
|
|
113
|
+
* node's rotation INTO THE MESH, where no override reaches it: mesh' = RotY(180°) · R · mesh.
|
|
114
|
+
* Without the flag nothing is baked and the placement's own rotation stands the mesh up.
|
|
115
|
+
* Measured on the school, whose four flagged models are the only ones this touches:
|
|
116
|
+
* `SM_Door_01` (R = RotY 180° · RotX −90°, so the half turns cancel) stands in its wall's opening,
|
|
117
|
+
* x −2.66..−1.34 — the whole R flipped it to +1.34..+2.66, R with the position put it at ±0.66,
|
|
118
|
+
* nothing laid it down; `SM_Door_02` (R = identity, mesh authored upright at x 0.78..3.22, nested
|
|
119
|
+
* at −2 in `SM_Wall_Door_03`) lands in ITS opening, −5.16..−2.84, only with the half turn —
|
|
120
|
+
* without it the double door stood 4 m off, which is how it shipped before.
|
|
121
|
+
* The node's SCALE is the root's too (`rootScale` below, `axis` keeps none of it): a Blender export
|
|
122
|
+
* without "Apply Unit" puts ×100 on that node, Unity shows it as the ROOT's scale, and a placement
|
|
123
|
+
* that writes `m_LocalScale` REPLACES it. Peremena's desks (×100 in the file, 85 in the scene) came out
|
|
124
|
+
* 8500× their mesh — 130 m across — while they were a child's scale multiplied by the placement's. */
|
|
125
|
+
type TransformShare = "full" | "none" | "axis" | "rootScale"
|
|
126
|
+
|
|
127
|
+
const xyz = (v: number[]): YMap => ({ x: String(v[0]), y: String(v[1]), z: String(v[2]) })
|
|
128
|
+
const xyzw = (q: number[]): YMap => ({ x: String(q[0]), y: String(q[1]), z: String(q[2]), w: String(q[3]) })
|
|
129
|
+
|
|
130
|
+
/** A node's local transform as a Unity Transform body: X mirrored, positions × the importer's scale. */
|
|
131
|
+
const transformBody = (n: FbxNode | null, share: TransformShare, meta: FbxMeta): YMap => {
|
|
132
|
+
const gs = meta.globalScale
|
|
133
|
+
const t = n && share === "full" ? [ -n.t[0] * gs, n.t[1] * gs, n.t[2] * gs ] : [ 0, 0, 0 ]
|
|
134
|
+
const q = !n || share === "none" || share === "rootScale" ? [ 0, 0, 0, 1 ] : share === "full" ? n.r : meta.bakeAxisConversion ? bakedAxis(n.r) : [ 0, 0, 0, 1 ]
|
|
135
|
+
const s = n && (share === "full" || share === "rootScale") ? n.s : [ 1, 1, 1 ]
|
|
136
|
+
return { m_LocalPosition: xyz(t), m_LocalRotation: xyzw(mirrorQuat(q)), m_LocalScale: xyz(s) }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The GameObject tree Unity's importer makes of a model file, under the ids its `.meta` publishes. */
|
|
140
|
+
export const modelTree = (info: ModelInfo, guid: string): Tree => {
|
|
141
|
+
const { nodes, meta } = info
|
|
142
|
+
const ids = new Map<string, UGO | UComp>()
|
|
143
|
+
const kids = new Map<number, number[]>()
|
|
144
|
+
nodes.forEach((n, i) => { if (n.parent >= 0) { const a = kids.get(n.parent) ?? []; a.push(i); kids.set(n.parent, a) } })
|
|
145
|
+
// helper nodes (geometric transform / scale inheritance) are not GameObjects: skip through them
|
|
146
|
+
const realChildren = (i: number): number[] => (kids.get(i) ?? []).flatMap((c) => (nodes[c].helper ? realChildren(c) : [ c ]))
|
|
147
|
+
const meshOf = (i: number): number => nodes[i].mesh >= 0 ? i : (kids.get(i) ?? []).find((c) => nodes[c].helper && meshOf(c) >= 0) ?? -1
|
|
148
|
+
const sceneRoot = nodes.findIndex((n) => n.isRoot)
|
|
149
|
+
const tops = sceneRoot >= 0 ? realChildren(sceneRoot) : nodes.map((_, i) => i).filter((i) => nodes[i].parent < 0)
|
|
150
|
+
// ONE top-level node collapses into the root — but only a BARE GROUP. A node that carries the MESH
|
|
151
|
+
// has to stay a child, because the root's transform never reaches the GLB (kindGlb builds the kind
|
|
152
|
+
// from its parts) and its `axis` share is the thing that stands a Blender export up.
|
|
153
|
+
const topMesh = tops.length === 1 && meshOf(tops[0]) >= 0 ? tops[0] : -1
|
|
154
|
+
const collapse = tops.length === 1 && topMesh < 0
|
|
155
|
+
|
|
156
|
+
// ids: the `.meta`'s own, Unity's fixed set for the root, else a placeholder nothing can address
|
|
157
|
+
let placeholders = 0
|
|
158
|
+
const idFor = (cls: number, name: string): string => meta.ids.get(`${cls}:${name}`)
|
|
159
|
+
?? (name === MODEL_ROOT ? MODEL_ROOT_IDS[cls] : undefined)
|
|
160
|
+
?? `-${cls}${String(++placeholders).padStart(6, "0")}`
|
|
161
|
+
const add = (go: UGO, cls: string, classId: number, key: string, body: YMap): UComp => {
|
|
162
|
+
const c: UComp = { cls, id: idFor(classId, key), body, go }
|
|
163
|
+
go.comps.push(c)
|
|
164
|
+
ids.set(c.id, c)
|
|
165
|
+
return c
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const make = (i: number | null, key: string, name: string, parent: UGO | null, share: TransformShare): UGO => {
|
|
169
|
+
const go: UGO = { id: idFor(1, key), body: { m_Name: name, m_IsActive: "1" }, comps: [], children: [], parent }
|
|
170
|
+
ids.set(go.id, go)
|
|
171
|
+
// the root of a model whose single top node is the mesh takes that node's scale (TransformShare)
|
|
172
|
+
const tn = i === null ? (parent === null && topMesh >= 0 ? topMesh : null) : i
|
|
173
|
+
add(go, "Transform", 4, key, transformBody(tn === null ? null : nodes[tn], i === null && tn !== null ? "rootScale" : share, meta))
|
|
174
|
+
const mi = i === null ? -1 : meshOf(i)
|
|
175
|
+
if (mi >= 0) {
|
|
176
|
+
const meshName = nodes[i!].name
|
|
177
|
+
const mesh = { fileID: meta.ids.get(`43:${meshName}`) ?? `name:${meshName}`, guid }
|
|
178
|
+
const mats = info.materials[mi].map((m) => { const g = meta.materials.get(m); return g ? { fileID: "2100000", guid: g } : { fileID: "0", guid: `name:${m}` } })
|
|
179
|
+
add(go, "MeshFilter", 33, key, { m_Mesh: mesh })
|
|
180
|
+
add(go, "MeshRenderer", 23, key, { m_Enabled: "1", m_CastShadows: "1", m_Materials: mats })
|
|
181
|
+
// `addColliders` is the importer generating a MeshCollider per mesh — the only trace of it is
|
|
182
|
+
// this flag, so a kit that leans on it (here: the interior floor, the ceiling and four walls)
|
|
183
|
+
// otherwise imports with nothing to stand on and the player falls through the level.
|
|
184
|
+
if (meta.addColliders) add(go, "MeshCollider", 64, key, { m_Enabled: "1", m_Convex: "0", m_Mesh: mesh })
|
|
185
|
+
}
|
|
186
|
+
for (const c of i === null ? tops : realChildren(i)) go.children.push(make(c, nodes[c].name, nodes[c].name, go, c === topMesh ? "axis" : "full"))
|
|
187
|
+
return go
|
|
188
|
+
}
|
|
189
|
+
const root = make(collapse ? tops[0] : null, MODEL_ROOT, info.stem, null, "none")
|
|
190
|
+
// every id here came from the FBX .meta's own tables, i.e. it IS the id this asset publishes
|
|
191
|
+
const bySource = new Map<string, UGO | UComp>()
|
|
192
|
+
for (const [ k, v ] of ids) bySource.set(`${guid}:${k}`, v)
|
|
193
|
+
return { roots: [ root ], ids, bySource }
|
|
194
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// WHAT IS A PLANT on an imported level — the kinds the level writer marks `foliage: true` (the SDK's
|
|
2
|
+
// vegetation tier: wind, touch bending, per-copy tint).
|
|
3
|
+
//
|
|
4
|
+
// The first rule was "any part wears an alpha-cutout material", and that is too broad by a mile: a
|
|
5
|
+
// poster is cutout, so is a road marking, a door's grille, a window frame — and the tier SWAYS them.
|
|
6
|
+
// On Chernobyl it caught 77 kinds of which 27 were plants; on the school it took two doors.
|
|
7
|
+
//
|
|
8
|
+
// So the evidence is the MATERIAL'S OWN NAME AND FOLDER, never its alpha mode: a pack sorts its
|
|
9
|
+
// materials by what they are (`Materials/Vegetation/…` against `Materials/Decals/`) or names them
|
|
10
|
+
// so (`M_Grass_Tall`, `Grass_01`), and that is the author telling us directly. The kind's name is
|
|
11
|
+
// weaker (`Fencing_Flowerbed_01` is a railing) and is consulted only when NO material of the kind
|
|
12
|
+
// could be read (binary .mat, a missing .meta). And because every rule is a guess about someone
|
|
13
|
+
// else's naming, the import takes an explicit list: `--foliage <re>` marks exactly the kinds whose
|
|
14
|
+
// name matches (the rules are then OFF), `--no-foliage` marks none.
|
|
15
|
+
import { basename } from "node:path"
|
|
16
|
+
import type { SceneKind } from "./scene"
|
|
17
|
+
import type { UnityMaterial } from "./matFile"
|
|
18
|
+
|
|
19
|
+
export type FoliageOptions = {
|
|
20
|
+
/** Explicit: exactly the kinds whose NAME matches wear the tier; the material rules are off. */
|
|
21
|
+
foliage?: RegExp
|
|
22
|
+
/** Nothing wears the tier. */
|
|
23
|
+
noFoliage?: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A material FOLDER that says "this is a plant" — one path segment, whole. */
|
|
27
|
+
const VEGETATION_DIR = /(^|[\\/])(vegetation|foliage|plants?|shrubs?|grass|trees?|bushes|flowers?)([\\/]|$)/i
|
|
28
|
+
/** A material NAME that says so — a whole word of it (`M_Grass_Tall`, `Grass_01`, `Leaves_Birch`). */
|
|
29
|
+
const VEGETATION_WORD = /(^|[^a-z])(grass|bush|bushes|shrub|leaf|leaves|foliage|vegetation|fern|ivy|vine|reed|clover|weed|moss|hedge|plant|flower|spruce|pine|birch|oak|palm|tree)(e?s)?([^a-z]|$)/i
|
|
30
|
+
/** …unless the same name says it is the WOODY part or the debris: a trunk, a stump, a fallen log. */
|
|
31
|
+
const NOT_VEGETATION_WORD = /(^|[^a-z])(trunk|bark|stump|log|logs|fallen|cut|root|roots|rock|stone|mushroom)([^a-z]|$)/i
|
|
32
|
+
|
|
33
|
+
/** Does the material's file say vegetation — by the folder it sits in, or by its own name? */
|
|
34
|
+
export const isVegetationMaterial = (um: UnityMaterial): boolean => {
|
|
35
|
+
if (!um.file) return false
|
|
36
|
+
const norm = um.file.replace(/\\/g, "/")
|
|
37
|
+
const dir = norm.slice(0, norm.lastIndexOf("/") + 1)
|
|
38
|
+
if (VEGETATION_DIR.test(dir)) return true
|
|
39
|
+
const name = basename(norm).replace(/\.mat$/i, "")
|
|
40
|
+
return VEGETATION_WORD.test(name) && !NOT_VEGETATION_WORD.test(name)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Vegetation by kind name — the fallback for a kind none of whose materials could be read.
|
|
44
|
+
const FOLIAGE_NAME = /grass|bush|shrub|flower|plant|reed|clover|fern|ivy|vine|leaf|leaves|tree|spruce|pine|birch|oak|palm|weed|moss|hedge|foliage/i
|
|
45
|
+
const NOT_FOLIAGE = /stump|log|branch|trunk_cut|rock|stone|mushroom|fenc|rail/i
|
|
46
|
+
|
|
47
|
+
/** Should this kind wear the foliage tier? `materials` = every material of every part the builder
|
|
48
|
+
* could read (null = unreadable), in part order. */
|
|
49
|
+
export const decideFoliage = (kind: SceneKind, materials: (UnityMaterial | null)[], opts: FoliageOptions = {}): boolean => {
|
|
50
|
+
if (opts.noFoliage) return false
|
|
51
|
+
if (opts.foliage) return opts.foliage.test(kind.name)
|
|
52
|
+
const read = materials.filter((m): m is UnityMaterial => !!m)
|
|
53
|
+
if (read.length > 0) return read.some(isVegetationMaterial)
|
|
54
|
+
return FOLIAGE_NAME.test(kind.name) && !NOT_FOLIAGE.test(kind.name)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The import log's one line about the verdict — so a wrong guess is seen, not found in the wind. */
|
|
58
|
+
export const describeFoliage = (names: string[], opts: FoliageOptions = {}): string => {
|
|
59
|
+
const how = opts.noFoliage ? "--no-foliage" : opts.foliage ? `--foliage ${opts.foliage.source}` : "by material folder / name"
|
|
60
|
+
if (names.length === 0) return `🌿 foliage tier: none (${how}; --foliage <re> marks kinds by name)`
|
|
61
|
+
const shown = names.slice(0, 12).join(", ") + (names.length > 12 ? `, … +${names.length - 12}` : "")
|
|
62
|
+
return `🌿 foliage tier: ${names.length} kind(s) ${how} — ${shown}${opts.foliage || opts.noFoliage ? "" : " (--foliage <re> overrides, --no-foliage none)"}`
|
|
63
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// A Unity file's objects as a tree: GameObjects with their components (raw YAML bodies) and
|
|
2
|
+
// children. prefabTree.ts builds these out of `.prefab` / `.unity` text, fbxModel.ts out of a model
|
|
3
|
+
// file; scene.ts reads the finished tree through the accessors at the bottom.
|
|
4
|
+
//
|
|
5
|
+
// Raw bodies stay raw (YAML maps) until a reader asks, so a prefab modification is just "set this
|
|
6
|
+
// path on that body" whatever the component.
|
|
7
|
+
|
|
8
|
+
import { isMap, num, quat, ref, str, vec3, type Quat, type Vec3, type YMap } from "./yaml"
|
|
9
|
+
import type { TRS } from "./math"
|
|
10
|
+
|
|
11
|
+
export type UComp = { cls: string, id: string, body: YMap, go: UGO }
|
|
12
|
+
export type UGO = {
|
|
13
|
+
id: string
|
|
14
|
+
body: YMap
|
|
15
|
+
comps: UComp[]
|
|
16
|
+
children: UGO[]
|
|
17
|
+
parent: UGO | null
|
|
18
|
+
/** Set on the root GameObject of a prefab instance: the source prefab's GUID. */
|
|
19
|
+
prefab?: string
|
|
20
|
+
}
|
|
21
|
+
// `ids` addresses an object the way the CURRENT file does (a nested prefab's are XOR-composed,
|
|
22
|
+
// Unity's own scheme); `bySource` addresses it the way an OVERRIDE does - by the asset the object
|
|
23
|
+
// is defined in plus its id THERE, which survives any amount of nesting. See the lookup below.
|
|
24
|
+
export type Tree = { roots: UGO[], ids: Map<string, UGO | UComp>, bySource: Map<string, UGO | UComp> }
|
|
25
|
+
|
|
26
|
+
/** A deep copy: every instance of a prefab gets its own bodies to apply modifications to. */
|
|
27
|
+
export const cloneTree = (t: Tree): Tree => {
|
|
28
|
+
const map = new Map<UGO | UComp, UGO | UComp>()
|
|
29
|
+
const cloneGo = (g: UGO, parent: UGO | null): UGO => {
|
|
30
|
+
const n: UGO = { id: g.id, body: structuredClone(g.body), comps: [], children: [], parent, prefab: g.prefab }
|
|
31
|
+
map.set(g, n)
|
|
32
|
+
for (const c of g.comps) { const nc: UComp = { cls: c.cls, id: c.id, body: structuredClone(c.body), go: n }; n.comps.push(nc); map.set(c, nc) }
|
|
33
|
+
for (const ch of g.children) n.children.push(cloneGo(ch, n))
|
|
34
|
+
return n
|
|
35
|
+
}
|
|
36
|
+
const roots = t.roots.map((r) => cloneGo(r, null))
|
|
37
|
+
const ids = new Map<string, UGO | UComp>()
|
|
38
|
+
for (const [ k, v ] of t.ids) { const n = map.get(v); if (n) ids.set(k, n) }
|
|
39
|
+
const bySource = new Map<string, UGO | UComp>()
|
|
40
|
+
for (const [ k, v ] of t.bySource) { const n = map.get(v); if (n) bySource.set(k, n) }
|
|
41
|
+
return { roots, ids, bySource }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ---- reading the finished tree --------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
export const comp = (go: UGO, cls: string): UComp | undefined => go.comps.find((c) => c.cls === cls)
|
|
47
|
+
export const goName = (go: UGO): string => str(go.body.m_Name)
|
|
48
|
+
export const goActive = (go: UGO): boolean => num(go.body.m_IsActive, 1) !== 0
|
|
49
|
+
export const localTRS = (go: UGO): TRS => {
|
|
50
|
+
const t = comp(go, "Transform") ?? comp(go, "RectTransform")
|
|
51
|
+
if (!t) return { t: [ 0, 0, 0 ], r: [ 0, 0, 0, 1 ], s: [ 1, 1, 1 ] }
|
|
52
|
+
const r = quat(t.body.m_LocalRotation)
|
|
53
|
+
const l = Math.hypot(r[0], r[1], r[2], r[3]) || 1
|
|
54
|
+
return { t: vec3(t.body.m_LocalPosition), r: [ r[0] / l, r[1] / l, r[2] / l, r[3] / l ], s: vec3(t.body.m_LocalScale, [ 1, 1, 1 ]) }
|
|
55
|
+
}
|
|
56
|
+
export const enabled = (c: UComp): boolean => num(c.body.m_Enabled, 1) !== 0
|
|
57
|
+
|
|
58
|
+
/** LOD level per renderer component (from every LODGroup in the tree): renderer → level. */
|
|
59
|
+
export const lodLevels = (roots: UGO[], ids?: Map<string, UGO | UComp>): Map<UComp, number> => {
|
|
60
|
+
const out = new Map<UComp, number>()
|
|
61
|
+
const walk = (g: UGO): void => {
|
|
62
|
+
for (const c of g.comps) {
|
|
63
|
+
if (c.cls !== "LODGroup" || !Array.isArray(c.body.m_LODs)) continue
|
|
64
|
+
// renderer refs are ids in the file the LODGroup came from — resolve among the group's own subtree
|
|
65
|
+
const byId = new Map<string, UComp>()
|
|
66
|
+
const collect = (x: UGO): void => { for (const k of x.comps) byId.set(k.id, k); x.children.forEach(collect) }
|
|
67
|
+
collect(g)
|
|
68
|
+
c.body.m_LODs.forEach((lod, level) => {
|
|
69
|
+
if (!isMap(lod) || !Array.isArray(lod.renderers)) return
|
|
70
|
+
for (const r of lod.renderers) {
|
|
71
|
+
const id = ref(isMap(r) ? r.renderer : undefined).fileID
|
|
72
|
+
const rc = byId.get(id) ?? (ids?.get(id) as UComp | undefined)
|
|
73
|
+
if (rc && "go" in rc && !out.has(rc)) out.set(rc, level)
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
g.children.forEach(walk)
|
|
78
|
+
}
|
|
79
|
+
roots.forEach(walk)
|
|
80
|
+
return out
|
|
81
|
+
}
|