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,104 @@
|
|
|
1
|
+
// GUID → file. Every reference inside a Unity project is a GUID; the path behind it comes from a
|
|
2
|
+
// `unity-index.txt` (written by `lecodes assets unpack`) or the `.meta` sidecars of a real project.
|
|
3
|
+
|
|
4
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs"
|
|
5
|
+
import { basename, dirname, join, resolve } from "node:path"
|
|
6
|
+
|
|
7
|
+
export const walkFiles = (dir: string, depth: number, out: string[], pred: (name: string) => boolean): void => {
|
|
8
|
+
if (depth < 0 || !existsSync(dir)) return
|
|
9
|
+
let entries
|
|
10
|
+
try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
|
|
11
|
+
for (const e of entries) {
|
|
12
|
+
const p = join(dir, e.name)
|
|
13
|
+
if (e.isDirectory()) { if (e.name !== "node_modules" && !e.name.startsWith(".")) walkFiles(p, depth - 1, out, pred) }
|
|
14
|
+
else if (pred(e.name)) out.push(p)
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type GuidResolver = { resolve: (guid: string) => string | null, source: string }
|
|
19
|
+
|
|
20
|
+
/** Find `unity-index.txt` (written by `lecodes assets unpack`) walking up from `start`. */
|
|
21
|
+
export const findUnityIndex = (start: string): string | null => {
|
|
22
|
+
let dir = resolve(start)
|
|
23
|
+
for (let i = 0; i < 12; i++) {
|
|
24
|
+
const f = join(dir, "unity-index.txt")
|
|
25
|
+
if (existsSync(f)) return f
|
|
26
|
+
const up = dirname(dir)
|
|
27
|
+
if (up === dir) break
|
|
28
|
+
dir = up
|
|
29
|
+
}
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The GUID the index lists for an absolute asset path (the FBX itself), or null. */
|
|
34
|
+
export const guidOfPath = (indexFile: string, absPath: string): string | null => {
|
|
35
|
+
const root = resolve(dirname(indexFile))
|
|
36
|
+
const want = resolve(absPath).toLowerCase().replace(/\\/g, "/")
|
|
37
|
+
for (const line of readFileSync(indexFile, "utf8").split(/\r?\n/)) {
|
|
38
|
+
const t = line.indexOf("\t")
|
|
39
|
+
if (t < 0) continue
|
|
40
|
+
if (resolve(root, line.slice(t + 1).trim()).toLowerCase().replace(/\\/g, "/") === want) return line.slice(0, t)
|
|
41
|
+
}
|
|
42
|
+
return null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** GUID → absolute path through an index file (`<guid>\t<path relative to the index's folder>`). */
|
|
46
|
+
export const resolverFromIndex = (indexFile: string): GuidResolver => {
|
|
47
|
+
const root = dirname(indexFile)
|
|
48
|
+
const map = new Map<string, string>()
|
|
49
|
+
for (const line of readFileSync(indexFile, "utf8").split(/\r?\n/)) {
|
|
50
|
+
const t = line.indexOf("\t")
|
|
51
|
+
if (t > 0) map.set(line.slice(0, t), line.slice(t + 1).trim())
|
|
52
|
+
}
|
|
53
|
+
return { source: indexFile, resolve: (g) => { const p = map.get(g); if (!p) return null; const abs = join(root, p); return existsSync(abs) ? abs : null } }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** GUID → path through `.meta` sidecars: scans up to the folder that holds `Assets` (or `depthUp` levels). */
|
|
57
|
+
export const resolverFromMeta = (start: string, depthUp = 6): GuidResolver | null => {
|
|
58
|
+
// THE PROJECT'S `Assets` HAS NO `.meta` OF ITS OWN — every folder inside it does. Without that test
|
|
59
|
+
// the first folder called `assets` on the way up won (a case-insensitive disk makes `Assets` match it
|
|
60
|
+
// too): Peremena's `Models/assets/` (a Sketchfab download) cut the index down to Models/, and the
|
|
61
|
+
// walls and ceiling lost their materials — `Materials/` was never scanned.
|
|
62
|
+
const isProjectAssets = (dir: string): boolean => /^assets$/i.test(basename(dir)) && !existsSync(`${dir}.meta`)
|
|
63
|
+
const holdsProjectAssets = (dir: string): boolean => {
|
|
64
|
+
try { return readdirSync(dir).some((n) => /^assets$/i.test(n) && isProjectAssets(join(dir, n))) } catch { return false }
|
|
65
|
+
}
|
|
66
|
+
let root = resolve(start)
|
|
67
|
+
for (let i = 0; i < depthUp; i++) {
|
|
68
|
+
if (isProjectAssets(root) || holdsProjectAssets(root)) break
|
|
69
|
+
const up = dirname(root)
|
|
70
|
+
if (up === root) break
|
|
71
|
+
root = up
|
|
72
|
+
}
|
|
73
|
+
const metas: string[] = []
|
|
74
|
+
walkFiles(root, 12, metas, (n) => n.endsWith(".meta"))
|
|
75
|
+
if (metas.length === 0) return null
|
|
76
|
+
const map = new Map<string, string>()
|
|
77
|
+
for (const f of metas) {
|
|
78
|
+
const head = readFileSync(f, "utf8").slice(0, 300)
|
|
79
|
+
const g = /\nguid: ([0-9a-f]{32})/.exec(head)?.[1]
|
|
80
|
+
if (g) map.set(g, f.slice(0, -5))
|
|
81
|
+
}
|
|
82
|
+
return { source: `${metas.length} .meta files under ${root}`, resolve: (g) => { const p = map.get(g); return p && existsSync(p) ? p : null } }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Every `.mat` the index knows (absolute paths) — shared materials live far from the FBX. */
|
|
86
|
+
export const indexedMaterialFiles = (indexFile: string): string[] => {
|
|
87
|
+
const root = dirname(indexFile)
|
|
88
|
+
const out: string[] = []
|
|
89
|
+
for (const line of readFileSync(indexFile, "utf8").split(/\r?\n/)) {
|
|
90
|
+
const t = line.indexOf("\t")
|
|
91
|
+
if (t > 0 && /\.mat$/i.test(line)) out.push(join(root, line.slice(t + 1).trim()))
|
|
92
|
+
}
|
|
93
|
+
return out
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The resolver for a scene / prefab file: the index found walking up from it (or the one given),
|
|
97
|
+
* else the Unity project's `.meta` sidecars. */
|
|
98
|
+
export const resolverFor = (file: string, indexFile?: string | null): GuidResolver => {
|
|
99
|
+
const idx = indexFile === undefined ? findUnityIndex(dirname(resolve(file))) : indexFile
|
|
100
|
+
if (idx && existsSync(idx)) return resolverFromIndex(idx)
|
|
101
|
+
const r = resolverFromMeta(dirname(resolve(file)), 12)
|
|
102
|
+
if (!r) throw new Error(`no unity-index.txt above ${file} and no .meta sidecars — cannot resolve GUIDs`)
|
|
103
|
+
return r
|
|
104
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Texture pixels for the material pass: decode whatever a pack ships, pack glTF's ORM layout.
|
|
2
|
+
|
|
3
|
+
import { sniffMime, decodeTga, decodeBmp, type Rgba } from "../textures/resolveImage"
|
|
4
|
+
import { decodePng } from "./png"
|
|
5
|
+
|
|
6
|
+
export const decodeImage = async (bytes: Uint8Array, hint: string): Promise<Rgba | null> => {
|
|
7
|
+
const mime = sniffMime(bytes, hint)
|
|
8
|
+
if (mime === "image/png") { const r = decodePng(bytes); if (r) return r }
|
|
9
|
+
if (mime === "image/x-tga") return decodeTga(bytes)
|
|
10
|
+
if (mime === "image/bmp") return decodeBmp(bytes)
|
|
11
|
+
try {
|
|
12
|
+
const sharp = (await import("sharp")).default
|
|
13
|
+
const { data, info } = await sharp(Buffer.from(bytes)).ensureAlpha().raw().toBuffer({ resolveWithObject: true })
|
|
14
|
+
return { width: info.width, height: info.height, data: new Uint8Array(data) }
|
|
15
|
+
} catch { return null }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Nearest-neighbour sample of one channel at (u, v) in 0..1. */
|
|
19
|
+
const sampleCh = (img: Rgba, ch: number, x: number, y: number, w: number, h: number): number => {
|
|
20
|
+
const sx = Math.min(img.width - 1, Math.floor((x + 0.5) * img.width / w))
|
|
21
|
+
const sy = Math.min(img.height - 1, Math.floor((y + 0.5) * img.height / h))
|
|
22
|
+
return img.data[(sy * img.width + sx) * 4 + ch]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type PackInput = { img: Rgba, channel: number, invert?: boolean }
|
|
26
|
+
/** Pack occlusion / roughness / metallic sources into one RGB image (glTF layout). Missing = constant. */
|
|
27
|
+
export const packOrm = (ao: PackInput | null, rough: PackInput | null, metal: PackInput | null, defaults = { ao: 255, rough: 255, metal: 0 }): Rgba => {
|
|
28
|
+
const srcs = [ ao, rough, metal ].filter((s): s is PackInput => !!s)
|
|
29
|
+
const w = Math.max(1, ...srcs.map((s) => s.img.width)), h = Math.max(1, ...srcs.map((s) => s.img.height))
|
|
30
|
+
const out = new Uint8Array(w * h * 4)
|
|
31
|
+
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
|
|
32
|
+
const d = (y * w + x) * 4
|
|
33
|
+
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 }
|
|
34
|
+
out[d] = get(ao, defaults.ao); out[d + 1] = get(rough, defaults.rough); out[d + 2] = get(metal, defaults.metal); out[d + 3] = 255
|
|
35
|
+
}
|
|
36
|
+
return { width: w, height: h, data: out }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Does this image's ALPHA channel carry a shape, or is it the flat 255 a decoder invents? Sampled —
|
|
40
|
+
* a leaf card is ~87 % clear, so a coarse stride sees it instantly and a false positive needs a
|
|
41
|
+
* meaningful part of the image to be see-through. */
|
|
42
|
+
export const alphaIsKeyed = (img: Rgba): boolean => {
|
|
43
|
+
const n = img.width * img.height
|
|
44
|
+
const step = Math.max(1, Math.floor(n / 4096))
|
|
45
|
+
let clear = 0, seen = 0
|
|
46
|
+
for (let i = 0; i < n; i += step) { seen++; if (img.data[i * 4 + 3] < 250) clear++ }
|
|
47
|
+
return seen > 0 && clear / seen > 0.02
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The mean alpha of an image (0..1), sampled on a coarse grid — what a mask map says as one number. Null when undecodable. */
|
|
51
|
+
export const meanAlpha = async (bytes: Uint8Array, hint: string): Promise<number | null> => {
|
|
52
|
+
const img = await decodeImage(bytes, hint)
|
|
53
|
+
if (!img) return null
|
|
54
|
+
const step = Math.max(1, Math.floor(Math.sqrt((img.width * img.height) / 4096)))
|
|
55
|
+
let sum = 0, n = 0
|
|
56
|
+
for (let y = 0; y < img.height; y += step) for (let x = 0; x < img.width; x += step) { sum += img.data[(y * img.width + x) * 4 + 3]; n++ }
|
|
57
|
+
return n ? sum / n / 255 : null
|
|
58
|
+
}
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
// One scene KIND (scene.ts: a fully expanded prefab — mesh parts, materials per submesh, LOD levels)
|
|
2
|
+
// → one glTF Document: the meshes pulled out of their FBX files, each on a node carrying the part's
|
|
3
|
+
// kind-local transform, the FBX importer's `globalScale` and the FBX's own geometric-transform helper,
|
|
4
|
+
// materials written from the Unity `.mat` files the MeshRenderer names (by GUID — no name guessing),
|
|
5
|
+
// LOD parts named `<part>_LOD<n>` so creator-gl's LOD pass groups them.
|
|
6
|
+
//
|
|
7
|
+
// Mesh identity: a MeshFilter names `{fileID, guid}` — the FBX's GUID and the Unity object id the FBX
|
|
8
|
+
// `.meta` names after the NODE that holds the mesh. That node's geometry in ufbx's frame (metres,
|
|
9
|
+
// right-handed Y-up) is Unity's mesh mirrored back on X, i.e. exactly what the X-mirrored kind frame
|
|
10
|
+
// expects; `globalScale` is the only factor left.
|
|
11
|
+
|
|
12
|
+
import { basename, dirname } from "node:path"
|
|
13
|
+
import { readFileSync } from "node:fs"
|
|
14
|
+
import { Document, type Material, type Mesh, type Node } from "@gltf-transform/core"
|
|
15
|
+
import { mergeDocuments, prune } from "@gltf-transform/functions"
|
|
16
|
+
import { readFbx } from "../fbx/read"
|
|
17
|
+
import { toDocument } from "../fbx/toDocument"
|
|
18
|
+
import { quiet } from "../props"
|
|
19
|
+
import { readFbxMeta, type FbxMeta } from "./fbxModel"
|
|
20
|
+
import { readUnityMeshAsset } from "./unityMesh"
|
|
21
|
+
import type { GuidResolver } from "./guid"
|
|
22
|
+
import { I4, mmul, scaleMatrix, type Mat4 } from "./math"
|
|
23
|
+
import { collectUnityMaterials, materialKey, parseUnityMaterial, windProfile, type UnityMaterial } from "./matFile"
|
|
24
|
+
import { pairSlots } from "./materialSlots"
|
|
25
|
+
import { unityMaterialWriter } from "./materialWriter"
|
|
26
|
+
import { projectTriplanar, triplanarOf } from "./triplanar"
|
|
27
|
+
import type { KindPart, SceneKind } from "./scene"
|
|
28
|
+
|
|
29
|
+
type FbxSource = { doc: Document, meta: FbxMeta, byName: Map<string, Node>, path: string, sole?: Node | null }
|
|
30
|
+
|
|
31
|
+
/** The file's ONLY mesh-bearing node, or null when it has none or more than one (cached per file). */
|
|
32
|
+
const soleMeshNode = (src: FbxSource): Node | undefined => {
|
|
33
|
+
if (src.sole === undefined) {
|
|
34
|
+
const hit: Node[] = []
|
|
35
|
+
for (const n of src.doc.getRoot().listNodes()) {
|
|
36
|
+
if (n.getMesh()) { hit.push(n); continue }
|
|
37
|
+
const kid = n.listChildren().find((c) => c.getMesh())
|
|
38
|
+
if (kid && !n.getMesh()) hit.push(n)
|
|
39
|
+
}
|
|
40
|
+
// the MESH either way: a parent counts by its child's mesh, not by the child node — mixing the two
|
|
41
|
+
// made every file whose mesh node has a parent (a Sketchfab export: root/…/Plane_0/Object_4) "two meshes"
|
|
42
|
+
const meshes = new Set(hit.map((n) => n.getMesh() ?? n.listChildren().find((c) => c.getMesh())?.getMesh()))
|
|
43
|
+
src.sole = meshes.size === 1 ? hit[0] : null
|
|
44
|
+
}
|
|
45
|
+
return src.sole ?? undefined
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type KindGlbContext = {
|
|
49
|
+
resolver: GuidResolver
|
|
50
|
+
/** The folders whose `.mat` files answer `name:<material>` refs (FBX materials not remapped). */
|
|
51
|
+
materialDirs: string[]
|
|
52
|
+
warn: (msg: string) => void
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** One kind's document under construction, with what is shared between its parts. */
|
|
56
|
+
type KindDoc = {
|
|
57
|
+
doc: Document
|
|
58
|
+
writer: ReturnType<typeof unityMaterialWriter>
|
|
59
|
+
/** per source FBX: its document merged in (source property → copy) */
|
|
60
|
+
merged: Map<string, Map<unknown, unknown>>
|
|
61
|
+
/** the FBX nodes that came along, dropped at the end */
|
|
62
|
+
imported: Set<Node>
|
|
63
|
+
materials: Map<string, Material>
|
|
64
|
+
meshes: Map<string, Mesh>
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** THE LOD CHAIN IS GROUPED BY BASE NAME, so every level of one mesh must share it — and a vegetation
|
|
68
|
+
* pack breaks that by convention: Unity's tree exporter calls the last level `Billboard_<Tree>` beside
|
|
69
|
+
* `<Tree>`, `<Tree>_LOD1`, `<Tree>_LOD2`. Left alone the billboard becomes a chain of ITS OWN whose
|
|
70
|
+
* only member is level 3, so it is selected at every distance and drawn OVER the tree it is supposed
|
|
71
|
+
* to replace: Chernobyl's forest wore flat white cards at point-blank range. The prefix goes when the
|
|
72
|
+
* part is a LOD member. */
|
|
73
|
+
/**
|
|
74
|
+
* The node names a renderer's GameObject could be standing for when the FBX `.meta` id table is empty
|
|
75
|
+
* (a pack routinely ships `internalIDToNameTable: []`), most literal first. Unity names a DUPLICATED
|
|
76
|
+
* object `Book (17)` while the node in the file is still `Book`; a renderer that sits on the prefab
|
|
77
|
+
* ROOT has no path of its own, and the prefab is named after the model with a pack's decoration —
|
|
78
|
+
* NatureManufacture's terrain-detail prefabs are `Prefab_heath_A_01_Unity_Terrain` over
|
|
79
|
+
* `heath_A_01.fbx`, whose meshes are `heath_A_01_LOD0` / `_LOD1`; a scene's loose renderer IS named
|
|
80
|
+
* like its node (`mountain_background_02_LOD1`), only the kind name carries it.
|
|
81
|
+
*/
|
|
82
|
+
export const meshNameCandidates = (hint?: string, kindName?: string): string[] => {
|
|
83
|
+
const out: string[] = []
|
|
84
|
+
const add = (s: string | undefined): void => { if (s && !out.includes(s)) out.push(s) }
|
|
85
|
+
for (const raw of [ hint, kindName ]) {
|
|
86
|
+
if (!raw) continue
|
|
87
|
+
add(raw)
|
|
88
|
+
const bare = raw.replace(/ \(\d+\)$/, "")
|
|
89
|
+
add(bare)
|
|
90
|
+
const plain = bare.replace(/^(Prefab|P|PF|PREFAB|SM|PRB)_/i, "").replace(/_(Unity_Terrain|Terrain|Prefab|VS)$/i, "")
|
|
91
|
+
add(plain)
|
|
92
|
+
add(`${plain}_LOD0`)
|
|
93
|
+
}
|
|
94
|
+
return out
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const partNodeName = (part: KindPart, kindName: string): string => {
|
|
98
|
+
const raw = (part.path.split("/").pop() || kindName).replace(/_LOD\d+$/i, "")
|
|
99
|
+
return part.lod > 0 ? `${raw.replace(/^Billboard_/i, "")}_LOD${part.lod}` : raw
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export class KindGlbBuilder {
|
|
103
|
+
private fbx = new Map<string, Promise<FbxSource | null>>()
|
|
104
|
+
private mats = new Map<string, UnityMaterial | null>()
|
|
105
|
+
private byKey: Map<string, UnityMaterial> | null = null
|
|
106
|
+
constructor(readonly ctx: KindGlbContext) {}
|
|
107
|
+
|
|
108
|
+
private source(guid: string): Promise<FbxSource | null> {
|
|
109
|
+
let p = this.fbx.get(guid)
|
|
110
|
+
if (!p) {
|
|
111
|
+
p = (async () => {
|
|
112
|
+
const path = this.ctx.resolver.resolve(guid)
|
|
113
|
+
if (!path) { this.ctx.warn(`mesh source ${guid}: not found`); return null }
|
|
114
|
+
// A MESH SAVED BY UNITY (`<Tree>_Mesh_s.asset`, the hand-made LOD3 cross of NatureManufacture's
|
|
115
|
+
// trees) is a one-node source of its own: `4300000` is the fileID Unity gives the mesh in a
|
|
116
|
+
// mesh asset, and the node carries the asset's name for the name / sole-mesh fallbacks.
|
|
117
|
+
if (/\.asset$/i.test(path)) {
|
|
118
|
+
try {
|
|
119
|
+
const um = readUnityMeshAsset(path)
|
|
120
|
+
if (!um) { this.ctx.warn(`${basename(path)}: not a readable mesh asset (binary, compressed or empty)`); return null }
|
|
121
|
+
const node = um.doc.getRoot().listNodes()[0]
|
|
122
|
+
const meta: FbxMeta = { globalScale: 1, names: new Map([ [ "4300000", um.name ] ]), ids: new Map([ [ `43:${um.name}`, "4300000" ] ]), materials: new Map(), addColliders: false, bakeAxisConversion: false }
|
|
123
|
+
return { doc: um.doc, meta, byName: new Map([ [ um.name, node ] ]), path, sole: node }
|
|
124
|
+
} catch (e) { this.ctx.warn(`${basename(path)}: ${(e as Error).message}`); return null }
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
const scene = await readFbx(new Uint8Array(readFileSync(path)))
|
|
128
|
+
const { doc } = await toDocument(scene, { baseDir: dirname(path), defaultClipName: basename(path), warn: () => {}, extraUvSets: true })
|
|
129
|
+
for (const a of doc.getRoot().listAnimations()) a.dispose()
|
|
130
|
+
const byName = new Map<string, Node>()
|
|
131
|
+
for (const n of doc.getRoot().listNodes()) if (!byName.has(n.getName())) byName.set(n.getName(), n)
|
|
132
|
+
return { doc, meta: readFbxMeta(path + ".meta"), byName, path }
|
|
133
|
+
} catch (e) { this.ctx.warn(`${basename(path)}: ${(e as Error).message}`); return null }
|
|
134
|
+
})()
|
|
135
|
+
this.fbx.set(guid, p)
|
|
136
|
+
}
|
|
137
|
+
return p
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private material(ref: string): UnityMaterial | null {
|
|
141
|
+
if (this.mats.has(ref)) return this.mats.get(ref)!
|
|
142
|
+
let um: UnityMaterial | null = null
|
|
143
|
+
if (ref.startsWith("name:")) {
|
|
144
|
+
this.byKey ??= collectUnityMaterials(this.ctx.materialDirs, 8).byKey
|
|
145
|
+
um = this.byKey.get(materialKey(ref.slice(5))) ?? null
|
|
146
|
+
} else if (ref) {
|
|
147
|
+
const path = this.ctx.resolver.resolve(ref)
|
|
148
|
+
if (path) { try { um = parseUnityMaterial(readFileSync(path, "utf8"), path) } catch { um = null } }
|
|
149
|
+
if (!um && path) this.ctx.warn(`material ${basename(path)}: unreadable (binary?) — the FBX's own kept`)
|
|
150
|
+
}
|
|
151
|
+
// A miss here is SILENT and expensive: the primitive keeps no material at all (the FBX's own is
|
|
152
|
+
// pruned with the rest of its document), so the kind renders as glTF's default white metal.
|
|
153
|
+
if (!um) this.ctx.warn(`material ${ref}: no .mat found — the part keeps no material`)
|
|
154
|
+
this.mats.set(ref, um)
|
|
155
|
+
return um
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Every material of every part, in part order (null = unreadable) — what the level writer's foliage
|
|
159
|
+
* verdict reads (`unity/foliage.ts`: the material's folder and name, never its alpha mode). */
|
|
160
|
+
materialsOf(kind: SceneKind): (UnityMaterial | null)[] {
|
|
161
|
+
const out: (UnityMaterial | null)[] = []
|
|
162
|
+
for (const part of kind.parts) for (const ref of part.materials) out.push(this.material(ref))
|
|
163
|
+
return out
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The mesh node of an FBX for a Unity mesh ref, with the transform from that node to its mesh
|
|
167
|
+
* (a geometric-transform helper child when ufbx made one).
|
|
168
|
+
*
|
|
169
|
+
* A MeshFilter names its mesh `{fileID, guid}`, and turning that NUMBER into a node name needs the
|
|
170
|
+
* FBX `.meta`'s own id table — which a pack routinely ships EMPTY (`internalIDToNameTable: []`;
|
|
171
|
+
* Chernobyl's every model does, and not one of its 648 kinds could be built). So when the id says
|
|
172
|
+
* nothing, fall back to the part's own NODE NAME: Unity names the GameObject it instantiates after
|
|
173
|
+
* the FBX node it came from, so `Pine_01_LOD0` in the prefab is `Pine_01_LOD0` in the file. It is a
|
|
174
|
+
* strict improvement — the id is tried first and a renamed node just fails the way it did before. */
|
|
175
|
+
private meshFor(src: FbxSource, fileID: string, hint?: string, kindName?: string): { mesh: Mesh, local: Mat4 } | null {
|
|
176
|
+
const name = fileID.startsWith("name:") ? fileID.slice(5) : src.meta.names.get(fileID)
|
|
177
|
+
// ...and when the part has no path either — the renderer sits on the prefab ROOT, which is how
|
|
178
|
+
// every single-mesh prop in a kit is built — there is still one unambiguous answer if the file
|
|
179
|
+
// holds exactly ONE mesh: that mesh.
|
|
180
|
+
let node = name !== undefined ? src.byName.get(name) : undefined
|
|
181
|
+
for (const c of meshNameCandidates(hint, kindName)) { if (node) break; node = src.byName.get(c) }
|
|
182
|
+
// the file's LOD chain of that base name — a detail prefab's one renderer means "the plant", and
|
|
183
|
+
// the plant is `heath_A_01_LOD0` (the finest level; the coarser ones are the same plant)
|
|
184
|
+
if (!node) {
|
|
185
|
+
for (const c of meshNameCandidates(hint, kindName)) {
|
|
186
|
+
const chain = [ ...src.byName.entries() ].filter(([ n ]) => n.replace(/_LOD\d+$/i, "") === c && /_LOD\d+$/i.test(n)).sort(([ a ], [ b ]) => a.localeCompare(b))
|
|
187
|
+
if (chain.length) { node = chain[0][1]; break }
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
node ??= soleMeshNode(src)
|
|
191
|
+
if (!node) return null
|
|
192
|
+
if (node.getMesh()) return { mesh: node.getMesh()!, local: I4() }
|
|
193
|
+
for (const c of node.listChildren()) if (c.getMesh()) return { mesh: c.getMesh()!, local: Array.from(c.getMatrix()) }
|
|
194
|
+
return null
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The materials of one mesh: each primitive takes the slot that names it (materialSlots.ts) — by
|
|
198
|
+
* NAME, never by position. A slot is known by the FBX material it names, the one this model's
|
|
199
|
+
* `.meta` remaps its GUID from, or — when the `.meta` remaps nothing — the `.mat`'s own name. */
|
|
200
|
+
private async assignMaterials(mesh: Mesh, part: KindPart, src: FbxSource, out: KindDoc): Promise<void> {
|
|
201
|
+
const prims = mesh.listPrimitives()
|
|
202
|
+
const byGuid = new Map<string, string>()
|
|
203
|
+
for (const [ n, g ] of src.meta.materials) if (!byGuid.has(g)) byGuid.set(g, n)
|
|
204
|
+
const slotName = part.materials.map((r) => (r.startsWith("name:") ? r.slice(5) : byGuid.get(r) ?? this.material(r)?.name ?? null))
|
|
205
|
+
const slotFor = pairSlots(prims.map((pr) => pr.getMaterial()?.getName() ?? ""), slotName)
|
|
206
|
+
for (let i = 0; i < prims.length; i++) {
|
|
207
|
+
const ref = part.materials[slotFor[i] >= 0 ? slotFor[i] : Math.min(i, part.materials.length - 1)]
|
|
208
|
+
if (!ref) continue
|
|
209
|
+
const um = this.material(ref)
|
|
210
|
+
// THE DETAIL LAYER ON ANOTHER UV SET (NatureManufacture's bark shader): albedo / normal / ORM are
|
|
211
|
+
// `lerp(main(uv0), detail(uv3 × tiling), mask.a)`, and the pines' mask is white everywhere — uv0 covers the
|
|
212
|
+
// roots alone, the whole trunk is the detail bark on the third UV set. glTF has one UV per texture slot,
|
|
213
|
+
// so the primitive takes the detail textures on TEXCOORD_0 = that set × tiling.
|
|
214
|
+
const detail = um ? await this.detailLayer(um) : null
|
|
215
|
+
const set = detail ? prims[i].getAttribute(`TEXCOORD_${detail.set}`) : null
|
|
216
|
+
const useDetail = detail !== null && set !== null
|
|
217
|
+
const key = useDetail ? `${ref}\u0000detail` : ref
|
|
218
|
+
let gm = out.materials.get(key)
|
|
219
|
+
if (!gm && um) {
|
|
220
|
+
gm = out.doc.createMaterial(um.name)
|
|
221
|
+
await out.writer.applyTo(gm, useDetail ? detail!.um : um, "name")
|
|
222
|
+
const wind = windProfile(um)
|
|
223
|
+
if (wind) {
|
|
224
|
+
// merged: applyTo may have put the GI switch (`emissiveBake`) there already
|
|
225
|
+
const extras = (gm.getExtras() ?? {}) as Record<string, unknown>
|
|
226
|
+
gm.setExtras({ ...extras, lecodes: { ...((extras.lecodes as Record<string, unknown> | undefined) ?? {}), wind } })
|
|
227
|
+
}
|
|
228
|
+
out.materials.set(key, gm)
|
|
229
|
+
}
|
|
230
|
+
if (!gm) continue
|
|
231
|
+
if (useDetail) {
|
|
232
|
+
const [ sx, sy, ox, oy ] = detail!.st
|
|
233
|
+
const src = set!, n = src.getCount(), uv = new Float32Array(new ArrayBuffer(n * 8)), v: number[] = []
|
|
234
|
+
// glTF v runs top-down (the reader flipped it): Unity's v' = v·sy + oy on the un-flipped value
|
|
235
|
+
for (let k = 0; k < n; k++) { src.getElement(k, v); uv[k * 2] = v[0] * sx + ox; uv[k * 2 + 1] = 1 - ((1 - v[1]) * sy + oy) }
|
|
236
|
+
prims[i].setAttribute("TEXCOORD_0", out.doc.createAccessor().setType("VEC2").setArray(uv).setBuffer(out.doc.getRoot().listBuffers()[0]))
|
|
237
|
+
}
|
|
238
|
+
prims[i].setMaterial(gm)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private detailCache = new Map<string, Promise<{ set: number, st: [number, number, number, number], um: UnityMaterial } | null>>()
|
|
243
|
+
|
|
244
|
+
/** A material whose surface IS its detail layer: `_DETALUSEUV3_ON` (the detail sampled on Unity's uv3 = set 2)
|
|
245
|
+
* with a `_DetailAlbedoMap`, under a `_DetailMask` whose alpha is white (a black or missing mask = the
|
|
246
|
+
* main layer alone, Unity's default; a mixed mask is logged and the main layer kept). */
|
|
247
|
+
private detailLayer(um: UnityMaterial): Promise<{ set: number, st: [number, number, number, number], um: UnityMaterial } | null> {
|
|
248
|
+
let p = this.detailCache.get(um.file)
|
|
249
|
+
if (!p) {
|
|
250
|
+
p = (async () => {
|
|
251
|
+
if (!um.keywords.includes("_DETALUSEUV3_ON") || !um.textures._DetailAlbedoMap) return null
|
|
252
|
+
const maskGuid = um.textures._DetailMask
|
|
253
|
+
const maskPath = maskGuid ? this.ctx.resolver.resolve(maskGuid) : null
|
|
254
|
+
if (!maskPath) return null
|
|
255
|
+
const { meanAlpha } = await import("./image")
|
|
256
|
+
const a = await meanAlpha(new Uint8Array(readFileSync(maskPath)), maskPath)
|
|
257
|
+
if (a === null) return null
|
|
258
|
+
if (a < 0.97) { if (a > 0.03) this.ctx.warn(`${um.name}: detail mask ${basename(maskPath)} is mixed (${a.toFixed(2)}) — main layer kept`); return null }
|
|
259
|
+
const textures: Record<string, string> = { ...um.textures, _MainTex: um.textures._DetailAlbedoMap }
|
|
260
|
+
for (const k of Object.keys(textures)) if (/^_(MaskMap|BaseColorMap|NormalMap|BumpMap|MetalicRAOGSmothnessA)\d*$/.test(k)) delete textures[k]
|
|
261
|
+
if (um.textures._DetailNormalMap) textures._BumpMap = um.textures._DetailNormalMap
|
|
262
|
+
if (um.textures._DetailMetalicRAOGSmothnessA) textures._MetalicRAOGSmothnessA = um.textures._DetailMetalicRAOGSmothnessA
|
|
263
|
+
const floats = { ...um.floats, _BumpScale: um.floats._DetailNormalMapScale ?? um.floats._BumpScale ?? 1 }
|
|
264
|
+
return { set: 2, st: um.tiling?._DetailAlbedoMap ?? [ 1, 1, 0, 0 ], um: { ...um, textures, floats } }
|
|
265
|
+
})()
|
|
266
|
+
this.detailCache.set(um.file, p)
|
|
267
|
+
}
|
|
268
|
+
return p
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** The part's mesh inside the kind's document, wearing the part's materials: one Mesh per (mesh,
|
|
272
|
+
* material list) — parts sharing a mesh with other materials get a clone. */
|
|
273
|
+
private async partMesh(part: KindPart, src: FbxSource, found: Mesh, out: KindDoc): Promise<Mesh> {
|
|
274
|
+
let map = out.merged.get(part.mesh.guid)
|
|
275
|
+
if (!map) {
|
|
276
|
+
map = mergeDocuments(out.doc, src.doc) as Map<unknown, unknown>
|
|
277
|
+
out.merged.set(part.mesh.guid, map)
|
|
278
|
+
for (const n of src.doc.getRoot().listNodes()) { const t = map.get(n) as Node | undefined; if (t) out.imported.add(t) }
|
|
279
|
+
}
|
|
280
|
+
const meshId = `${part.mesh.guid}:${part.mesh.fileID}:`
|
|
281
|
+
const key = meshId + part.materials.join(",")
|
|
282
|
+
let mesh = out.meshes.get(key)
|
|
283
|
+
if (!mesh) {
|
|
284
|
+
const base = map.get(found) as Mesh
|
|
285
|
+
mesh = [ ...out.meshes.keys() ].some((k) => k.startsWith(meshId)) ? base.clone() : base
|
|
286
|
+
await this.assignMaterials(mesh, part, src, out)
|
|
287
|
+
out.meshes.set(key, mesh)
|
|
288
|
+
}
|
|
289
|
+
return mesh
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Primitives whose material samples TRIPLANAR (a Shader Graph, triplanar.ts) get the projection baked
|
|
293
|
+
* into their UVs. The UVs depend on the part's matrix, so such a mesh becomes this part's own copy. */
|
|
294
|
+
private triplanarUVs(mesh: Mesh, matrix: Mat4, out: KindDoc): Mesh {
|
|
295
|
+
const refOf = new Map<Material, string>()
|
|
296
|
+
for (const [ ref, gm ] of out.materials) refOf.set(gm, ref)
|
|
297
|
+
const plan = mesh.listPrimitives().map((p) => {
|
|
298
|
+
// the cache key may carry the detail-layer suffix (assignMaterials) — the .mat is the ref before it
|
|
299
|
+
const ref = p.getMaterial() ? refOf.get(p.getMaterial()!)?.split("\u0000")[0] : undefined
|
|
300
|
+
const um = ref ? this.material(ref) : null
|
|
301
|
+
return um ? triplanarOf(um, this.ctx.resolver) : null
|
|
302
|
+
})
|
|
303
|
+
if (!plan.some(Boolean)) return mesh
|
|
304
|
+
const own = out.doc.createMesh(mesh.getName())
|
|
305
|
+
mesh.listPrimitives().forEach((p, i) => {
|
|
306
|
+
const t = plan[i]
|
|
307
|
+
const q = t ? p.clone() : p
|
|
308
|
+
if (t) projectTriplanar(out.doc, q, t.space === "object" ? I4() : matrix, t.tile)
|
|
309
|
+
own.addPrimitive(q)
|
|
310
|
+
})
|
|
311
|
+
return own
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async build(kind: SceneKind): Promise<{ doc: Document, parts: number, missing: string[] }> {
|
|
315
|
+
const doc = quiet(new Document())
|
|
316
|
+
doc.createBuffer()
|
|
317
|
+
const scene = doc.createScene(kind.name)
|
|
318
|
+
const root = doc.createNode(kind.name)
|
|
319
|
+
scene.addChild(root)
|
|
320
|
+
const out: KindDoc = { doc, writer: unityMaterialWriter(doc, this.ctx.resolver), merged: new Map(), imported: new Set(), materials: new Map(), meshes: new Map() }
|
|
321
|
+
const missing: string[] = []
|
|
322
|
+
let parts = 0
|
|
323
|
+
for (const part of kind.parts) {
|
|
324
|
+
const where = part.path || kind.name
|
|
325
|
+
const src = await this.source(part.mesh.guid)
|
|
326
|
+
if (!src) { missing.push(`${where}: mesh file ${part.mesh.guid}`); continue }
|
|
327
|
+
const hit = this.meshFor(src, part.mesh.fileID, part.path.split("/").pop() || undefined, kind.name)
|
|
328
|
+
if (!hit) { missing.push(`${where}: mesh ${src.meta.names.get(part.mesh.fileID) ?? part.mesh.fileID} not in ${basename(src.path)}`); continue }
|
|
329
|
+
// A MESH WITH NO VERTICES IS NOT A MESH. Packs ship them — the Modular Shooting Range's floor
|
|
330
|
+
// stripes carry five LOD levels of which the last is empty — and writing one out is not merely
|
|
331
|
+
// wasteful: filament's gltfio walks the primitive in `ResourceLoader::loadResources` and takes
|
|
332
|
+
// the whole host down with an access violation, before a single frame. Skipped like a missing
|
|
333
|
+
// mesh, and reported, because a silently thinner LOD chain is its own bug.
|
|
334
|
+
if (!hit.mesh.listPrimitives().some((p) => (p.getAttribute("POSITION")?.getCount() ?? 0) > 0)) {
|
|
335
|
+
missing.push(`${where}: mesh "${hit.mesh.getName() || "?"}" has no vertices — skipped`)
|
|
336
|
+
continue
|
|
337
|
+
}
|
|
338
|
+
let mesh = await this.partMesh(part, src, hit.mesh, out)
|
|
339
|
+
// Unity bakes the geometric transform into the mesh, THEN scales it: part · S(globalScale) · helper
|
|
340
|
+
const m = mmul(mmul(part.matrix, scaleMatrix(src.meta.globalScale)), hit.local)
|
|
341
|
+
mesh = this.triplanarUVs(mesh, m, out)
|
|
342
|
+
root.addChild(doc.createNode(partNodeName(part, kind.name)).setMesh(mesh).setMatrix(m as never))
|
|
343
|
+
parts++
|
|
344
|
+
}
|
|
345
|
+
// the FBX's further UV sets were shader inputs (read for the detail layer above); TEXCOORD_1 is the lightmap set
|
|
346
|
+
for (const m of doc.getRoot().listMeshes()) for (const p of m.listPrimitives()) for (const k of [ 1, 2, 3 ]) { const a = p.getAttribute(`TEXCOORD_${k}`); if (a) { p.setAttribute(`TEXCOORD_${k}`, null); if (a.listParents().length <= 1) a.dispose() } }
|
|
347
|
+
// drop everything the FBX documents brought besides the meshes / materials we used
|
|
348
|
+
for (const s of doc.getRoot().listScenes()) if (s !== scene) s.dispose()
|
|
349
|
+
for (const n of out.imported) n.dispose()
|
|
350
|
+
await doc.transform(prune({ keepLeaves: false }))
|
|
351
|
+
// ONE BUFFER. `mergeDocuments` brings each source FBX's own buffer along, so a kind whose parts
|
|
352
|
+
// come from SEVERAL model files ends up with several — and a GLB may hold at most one, which
|
|
353
|
+
// glTF-Transform reports as "GLB must have 0-1 buffers" from inside writeBinary. The school's
|
|
354
|
+
// kinds each drew from a single FBX and never hit it; Chernobyl assembles prefabs out of parts
|
|
355
|
+
// from different models and dies partway through the level.
|
|
356
|
+
const buffers = doc.getRoot().listBuffers()
|
|
357
|
+
if (buffers.length > 1) {
|
|
358
|
+
const keep = buffers[0]
|
|
359
|
+
for (const a of doc.getRoot().listAccessors()) a.setBuffer(keep)
|
|
360
|
+
for (const b of buffers.slice(1)) b.dispose()
|
|
361
|
+
}
|
|
362
|
+
return { doc, parts, missing }
|
|
363
|
+
}
|
|
364
|
+
}
|