lecodes-assets 0.2.1 → 2.4.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/README.md +203 -26
  2. package/dist/cli.js +17948 -11911
  3. package/dist/index.js +17525 -11479
  4. package/package.json +3 -1
  5. package/src/cli-kit/command.ts +100 -0
  6. package/src/cli-kit/help.ts +72 -0
  7. package/src/cli-kit/index.ts +4 -0
  8. package/src/cli-kit/parse.ts +72 -0
  9. package/src/cli-kit/run.ts +52 -0
  10. package/src/cli.ts +24 -1039
  11. package/src/commands/avatar.ts +77 -0
  12. package/src/commands/bvh.ts +26 -0
  13. package/src/commands/convert.ts +156 -0
  14. package/src/commands/doctor.ts +67 -0
  15. package/src/commands/geometry.ts +45 -0
  16. package/src/commands/index.ts +24 -0
  17. package/src/commands/marks.ts +38 -0
  18. package/src/commands/mirror.ts +35 -0
  19. package/src/commands/probe.ts +43 -0
  20. package/src/commands/retarget/donor.ts +63 -0
  21. package/src/commands/retarget/index.ts +169 -0
  22. package/src/commands/retarget/map.ts +81 -0
  23. package/src/commands/retarget/report.ts +77 -0
  24. package/src/commands/scene.ts +103 -0
  25. package/src/commands/shared.ts +121 -0
  26. package/src/commands/sky.ts +35 -0
  27. package/src/commands/terrainPack.ts +40 -0
  28. package/src/commands/unpack.ts +43 -0
  29. package/src/commands/unreal.ts +109 -0
  30. package/src/commands/unwrap.ts +56 -0
  31. package/src/doctor/cardTrim.ts +286 -0
  32. package/src/doctor/doctor.ts +94 -30
  33. package/src/doctor/merge/accessors.ts +30 -0
  34. package/src/doctor/merge/atlasLayout.ts +94 -0
  35. package/src/doctor/merge/atlasPixels.ts +114 -0
  36. package/src/doctor/merge/bindSpace.ts +59 -0
  37. package/src/doctor/merge/classify.ts +136 -0
  38. package/src/doctor/merge/geometry.ts +167 -0
  39. package/src/doctor/merge/index.ts +98 -0
  40. package/src/doctor/merge/matrix.ts +60 -0
  41. package/src/doctor/merge/plan.ts +88 -0
  42. package/src/doctor/merge/textures.ts +60 -0
  43. package/src/doctor/merge/types.ts +77 -0
  44. package/src/doctor/transcodeKtx2.ts +179 -28
  45. package/src/doctor/validateGlb.ts +2 -2
  46. package/src/doctor/vertexAo.ts +321 -0
  47. package/src/fbx/read.ts +2 -0
  48. package/src/fbx/toDocument.ts +227 -209
  49. package/src/geometry.ts +222 -0
  50. package/src/index.ts +2 -0
  51. package/src/retarget/heading.ts +238 -171
  52. package/src/retarget/humanoid.ts +572 -561
  53. package/src/retarget/retarget.ts +2375 -2272
  54. package/src/retarget/rig.ts +4 -1
  55. package/src/retarget/writeMarks.ts +134 -132
  56. package/src/sky/cmgen.ts +3 -3
  57. package/src/sky/index.ts +57 -8
  58. package/src/terrainPack.ts +61 -22
  59. package/src/unity/colliderFit.ts +287 -0
  60. package/src/unity/fbxModel.ts +194 -0
  61. package/src/unity/foliage.ts +63 -0
  62. package/src/unity/gameObjects.ts +81 -0
  63. package/src/unity/guid.ts +104 -0
  64. package/src/unity/image.ts +58 -0
  65. package/src/unity/kindGlb.ts +364 -0
  66. package/src/unity/lecodesLevel.ts +381 -0
  67. package/src/unity/levelTerrain.ts +116 -0
  68. package/src/unity/matFile.ts +179 -0
  69. package/src/unity/materialSlots.ts +53 -0
  70. package/src/unity/materialWriter.ts +306 -0
  71. package/src/unity/materials.ts +34 -429
  72. package/src/unity/math.ts +82 -0
  73. package/src/unity/prefabTree.ts +285 -0
  74. package/src/unity/prefabs.ts +10 -36
  75. package/src/unity/propertyPath.ts +43 -0
  76. package/src/unity/scene.ts +325 -304
  77. package/src/unity/serialized.ts +242 -0
  78. package/src/unity/terrain.ts +221 -0
  79. package/src/unity/triBudget.ts +77 -0
  80. package/src/unity/triplanar.ts +122 -0
  81. package/src/unity/unityMesh.ts +151 -0
  82. package/src/unity/unpack.ts +24 -5
  83. package/src/unity/yaml.ts +131 -0
  84. package/src/unreal/index.ts +6 -5
  85. package/src/unreal/materialGraph.ts +224 -0
  86. package/src/unreal/materials.ts +117 -9
  87. package/src/unreal/package.ts +30 -12
  88. package/src/unreal/resolve.ts +6 -4
  89. package/src/unreal/sound.ts +128 -0
  90. package/src/unreal/texture.ts +70 -3
  91. package/src/unreal/toDocument.ts +7 -0
  92. package/src/unreal/versions.ts +8 -0
  93. package/src/unwrap/index.ts +167 -0
  94. package/src/unwrap/lightmapUv.ts +761 -0
  95. package/src/wasm/fbxReader.js +1 -1
  96. package/src/wasm/lightmapUv.js +1 -1
  97. package/src/doctor/lightmapUv.ts +0 -452
@@ -0,0 +1,179 @@
1
+ // A Unity `.mat` file read into its slots. Only YAML-serialised materials are fully readable (Unity
2
+ // ≥ 5 with "Force Text"); a BINARY `.mat` (many asset-store packs ship those) falls back to the
3
+ // pack's naming convention — its slots are inferred from the textures next to it.
4
+
5
+ import { existsSync, readFileSync } from "node:fs"
6
+ import { basename, dirname, resolve } from "node:path"
7
+ import { walkFiles } from "./guid"
8
+
9
+ export type UnityMaterial = {
10
+ name: string
11
+ file: string
12
+ /** Shader asset path (resolved) or the builtin id. */
13
+ shader: string
14
+ /** Texture slot → GUID (only slots that reference a texture). */
15
+ textures: Record<string, string>
16
+ floats: Record<string, number>
17
+ colors: Record<string, [number, number, number, number]>
18
+ keywords: string[]
19
+ /** Texture slot → `[scaleX, scaleY, offsetX, offsetY]` (Unity `m_Scale` / `m_Offset`) for the slots that are not (1, 1, 0, 0). */
20
+ tiling?: Record<string, [number, number, number, number]>
21
+ /** `m_LightmapFlags`: bit 4 = EmissiveIsBlack - Unity's own "this material does not emit", whatever the colours say;
22
+ * bit 2 = BakedEmissive, bit 1 = RealtimeEmissive - the inspector's "Global Illumination" (neither = None: the glow
23
+ * lights nothing; materialWriter writes it as the glTF extras `lecodes.emissiveBake: false`). */
24
+ lightmapFlags?: number
25
+ }
26
+
27
+ // ---- .mat parsing (YAML) ------------------------------------------------------------------------
28
+
29
+ /** Parse a YAML-serialised Unity material. Returns null for binary serialisation / not a material. */
30
+ export const parseUnityMaterial = (text: string, file: string): UnityMaterial | null => {
31
+ if (!text.startsWith("%YAML") || !/\nMaterial:\n/.test(text)) return null
32
+ const block = text.slice(text.indexOf("\nMaterial:\n"))
33
+ const name = /\n\s*m_Name: (.*)/.exec(block)?.[1]?.trim() || basename(file, ".mat")
34
+ const sh = /m_Shader: \{fileID: (-?\d+)(?:, guid: ([0-9a-f]{32}))?/.exec(text)
35
+ // Unity's built-in shaders live in two pseudo-assets whose GUIDs are all zeros but one digit
36
+ // (`…f000…` = builtin_extra: Standard 46, Standard (Specular) 45, Autodesk Interactive 47); the fileID
37
+ // is then the shader's identity, so those read as `builtin:<fileID>` like a GUID-less reference does.
38
+ const builtin = !sh?.[2] || /^0{16}[ef]0{15}$/.test(sh[2])
39
+ const shader = sh ? (builtin ? `builtin:${sh[1]}` : `guid:${sh[2]}`) : ""
40
+ const textures: Record<string, string> = {}
41
+ const texRe = /\n\s+- (\w+):\n\s+m_Texture: \{fileID: -?\d+, guid: ([0-9a-f]{32}), type: \d+\}/g
42
+ for (let m = texRe.exec(text); m; m = texRe.exec(text)) textures[m[1]] = m[2]
43
+ // a slot's tiling — a detail layer's bark repeats 3× along the trunk (NatureManufacture `_DetailAlbedoMap` (1, 3))
44
+ const tiling: Record<string, [number, number, number, number]> = {}
45
+ const stRe = /\n\s+- (\w+):\n\s+m_Texture: \{fileID: -?\d+, guid: [0-9a-f]{32}, type: \d+\}\n\s+m_Scale: \{x: (-?[\d.eE+-]+), y: (-?[\d.eE+-]+)\}\n\s+m_Offset: \{x: (-?[\d.eE+-]+), y: (-?[\d.eE+-]+)\}/g
46
+ for (let m = stRe.exec(text); m; m = stRe.exec(text)) { const st: [number, number, number, number] = [ Number(m[2]), Number(m[3]), Number(m[4]), Number(m[5]) ]; if (st[0] !== 1 || st[1] !== 1 || st[2] !== 0 || st[3] !== 0) tiling[m[1]] = st }
47
+ const floats: Record<string, number> = {}
48
+ const floatSection = /m_Floats:\n([\s\S]*?)\n\s+m_Colors:/.exec(text)?.[1] ?? ""
49
+ const fRe = /- (\w+): (-?[\d.eE+-]+)/g
50
+ for (let m = fRe.exec(floatSection); m; m = fRe.exec(floatSection)) floats[m[1]] = Number(m[2])
51
+ const colors: Record<string, [number, number, number, number]> = {}
52
+ const cRe = /- (\w+): \{r: (-?[\d.eE+-]+), g: (-?[\d.eE+-]+), b: (-?[\d.eE+-]+), a: (-?[\d.eE+-]+)\}/g
53
+ 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]) ]
54
+ // keywords: one space-separated line before Unity 2021, a `m_ValidKeywords:` list since
55
+ const keywords = (/m_ShaderKeywords: (.*)/.exec(text)?.[1] ?? "").trim().split(/\s+/).filter(Boolean)
56
+ const valid = /\n\s*m_ValidKeywords:\n((?:\s*- .*\n)*)/.exec(text)?.[1]
57
+ if (valid) for (const k of valid.split("\n")) { const m = /^\s*- (\S+)/.exec(k); if (m && !keywords.includes(m[1])) keywords.push(m[1]) }
58
+ const lightmapFlags = Number(/m_LightmapFlags: (\d+)/.exec(text)?.[1] ?? NaN)
59
+ return { name, file, shader, textures, floats, colors, keywords, tiling: Object.keys(tiling).length ? tiling : undefined, lightmapFlags: Number.isFinite(lightmapFlags) ? lightmapFlags : undefined }
60
+ }
61
+
62
+ export const isBinaryUnityAsset = (bytes: Uint8Array): boolean => !(bytes[0] === 0x25 && bytes[1] === 0x59 && bytes[2] === 0x41) // "%YA"
63
+
64
+ // ---- what a material says about itself ------------------------------------------------------------
65
+
66
+ /** Unity's alpha-cutout switch across pipelines: URP `_AlphaClip`, HDRP `_AlphaCutoffEnable`, Standard `_Mode` 1,
67
+ * or the keyword — what becomes glTF MASK (and marks a kind as foliage for the level writer). */
68
+ export const isCutoutMaterial = (um: UnityMaterial): boolean => {
69
+ const clip = um.floats._AlphaClip ?? um.floats._AlphaCutoffEnable
70
+ return clip === 1 || um.floats._Mode === 1 || um.keywords.includes("_ALPHATEST_ON")
71
+ }
72
+
73
+ /**
74
+ * The material carries its pipeline's own cutout SWITCH and that switch is OFF — i.e. it has said,
75
+ * in the field its shader actually reads, that it is not a cutout. Only `_AlphaClip` (URP) and
76
+ * `_AlphaCutoffEnable` (HDRP) count: `_Mode` is the Standard shader's field and packs leave a stale
77
+ * `_Mode: 0` on materials whose custom shader never looks at it, which is the whole reason the
78
+ * texture heuristic below exists.
79
+ */
80
+ export const declaresNoCutout = (um: UnityMaterial): boolean =>
81
+ um.floats._AlphaClip === 0 || um.floats._AlphaCutoffEnable === 0
82
+
83
+ /** The material's WIND PROFILE — Unity HDRP's Wind.hlsl properties, which NatureManufacture's vegetation shaders carry
84
+ * per material: `_Drag` (how much of the wind the plant takes: trees 0.1, grass 1-2), `_Stiffness` (the bend's growth
85
+ * along the plant), `_InitialBend` (a permanent lean downwind), `_ShiverDrag` (the leaves' own motion: needles 0.02,
86
+ * grass 0.1-0.2, bark ~0) and `_ShiverDirectionality` (0 = along the normal, 1 = along the wind). Goes into the glTF
87
+ * material extras as `lecodes.wind`, which creator-gl's foliage tier reads (glb.cpp foliageProfileFromExtras); a
88
+ * material without the two defining floats has no profile and the engine picks one by the plant's size. */
89
+ export const windProfile = (um: UnityMaterial): [number, number, number, number, number] | null => {
90
+ const f = um.floats
91
+ if (f._Drag === undefined || f._Stiffness === undefined) return null
92
+ return [ f._Drag, f._Stiffness, f._InitialBend ?? 0, f._ShiverDrag ?? 0, f._ShiverDirectionality ?? 0.5 ]
93
+ }
94
+
95
+ /** Normalise a material name for matching: case, and the M_/MI_/MAT_ prefixes packs sprinkle. */
96
+ export const materialKey = (n: string): string => n.trim().toLowerCase().replace(/^(mi|m|mat|mtl)_/, "").replace(/\.\d+$/, "")
97
+
98
+ // ---- binary materials, collecting ------------------------------------------------------------------
99
+
100
+ /** Texture references an inferred material carries: a pseudo-GUID naming the file directly. */
101
+ export const PATH_GUID = "path:"
102
+ const IMAGE_RE = /\.(png|jpe?g|tga|bmp|tiff?|psd|exr)$/i
103
+ /** Suffix conventions (after the material name, case-insensitive) → Unity slot. The base colour is
104
+ * the bare name or one of the albedo suffixes. */
105
+ const SUFFIX_SLOTS: [RegExp, string][] = [
106
+ [ /^$|^_(d|bc|albedo|diffuse|diff|basecolor|base|col|color)$/i, "_MainTex" ],
107
+ [ /^_(nm|n|nrm|normal|normalmap|norm)$/i, "_BumpMap" ],
108
+ [ /^_(ms|metallicsmoothness|metallicgloss|mg)$/i, "_MetallicGlossMap" ],
109
+ [ /^_(m|metallic|metal|met)$/i, "_Metallic" ],
110
+ [ /^_(r|rough|roughness)$/i, "_Roughness" ],
111
+ [ /^_(s|smoothness|gloss)$/i, "_Smoothness" ],
112
+ [ /^_(ao|occlusion|ambientocclusion)$/i, "_OcclusionMap" ],
113
+ [ /^_(e|em|emission|emissive|emit)$/i, "_EmissionMap" ],
114
+ [ /^_(orm|ormh)$/i, "_ORM" ],
115
+ [ /^_(mask|maskmap)$/i, "_MaskMap" ],
116
+ ]
117
+
118
+ /**
119
+ * A binary-serialised `.mat` can't be parsed, but asset-store packs name their textures after the
120
+ * material: `Seamless_asphalt_v1.mat` next to `Source/Seamless_asphalt_v1.tga` + `…_NM.tga`. Scan the
121
+ * .mat's folder (and its subfolders, two deep) for images whose stem is the material name plus a
122
+ * known suffix and build a material whose texture refs point at those files (`path:` pseudo-GUIDs,
123
+ * resolved without an index). Null when no base colour is found — a wrong guess is worse than none.
124
+ */
125
+ export const inferBinaryMaterial = (file: string): UnityMaterial | null => {
126
+ const name = basename(file, ".mat")
127
+ const dir = dirname(file)
128
+ const images: string[] = []
129
+ walkFiles(dir, 2, images, (n) => IMAGE_RE.test(n))
130
+ const textures: Record<string, string> = {}
131
+ const lname = name.toLowerCase()
132
+ for (const img of images.sort()) {
133
+ const stem = basename(img).replace(/\.[^.]+$/, "")
134
+ if (!stem.toLowerCase().startsWith(lname)) continue
135
+ const suffix = stem.slice(name.length)
136
+ const slot = SUFFIX_SLOTS.find(([ re ]) => re.test(suffix))?.[1]
137
+ if (slot && !textures[slot]) textures[slot] = PATH_GUID + img
138
+ }
139
+ if (!textures._MainTex) return null
140
+ return { name, file, shader: "binary", textures, floats: {}, colors: {}, keywords: [] }
141
+ }
142
+
143
+ /** Every readable `.mat` under `dirs` (recursive) plus `extraFiles`, keyed by normalised name.
144
+ * Binary ones are listed in `binary`; those whose textures could be inferred from their neighbours
145
+ * (`inferBinaryMaterial`) are ALSO used and listed in `inferred`. */
146
+ export const collectUnityMaterials = (dirs: string[], depth = 6, extraFiles: string[] = []): { byKey: Map<string, UnityMaterial>, byFile: Map<string, UnityMaterial>, binary: string[], inferred: string[] } => {
147
+ const files: string[] = []
148
+ for (const d of dirs) walkFiles(d, depth, files, (n) => /\.mat$/i.test(n))
149
+ const seen = new Set(files.map((f) => resolve(f)))
150
+ for (const f of extraFiles) { const a = resolve(f); if (!seen.has(a) && existsSync(a)) { seen.add(a); files.push(a) } }
151
+ const byKey = new Map<string, UnityMaterial>()
152
+ // Keyed by ABSOLUTE PATH as well: `materialKey` strips the M_/MI_ prefix, so a pack that ships a
153
+ // real `Materials/M_Wall.mat` beside the FBX's placeholder `Assets/Materials/MI_Wall.mat` collapses
154
+ // both onto one key and whichever was walked first wins. A prefab override names its material by
155
+ // GUID — an exact file — and must be looked up as one.
156
+ const byFile = new Map<string, UnityMaterial>()
157
+ const binary: string[] = []
158
+ const inferred: string[] = []
159
+ const put = (m: UnityMaterial, f: string) => {
160
+ byFile.set(resolve(f), m)
161
+ const k = materialKey(m.name)
162
+ if (!byKey.has(k)) byKey.set(k, m)
163
+ const fk = materialKey(basename(f, ".mat"))
164
+ if (!byKey.has(fk)) byKey.set(fk, m)
165
+ }
166
+ for (const f of files) {
167
+ const bytes = new Uint8Array(readFileSync(f))
168
+ if (isBinaryUnityAsset(bytes)) {
169
+ binary.push(f)
170
+ const m = inferBinaryMaterial(f)
171
+ if (m) { inferred.push(f); put(m, f) }
172
+ continue
173
+ }
174
+ const m = parseUnityMaterial(new TextDecoder().decode(bytes), f)
175
+ if (!m) continue
176
+ put(m, f)
177
+ }
178
+ return { byKey, byFile, binary, inferred }
179
+ }
@@ -0,0 +1,53 @@
1
+ // WHICH MATERIAL SLOT BELONGS TO WHICH PRIMITIVE.
2
+ //
3
+ // A MeshRenderer's `m_Materials` is indexed by UNITY's submesh order, and that is NOT always the
4
+ // order ufbx hands us the primitives in: for Chernobyl's `Balcony_13` the two orders agree on LOD0
5
+ // and disagree on LOD1, so assigning the list by POSITION put the trim's material on the wall, the
6
+ // balcony panel's on the trim and the wall's on the glass — on the far level only. On screen that is
7
+ // a building whose textures rearrange themselves as you walk up to it, and it is invisible in any
8
+ // check that compares the SET of materials per level, because the set is identical.
9
+ //
10
+ // The pairing is recoverable because both sides name the same FBX material: a slot holds either
11
+ // `name:<fbx material>` or the GUID that this model's `.meta` remaps that name to — and when the
12
+ // `.meta` remaps nothing (a pack that assigns materials in its PREFABS ships an empty one), the slot
13
+ // still has a name: the `.mat`'s own. Packs name those after the FBX slot they fill.
14
+
15
+ import { materialKey } from "./matFile"
16
+
17
+ /**
18
+ * For each primitive (by its FBX material name) the index of the slot it takes, −1 for none.
19
+ * `slotNames[k]` is what slot k is known as (null when nothing names it).
20
+ *
21
+ * 1. equal names;
22
+ * 2. equal KEYS (M_/MI_ prefixes aside), then one key containing the other — taken only when exactly
23
+ * one slot fits. ORDER IS NOT A PAIRING: what was left over used to be handed out in primitive
24
+ * order, and `SM_Wall_Door_04`'s wall reads as [Wall, Wall_Exterior] here while the prefab
25
+ * assigns [Brick_Wall, Wall], so the brick ended up on the INSIDE face. By name `Wall` ↔
26
+ * `Wall.mat` and `Metal` ↔ `Metal_01.mat` pair, which leaves `Wall_Exterior` ↔ `Brick_Wall.mat`
27
+ * as the only way round for the rest;
28
+ * 3. what is left over to what is left over, in order — which is exactly how an OVERRIDDEN slot
29
+ * finds its primitive, since an override's GUID matches no FBX name.
30
+ */
31
+ export const pairSlots = (fbxNames: string[], slotNames: (string | null)[]): number[] => {
32
+ const slotFor = new Array<number>(fbxNames.length).fill(-1)
33
+ const used = new Set<number>()
34
+ const take = (i: number, k: number): void => { slotFor[i] = k; used.add(k) }
35
+ for (let i = 0; i < fbxNames.length; i++) {
36
+ const j = slotNames.findIndex((n, k) => n !== null && n === fbxNames[i] && !used.has(k))
37
+ if (j >= 0) take(i, j)
38
+ }
39
+ for (let i = 0; i < fbxNames.length; i++) {
40
+ if (slotFor[i] >= 0 || !fbxNames[i]) continue
41
+ const a = materialKey(fbxNames[i])
42
+ const free = slotNames.map((n, k) => ({ n, k })).filter((x) => x.n !== null && !used.has(x.k))
43
+ let hit = free.find((x) => materialKey(x.n!) === a)
44
+ if (!hit && a.length >= 3) {
45
+ const near = free.filter((x) => { const b = materialKey(x.n!); return b.length >= 3 && (b.includes(a) || a.includes(b)) })
46
+ if (near.length === 1) hit = near[0]
47
+ }
48
+ if (hit) take(i, hit.k)
49
+ }
50
+ const spare = slotNames.map((_, k) => k).filter((k) => !used.has(k))
51
+ for (let i = 0; i < fbxNames.length; i++) if (slotFor[i] < 0 && spare.length) slotFor[i] = spare.shift()!
52
+ return slotFor
53
+ }
@@ -0,0 +1,306 @@
1
+ // One Unity material → one glTF material: base colour, normal, emissive, alpha mode, and ONE
2
+ // metallic-roughness(+occlusion) texture packed the glTF way (R = AO, G = roughness, B = metallic),
3
+ // re-encoding Unity's layouts (Standard `_MetallicGlossMap` R=metal A=smooth, HDRP/URP `_MaskMap`
4
+ // R=metal G=AO A=smooth, separate Metallic / Roughness / Smoothness / AO maps, or a ready `_ORM`),
5
+ // plus the shader's own SCALE on those maps (`_RoughnessScale` / `_MetallicScale` → the glTF
6
+ // factors, which multiply the same way).
7
+
8
+ import { readFileSync } from "node:fs"
9
+ import { basename } from "node:path"
10
+ import type { Document, Material, Texture } from "@gltf-transform/core"
11
+ import { KHRMaterialsEmissiveStrength } from "@gltf-transform/extensions"
12
+ import { sniffMime, encodePng, type Rgba } from "../textures/resolveImage"
13
+ import type { GuidResolver } from "./guid"
14
+ import { alphaIsKeyed, decodeImage, packOrm, type PackInput } from "./image"
15
+ import { PATH_GUID, declaresNoCutout, isCutoutMaterial, type UnityMaterial } from "./matFile"
16
+
17
+ export type Warn = (w: { level: "warn" | "info", message: string }) => void
18
+
19
+ // ---- slot vocabulary --------------------------------------------------------------------------------
20
+
21
+ // `_Main*` is the vertex-PAINT convention: a shadergraph that blends a base layer with per-vertex-
22
+ // colour layers (R/G/B channels) names the base one "Main". Nothing here can run the graph, so the
23
+ // main layer IS the material — far better than the white nothing those slot names used to produce.
24
+ // `_Base_Color` / `_Base_Map` / `_Normal_Map` are the UNDERSCORED spellings a hand-written shader
25
+ // graph produces from a property display name ("Base Color" -> `_Base_Color`), and they are common:
26
+ // Chernobyl's vegetation shader names its albedo ONLY `_Base_Color`, which left 1400+ of its trees
27
+ // untextured, and the school pack has three `_Base_Map` materials of its own. Missing a base slot is
28
+ // the quietest failure in this file — the material still carries a colour, so it reads as flat art
29
+ // rather than as a lost texture.
30
+ export const SLOTS = {
31
+ base: [ "_BaseMap", "_Base_Map", "_MainTex", "_BaseColor", "_Base_Color", "_BaseColorMap", "_BC", "_Albedo", "_AlbedoMap", "_Diffuse", "_DiffuseMap", "_Color", "_MainBC", "_MainTex1", "_Diffuse_Tex", "_Albedo_Tex", "_Base_Tex" ],
32
+ normal: [ "_BumpMap", "_NormalMap", "_Normal_Map", "_Normal", "_NormalTex", "_MainNormal", "_Normal_Tex" ],
33
+ // `_ORMH` = Occlusion / Roughness / Metallic / Height — the same RGB as an ORM with the height in
34
+ // alpha (glTF's MR sampler ignores alpha, so it reads as an ORM). MISSING IT IS EXPENSIVE: the
35
+ // Modular Shooting Range's materials carry their whole shading in `_ORMH` and ALSO point
36
+ // `_OcclusionMap` at the same file, so the pass took the AO channel alone and filled the other two
37
+ // from the floats — `_Metallic: 1` — and every sign, locker and pipe in the level came out a bare
38
+ // mirror (2026-09-17: measured B = 1.000 in the import against 0.009 in T_Signs_ORM.png).
39
+ orm: [ "_ORM", "_ORMMap", "_ORMH", "_ORMHMap", "_OcclusionRoughnessMetallic", "_ORMTex", "_MainORM" ],
40
+ // `_MetalicRAOGSmothnessA` = NatureManufacture's own shaders: the HDRP layout under another name
41
+ maskMap: [ "_MaskMap", "_MetalicRAOGSmothnessA" ],
42
+ metallicGloss: [ "_MetallicGlossMap" ],
43
+ metallic: [ "_Metallic", "_MetallicMap", "_MetallicTex", "_MetalMap" ],
44
+ roughness: [ "_Roughness", "_RoughnessMap", "_RoughnessTex", "_Roughness_Tex" ],
45
+ smoothness: [ "_Smoothness", "_SmoothnessMap", "_SmoothnessTex", "_Gloss", "_GlossMap" ],
46
+ occlusion: [ "_OcclusionMap", "_Occlusion", "_AO", "_AOMap", "_AmbientOcclusion" ],
47
+ emission: [ "_EmissionMap", "_Emission", "_Emissive", "_EmissiveMap", "_EmissiveColorMap" ],
48
+ alpha: [ "_Alpha", "_Opacity", "_OpacityMap", "_AlphaMap", "_OpacityMask" ],
49
+ }
50
+ export const pick = (m: UnityMaterial, names: string[]): string | null => { for (const n of names) { const g = m.textures[n]; if (g) return g } return null }
51
+
52
+ /** Writes Unity `.mat` materials into glTF materials of `doc` (textures decoded / packed once per
53
+ * document). `applyTo` rewrites one glTF material from one Unity material and returns a slot summary. */
54
+ export const unityMaterialWriter = (doc: Document, resolver: GuidResolver, warn: Warn = () => {}): { applyTo: (gm: Material, um: UnityMaterial, how: string) => Promise<string[]> } => {
55
+ const texCache = new Map<string, Texture | null>()
56
+ const imgCache = new Map<string, Rgba | null>()
57
+ const loadBytes = (guid: string): { bytes: Uint8Array, file: string } | null => {
58
+ const f = guid.startsWith(PATH_GUID) ? guid.slice(PATH_GUID.length) : resolver.resolve(guid)
59
+ if (!f) { warn({ level: "warn", message: `texture ${guid}: not in the index / not extracted — skipped` }); return null }
60
+ return { bytes: new Uint8Array(readFileSync(f)), file: f }
61
+ }
62
+ const texFromGuid = async (guid: string): Promise<Texture | null> => {
63
+ if (texCache.has(guid)) return texCache.get(guid)!
64
+ const src = loadBytes(guid)
65
+ let tex: Texture | null = null
66
+ if (src) {
67
+ const mime = sniffMime(src.bytes, src.file)
68
+ let data = src.bytes, outMime: "image/png" | "image/jpeg" = "image/png"
69
+ if (mime === "image/png" || mime === "image/jpeg") outMime = mime
70
+ else {
71
+ const img = await decodeImage(src.bytes, src.file)
72
+ if (!img) { warn({ level: "warn", message: `texture ${basename(src.file)}: unsupported format (${mime}) — skipped` }); texCache.set(guid, null); return null }
73
+ data = encodePng(img)
74
+ }
75
+ tex = doc.createTexture(basename(src.file).replace(/\.[^.]+$/, "")).setImage(data).setMimeType(outMime)
76
+ }
77
+ texCache.set(guid, tex)
78
+ return tex
79
+ }
80
+ const imgFromGuid = async (guid: string): Promise<Rgba | null> => {
81
+ if (imgCache.has(guid)) return imgCache.get(guid)!
82
+ const src = loadBytes(guid)
83
+ const img = src ? await decodeImage(src.bytes, src.file) : null
84
+ if (src && !img) warn({ level: "warn", message: `texture ${basename(src.file)}: could not decode for packing — skipped` })
85
+ imgCache.set(guid, img)
86
+ return img
87
+ }
88
+ const nameOf = (guid: string): string => { const f = guid.startsWith(PATH_GUID) ? guid.slice(PATH_GUID.length) : resolver.resolve(guid); return f ? basename(f) : guid }
89
+
90
+ /** Rewrite one glTF material from a Unity material; returns the slot summary. */
91
+ const applyTo = async (gm: Material, um: UnityMaterial, how: string): Promise<string[]> => {
92
+ const slots: string[] = how === "name" ? [] : [ `(matched by ${how})` ]
93
+ if (um.shader === "binary") slots.push("(binary .mat — slots inferred from sibling textures)")
94
+ // base colour (+ the FBX's base when the .mat has none — keep whatever is there)
95
+ // the first base slot whose texture EXISTS: custom shaders keep stale refs in the usual slots (a
96
+ // tree-billboard shader's `_BaseColor` names a deleted file, its atlas sits in `_MainTex1`)
97
+ const baseG = SLOTS.base.map((n) => um.textures[n]).find((g) => g && (g.startsWith(PATH_GUID) || resolver.resolve(g))) ?? null
98
+ if (baseG) { const t = await texFromGuid(baseG); if (t) { gm.setBaseColorTexture(t); slots.push(`base=${nameOf(baseG)}`) } }
99
+ const baseColor = um.colors._BaseColor ?? um.colors._Color
100
+ if (baseColor) gm.setBaseColorFactor([ baseColor[0], baseColor[1], baseColor[2], gm.getBaseColorFactor()[3] ])
101
+ // EVERY slot is picked the way the base is: the first candidate whose texture EXISTS. A `.mat` keeps the
102
+ // property block of every shader it ever wore, so a NatureManufacture bark that moved from HDRP Lit to
103
+ // the pack's own shader still names a deleted `_MaskMap` beside the live `_MetalicRAOGSmothnessA` — and
104
+ // taking the dead one first left the trunk with no pack at all, i.e. `_Smoothness: 1` = a mirror.
105
+ const live = (names: string[]): string | null => names.map((n) => um.textures[n]).find((g) => g && (g.startsWith(PATH_GUID) || resolver.resolve(g))) ?? null
106
+ const nG = live(SLOTS.normal)
107
+ if (nG) { const t = await texFromGuid(nG); if (t) { gm.setNormalTexture(t); gm.setNormalScale(um.floats._BumpScale ?? 1); slots.push(`normal=${nameOf(nG)}`) } }
108
+
109
+ // metallic / roughness / occlusion
110
+ // Unity's built-in "Autodesk Interactive" shader (builtin fileID 47 — what a Maya / 3ds Max PBR
111
+ // material imports as) reuses the Standard shader's slot NAMES with other meanings: `_SpecGlossMap`
112
+ // is a ROUGHNESS map, `_MetallicGlossMap` a plain metallic map (its alpha is NOT smoothness — it
113
+ // ships opaque, and reading it as smoothness 1 turned a cloth glove into a mirror), and
114
+ // `_Glossiness` is the roughness slider.
115
+ const autodesk = um.shader === "builtin:47"
116
+ const ormG = live(SLOTS.orm), maskG = live(SLOTS.maskMap), mgG = autodesk ? null : live(SLOTS.metallicGloss)
117
+ const metG = live(SLOTS.metallic) ?? (autodesk ? live(SLOTS.metallicGloss) : null)
118
+ const roughG = live(SLOTS.roughness) ?? (autodesk ? um.textures._SpecGlossMap ?? null : null)
119
+ const smoothG = live(SLOTS.smoothness), aoG = live(SLOTS.occlusion)
120
+ /** Metallic / roughness as plain NUMBERS off the material.
121
+ *
122
+ * This runs when no map covers them — including when the material NAMES metal-ish slots that
123
+ * then fail to resolve or decode, which used to fall through and set nothing at all. glTF's
124
+ * default metallicFactor is 1 and Unity's `_Metallic` default is 0, so "set nothing" means
125
+ * EVERY SUCH MATERIAL BECOMES A MIRROR: `Maple_Leaves` says `_Metallic: 0` in the .mat, names a
126
+ * `_Mask` and a `_Smoothness` texture that are not in the pack, and arrived at metallic 1.00 —
127
+ * a rough metal has no diffuse, so Chernobyl's maples reflected the sky and read as silver. */
128
+ const scalarMr = (): void => {
129
+ // ALWAYS a number (2026-10-18): a shader with no `_Metallic` at all — NatureManufacture's Cross Model
130
+ // Shader (the trees' LOD3 billboard cross, a specular shader with `_Specular` 0) — left the glTF default
131
+ // of 1 standing, and every far tree in the forest was a black cut-out: a metal has no diffuse. Unity's
132
+ // default is 0, so the absence means 0
133
+ gm.setMetallicFactor(um.floats._Metallic ?? 0)
134
+ if (smoothness !== undefined) gm.setRoughnessFactor(1 - smoothness)
135
+ slots.push(`metallic=${gm.getMetallicFactor().toFixed(2)} roughness=${gm.getRoughnessFactor().toFixed(2)}`)
136
+ }
137
+ const smoothness = autodesk ? (um.floats._Glossiness !== undefined ? 1 - um.floats._Glossiness : undefined) : um.floats._Smoothness ?? um.floats._Glossiness
138
+ // A shader's SCALE on top of the maps. glTF's metallicFactor / roughnessFactor multiply their
139
+ // texture exactly the way these do, so they carry over one-to-one — and dropping them is not a
140
+ // cosmetic loss: KINEMATION's weapon shader ships `_RoughnessScale: 0.65`, so a gun imported
141
+ // without it renders 54 % rougher than the artist set it, which reads as plastic instead of
142
+ // gunmetal. Every weapon in that pack sets 0.6-0.8; only characters leave it at 1.
143
+ // Names differ per shader, so accept the ones seen in the wild.
144
+ const scaleOf = (...keys: string[]): number => {
145
+ for (const k of keys) { const v = um.floats[k]; if (v !== undefined && v >= 0 && v <= 1) return v }
146
+ return 1
147
+ }
148
+ const roughScale = scaleOf("_RoughnessScale", "_RoughnessMultiplier", "_RoughnessStrength")
149
+ const metalScale = scaleOf("_MetallicScale", "_MetallicMultiplier", "_MetallicStrength")
150
+ const setMr = (t: Texture, withAo: boolean, label: string): void => {
151
+ gm.setMetallicRoughnessTexture(t).setMetallicFactor(metalScale).setRoughnessFactor(roughScale)
152
+ if (withAo) gm.setOcclusionTexture(t).setOcclusionStrength(um.floats._OcclusionStrength ?? 1)
153
+ if (roughScale !== 1 || metalScale !== 1) {
154
+ slots.push(`scale metallic=${metalScale} roughness=${roughScale}`)
155
+ }
156
+ slots.push(label)
157
+ }
158
+ if (ormG) {
159
+ const t = await texFromGuid(ormG)
160
+ if (t) setMr(t, true, `orm=${nameOf(ormG)}`)
161
+ } else if (maskG || mgG || metG || roughG || smoothG || aoG) {
162
+ // pack: R = AO, G = roughness, B = metallic
163
+ let ao: PackInput | null = null, rough: PackInput | null = null, metal: PackInput | null = null
164
+ const parts: string[] = []
165
+ 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)}`) } }
166
+ if (mgG) { const img = await imgFromGuid(mgG); if (img) { metal = { img, channel: 0 }; rough = { img, channel: 3, invert: true }; parts.push(`MetallicGloss ${nameOf(mgG)}`) } }
167
+ if (metG) { const img = await imgFromGuid(metG); if (img) { metal = { img, channel: 0 }; parts.push(`metallic ${nameOf(metG)}`) } }
168
+ if (roughG) { const img = await imgFromGuid(roughG); if (img) { rough = { img, channel: 0 }; parts.push(`roughness ${nameOf(roughG)}`) } }
169
+ else if (smoothG) { const img = await imgFromGuid(smoothG); if (img) { rough = { img, channel: 0, invert: true }; parts.push(`smoothness ${nameOf(smoothG)}`) } }
170
+ if (aoG) { const img = await imgFromGuid(aoG); if (img) { ao = { img, channel: 0 }; parts.push(`ao ${nameOf(aoG)}`) } }
171
+ if (metal || rough || ao) {
172
+ // scalar fallbacks for the channels no map covers
173
+ const dflt = { ao: 255, rough: Math.round((1 - (smoothness ?? 0.5)) * 255), metal: Math.round((um.floats._Metallic ?? 0) * 255) }
174
+ const packed = packOrm(ao, rough, metal, dflt)
175
+ const t = doc.createTexture(`${um.name}_ORM`).setImage(encodePng(packed)).setMimeType("image/png")
176
+ setMr(t, !!ao, `packed ORM ← ${parts.join(" + ")}`)
177
+ } else scalarMr()
178
+ } else scalarMr()
179
+
180
+ // emission: only when the material actually emits (keyword or a non-black colour)
181
+ const emG = live(SLOTS.emission)
182
+ // `_EmissiveColor` FIRST: HDRP's Lit shader emits by that one and leaves the Standard shader's
183
+ // `_EmissionColor` sitting in the property block at white, so reading `_EmissionColor` made every
184
+ // surface of an HDRP pack a self-lit white card — a whole imported level rendered as blank
185
+ // cardboard. URP / Standard materials carry no `_EmissiveColor`, so they are unaffected.
186
+ // ... EXCEPT a material whose `_EMISSION` keyword is on: that is URP / Standard speaking, and there `_EmissionColor`
187
+ // is the live one. A URP pack upgraded from HDRP keeps dead `_EmissiveColor` / `_EmissiveIntensity` fields (the
188
+ // school, 2026-09-18: Ceiling_02 emits at `_EmissionColor` 10 while its `_EmissiveColor` says 1200 nits).
189
+ const emC = um.keywords.includes("_EMISSION") ? um.colors._EmissionColor ?? um.colors._EmissiveColor : um.colors._EmissiveColor ?? um.colors._EmissionColor
190
+ // An explicitly BLACK _EmissionColor is a veto, not a missing value: Unity multiplies the
191
+ // emission map by that colour, so black means the surface does not emit however many maps are
192
+ // bound. Without this the "a map and no keywords, so assume it emits" fallback below fires on
193
+ // custom-shader materials that keep a texture in the slot and switch it off with the colour —
194
+ // which lit a whole rifle body through a magazine's emission map.
195
+ // `m_LightmapFlags` bit 4 (EmissiveIsBlack) is Unity's own veto, in every pipeline: the inspector sets it when the
196
+ // emission is switched off. The school's Ceiling_03 has no `_EMISSION`, this flag, and a dead HDRP `_EmissiveColor`
197
+ // of 0.125 - it was imported as a dim emitter.
198
+ // ... but ONLY for the Standard / URP Lit family, where the inspector couples the flag with the `_EMISSION` keyword.
199
+ // HDRP (and the shadergraphs of HDRP packs) leaves the flag at 4 on materials that glow by `_EmissiveColor` - there it
200
+ // only says "no contribution to baked GI" (the range's M_Lamp_long: `_EmissiveColor` 4, flags 4, and it emits).
201
+ const keywordShader = um.shader === "builtin:45" || um.shader === "builtin:46"
202
+ || um.shader === "guid:933532a4fcc9baf4fa0491de14d08ed7" || um.shader === "guid:8d2bb70cbf9db8d4da26e15b26e74248" // URP Lit, Simple Lit
203
+ const flagVeto = keywordShader && ((um.lightmapFlags ?? 0) & 4) !== 0 && !um.keywords.includes("_EMISSION")
204
+ const emissionVetoed = (emC !== undefined && emC[0] + emC[1] + emC[2] <= 0.01) || flagVeto
205
+ const emits = !emissionVetoed && (um.keywords.includes("_EMISSION")
206
+ || (emC ? emC[0] + emC[1] + emC[2] > 0.01 : false)
207
+ || (!!emG && !um.keywords.length))
208
+ // an HDR emission colour (Unity's intensity is in it): glTF's factor stops at 1, the rest is
209
+ // KHR_materials_emissive_strength - the factor keeps the hue, the strength the brightest channel
210
+ const setEmission = (c: readonly number[] | undefined): void => {
211
+ const lit = c && c[0] + c[1] + c[2] > 0.01 ? c : [ 1, 1, 1 ]
212
+ const peak = Math.max(lit[0], lit[1], lit[2], 1)
213
+ gm.setEmissiveFactor([ lit[0] / peak, lit[1] / peak, lit[2] / peak ])
214
+ if (peak > 1.001) {
215
+ gm.setExtension("KHR_materials_emissive_strength", doc.createExtension(KHRMaterialsEmissiveStrength).createEmissiveStrength().setEmissiveStrength(peak))
216
+ slots.push(`emissiveStrength=${peak.toFixed(2)}`)
217
+ }
218
+ }
219
+ if (emG && emits) {
220
+ const t = await texFromGuid(emG)
221
+ if (t) { gm.setEmissiveTexture(t); setEmission(emC); slots.push(`emissive=${nameOf(emG)}`) }
222
+ } else if (emC && emits && !emG) setEmission(emC)
223
+ else if (!emits) {
224
+ // The Unity material is the authority and it says this surface does NOT emit — so CLEAR what
225
+ // the FBX bound (toDocument.ts binds fbx emissiveTex with factor 1,1,1 whenever the mesh file
226
+ // names one). DCC exports routinely carry an emission slot the Unity material later overrode
227
+ // to none; leaving it produced a rifle body glowing through a magazine's emission map, which
228
+ // only became visible once the textures stopped being mushy.
229
+ if (gm.getEmissiveTexture()) { gm.setEmissiveTexture(null); slots.push("emissive=off") }
230
+ const ef = gm.getEmissiveFactor()
231
+ if (ef[0] + ef[1] + ef[2] > 0) gm.setEmissiveFactor([ 0, 0, 0 ])
232
+ }
233
+ // THE GI SWITCH: does the glow light the level in a lightmap bake? Unity says it per material in `m_LightmapFlags`
234
+ // (the inspector's "Global Illumination"): bit 2 BakedEmissive = Baked, bit 1 RealtimeEmissive = Realtime (which a
235
+ // project without realtime GI bakes as well), neither = None — drawn, lighting nothing; the pack lights the room
236
+ // with lamps instead (the range's M_Lamp_long: `_EmissiveColor` 4, flags 4 = EmissiveIsBlack alone). The engine's
237
+ // bake reads the glTF extras {"lecodes":{"emissiveBake":false}} (creator-gl lightmap_bake.cpp). A material with no
238
+ // `m_LightmapFlags` at all keeps the default: it bakes. MERGED into the extras (the foliage `wind` profile lives there).
239
+ const giNone = emits && um.lightmapFlags !== undefined && (um.lightmapFlags & 3) === 0
240
+ const extras = (gm.getExtras() ?? {}) as Record<string, unknown>
241
+ const lecodes = extras.lecodes as Record<string, unknown> | undefined
242
+ if (giNone) {
243
+ gm.setExtras({ ...extras, lecodes: { ...(lecodes ?? {}), emissiveBake: false } })
244
+ slots.push("emissiveBake=false (Unity GI: None)")
245
+ } else if (lecodes && "emissiveBake" in lecodes) {
246
+ // a re-import of a material whose flags changed: the switch goes
247
+ const { emissiveBake: _off, ...rest } = lecodes
248
+ const { lecodes: _l, ...others } = extras
249
+ gm.setExtras(Object.keys(rest).length ? { ...others, lecodes: rest } : others)
250
+ }
251
+
252
+ // alpha: URP `_Surface` 1 = transparent, `_AlphaClip` 1 = cutout; Standard `_Mode` 1 cutout, 2/3 blend;
253
+ // HDRP `_SurfaceType` 1 = transparent, `_AlphaCutoffEnable` 1 = cutout at `_AlphaCutoff` (a foliage
254
+ // pack's leaves are all cutout — missed, the texture's clear pixels draw as black cards)
255
+ const mode = um.floats._Mode, surface = um.floats._Surface ?? um.floats._SurfaceType
256
+ const cutout = isCutoutMaterial(um)
257
+ // THE AUTHORED SURFACE TYPE BEATS THE CLIP FLAG. URP writes `_AlphaClip: 1` beside `_Surface: 1`
258
+ // for a pane that is transparent AND clipped (a window's glass is the ordinary case: clip the
259
+ // fully-clear texels, blend the rest), and read as CUTOUT such a pane comes out fully OPAQUE —
260
+ // the test passes everywhere once the base alpha is 1, so the glass turns to stone and nothing
261
+ // in the conversion looks wrong. Keywords stay weaker evidence than the surface float: a stale
262
+ // `_ALPHABLEND_ON` left on an opaque foliage material must not beat its clip.
263
+ const transparentSurface = surface === 1 || mode === 2 || mode === 3
264
+ const blend = transparentSurface || um.keywords.includes("_ALPHABLEND_ON") || um.keywords.includes("_ALPHAPREMULTIPLY_ON") || um.keywords.includes("_SURFACE_TYPE_TRANSPARENT")
265
+ if (transparentSurface || (blend && !cutout)) {
266
+ gm.setAlphaMode("BLEND")
267
+ // ...and on a transparent material Unity's base-colour ALPHA is the opacity — the one number
268
+ // that says how much glass this is (0.18 on this school's window panes). Everywhere else the
269
+ // FBX's own alpha is deliberately left alone; see the base colour written above.
270
+ const a = (um.colors._BaseColor ?? um.colors._Color)?.[3]
271
+ if (a !== undefined && a < 1) { const c = gm.getBaseColorFactor(); gm.setBaseColorFactor([ c[0], c[1], c[2], a ]) }
272
+ slots.push(`blend${a !== undefined && a < 1 ? ` a=${a.toFixed(2)}` : ""}`)
273
+ }
274
+ else if (cutout) { gm.setAlphaMode("MASK").setAlphaCutoff(um.floats._AlphaCutoff ?? um.floats._Cutoff ?? 0.5); slots.push("cutout") }
275
+ // A CUSTOM shader's `_Mode` / `_Surface` MEAN NOTHING. Those floats belong to Unity's own lit
276
+ // shaders; a hand-written one keys its opacity however it likes and routinely leaves them at 0.
277
+ // Chernobyl's vegetation shader clips at `_Cutoff` internally while `Birch_Leaves` and
278
+ // `Small_Tree` say `_Mode: 0` with no keywords at all — imported opaque, which drew every leaf
279
+ // CARD as a solid quad and turned a forest of 9326 trees into white blobs seen from the air.
280
+ // So when the shader is one we cannot read, the TEXTURE is the evidence: a base map whose alpha
281
+ // channel actually carries a shape is a cutout, whatever the material's floats claim.
282
+ // ...but NEVER against a material that declared itself opaque in the field its own pipeline
283
+ // reads. HDRP addresses every shader by GUID, so `guid:` alone says nothing there, and the
284
+ // Modular Shooting Range's linoleum — `_AlphaCutoffEnable: 0`, `_SurfaceType: 0` — ships a base
285
+ // map whose alpha channel this heuristic read as a shape. The whole FLOOR was then clipped away
286
+ // (you saw the placeholder ground box through it) and so was the 12 cm skirting at the foot of
287
+ // every wall, which is the same linoleum material on the wall mesh. `_Mode: 0` still does not
288
+ // count as a declaration — see `declaresNoCutout`.
289
+ else if (um.shader.startsWith("guid:") && baseG && !declaresNoCutout(um) && await (async () => {
290
+ const img = await imgFromGuid(baseG)
291
+ return !!img && alphaIsKeyed(img)
292
+ })()) {
293
+ gm.setAlphaMode("MASK").setAlphaCutoff(um.floats._AlphaCutoff ?? um.floats._Cutoff ?? 0.5)
294
+ slots.push(`cutout (custom shader, keyed base map)`)
295
+ }
296
+ else if (gm.getAlphaMode() !== "OPAQUE" && !pick(um, SLOTS.alpha)) gm.setAlphaMode("OPAQUE")
297
+ // two-sided: Standard/URP `_Cull` 0, HDRP `_DoubleSidedEnable` 1 / `_CullMode` 0
298
+ if (um.floats._Cull === 0 || um.floats._CullMode === 0 || um.floats._DoubleSidedEnable === 1 || um.keywords.includes("_DOUBLESIDED_ON")) {
299
+ gm.setDoubleSided(true)
300
+ slots.push("double-sided")
301
+ }
302
+
303
+ return slots
304
+ }
305
+ return { applyTo }
306
+ }