lecodes-assets 0.2.1 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/README.md +203 -26
  2. package/dist/cli.js +18157 -11441
  3. package/dist/index.js +16603 -9878
  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 +79 -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 +108 -0
  25. package/src/commands/shared.ts +140 -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 +288 -0
  32. package/src/doctor/doctor.ts +94 -30
  33. package/src/doctor/impostor.ts +614 -0
  34. package/src/doctor/merge/accessors.ts +30 -0
  35. package/src/doctor/merge/atlasLayout.ts +94 -0
  36. package/src/doctor/merge/atlasPixels.ts +114 -0
  37. package/src/doctor/merge/bindSpace.ts +59 -0
  38. package/src/doctor/merge/classify.ts +136 -0
  39. package/src/doctor/merge/geometry.ts +167 -0
  40. package/src/doctor/merge/index.ts +98 -0
  41. package/src/doctor/merge/matrix.ts +60 -0
  42. package/src/doctor/merge/plan.ts +88 -0
  43. package/src/doctor/merge/textures.ts +60 -0
  44. package/src/doctor/merge/types.ts +77 -0
  45. package/src/doctor/transcodeKtx2.ts +186 -31
  46. package/src/doctor/transcodeTextures.ts +6 -1
  47. package/src/doctor/validateGlb.ts +2 -2
  48. package/src/doctor/vertexAo.ts +333 -0
  49. package/src/fbx/read.ts +2 -0
  50. package/src/fbx/toDocument.ts +227 -209
  51. package/src/geometry.ts +222 -0
  52. package/src/index.ts +2 -0
  53. package/src/retarget/heading.ts +238 -171
  54. package/src/retarget/humanoid.ts +572 -561
  55. package/src/retarget/retarget.ts +2375 -2272
  56. package/src/retarget/rig.ts +4 -1
  57. package/src/retarget/writeMarks.ts +134 -132
  58. package/src/sky/cmgen.ts +3 -3
  59. package/src/sky/index.ts +57 -8
  60. package/src/terrainPack.ts +61 -22
  61. package/src/unity/colliderFit.ts +287 -0
  62. package/src/unity/fbxModel.ts +194 -0
  63. package/src/unity/foliage.ts +63 -0
  64. package/src/unity/gameObjects.ts +90 -0
  65. package/src/unity/guid.ts +104 -0
  66. package/src/unity/image.ts +58 -0
  67. package/src/unity/kindGlb.ts +364 -0
  68. package/src/unity/lecodesLevel.ts +414 -0
  69. package/src/unity/levelTerrain.ts +125 -0
  70. package/src/unity/matFile.ts +179 -0
  71. package/src/unity/materialSlots.ts +53 -0
  72. package/src/unity/materialWriter.ts +316 -0
  73. package/src/unity/materials.ts +34 -429
  74. package/src/unity/math.ts +82 -0
  75. package/src/unity/prefabTree.ts +285 -0
  76. package/src/unity/prefabs.ts +10 -36
  77. package/src/unity/propertyPath.ts +43 -0
  78. package/src/unity/scene.ts +339 -304
  79. package/src/unity/serialized.ts +242 -0
  80. package/src/unity/terrain.ts +221 -0
  81. package/src/unity/triBudget.ts +77 -0
  82. package/src/unity/triplanar.ts +122 -0
  83. package/src/unity/unityMesh.ts +151 -0
  84. package/src/unity/unpack.ts +24 -5
  85. package/src/unity/yaml.ts +131 -0
  86. package/src/unreal/index.ts +6 -5
  87. package/src/unreal/materialGraph.ts +224 -0
  88. package/src/unreal/materials.ts +117 -9
  89. package/src/unreal/package.ts +30 -12
  90. package/src/unreal/resolve.ts +6 -4
  91. package/src/unreal/sound.ts +128 -0
  92. package/src/unreal/texture.ts +70 -3
  93. package/src/unreal/toDocument.ts +7 -0
  94. package/src/unreal/versions.ts +8 -0
  95. package/src/unwrap/index.ts +167 -0
  96. package/src/unwrap/lightmapUv.ts +761 -0
  97. package/src/wasm/fbxReader.js +1 -1
  98. package/src/wasm/lightmapUv.js +1 -1
  99. package/src/doctor/lightmapUv.ts +0 -452
@@ -0,0 +1,414 @@
1
+ // A Unity scene import (scene.ts) → files in a LeCodes project:
2
+ //
3
+ // assets/<map>/<Kind>.glb one per kind (kindGlb.ts), texture-capped + KTX2 by the doctor
4
+ // assets/<map>/terrain/… <map>.terrain (heights, control) + the packed layer textures
5
+ // src/<map>/<Kind>.scene.ts a prefab scene per kind: the model + its colliders
6
+ // src/<map>.scene.ts sun, terrain, every placement (grouped like the Unity scene)
7
+ //
8
+ // Everything here is REGENERATED by the next import — the files say so at the top. A game level
9
+ // instances `src/<map>.scene.ts` as a prefab in a scene file of its own and adds its gameplay
10
+ // nodes there. GLBs are rebuilt only when their kind changed (assets/<map>/kinds.json) or --force.
11
+
12
+ import { createHash } from "node:crypto"
13
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
14
+ import { dirname, join, relative, resolve } from "node:path"
15
+ import { makeIO } from "../fbx/convert"
16
+ import { decideFoliage, describeFoliage, type FoliageOptions } from "./foliage"
17
+ import { findUnityIndex, resolverFor } from "./guid"
18
+ import { fitVerdict, readGlbTriangles, type FitBox, type FitTriangles } from "./colliderFit"
19
+ import { DEFAULT_MAX_TRIS, fitTriangleBudget } from "./triBudget"
20
+ import { KindGlbBuilder } from "./kindGlb"
21
+ import { writeTerrain, type WrittenTerrain } from "./levelTerrain"
22
+ import { decompose, eulerYXZ, qmul, r2, r3 } from "./math"
23
+ import type { KindCollider, SceneImportFull, SceneKind } from "./scene"
24
+
25
+ export type WriteLevelOptions = FoliageOptions & {
26
+ project: string
27
+ /** Map name: folder + scene file stem (default: the Unity scene's file name, lower-cased). */
28
+ name?: string
29
+ force?: boolean
30
+ maxSize?: number
31
+ ktx2?: boolean
32
+ /** The heightfield's largest resolution (samples a side): a finer Unity heightmap is read every k-th sample
33
+ * (levelTerrain.ts). Default 1025: half-metre cells over a 500 m map, the size every level so far ran on. */
34
+ terrainRes?: number
35
+ log?: (s: string) => void
36
+ /** Leave the pack's directional light out of the scene file: the level owns its sun — filament renders the
37
+ * brightest directional light of a scene and the bake follows it, so a second one only confuses. */
38
+ noSun?: boolean
39
+ /** Kinds (by name) that take `static: false` — no atlas rect, lit in real time. The case it was
40
+ * written for is a LAMP FIXTURE: the pack models the bulb inside its housing, so the fixture's own
41
+ * faces are the brightest texels in the level (the range's `SM_Door_lamp_a` read 18 000 lx against
42
+ * a 1 700 lx floor) and the bake spends a rect on each. Also the right switch for anything the game
43
+ * moves or swaps. */
44
+ noLightmap?: RegExp
45
+ /** METRES A BOX COLLIDER'S FACE MAY STAND OFF THE GEOMETRY under it before the kind takes a MESH
46
+ * collider instead (default 0.015; colliderFit.ts measures it, a huge value keeps every box).
47
+ * A pack's box is an approximation, and where it is a loose one everything that lands on the hit
48
+ * point lands in mid-air: the shooting range's wall module is flat at ±0.100 m while its box was
49
+ * fitted to the ±0.138 m of its edge trim, so bullets stopped 3.8 cm in front of the wall, their
50
+ * impact particles hung there and the bullet-hole decals (a ±3 cm projection box) never reached
51
+ * the wall at all. */
52
+ colliderFit?: number
53
+ /** Write NO colliders at all: every kind is pure visuals, no node carries a Physics body. A viewer
54
+ * wants that, and so does any level whose placements outnumber what Jolt is configured for
55
+ * (the Mountain scene: 78 000 placements, ~20 000 of them with a collider — the body cap fired
56
+ * before the level finished loading). */
57
+ noColliders?: boolean
58
+ /** Kinds whose name matches take `castShadows: false`. A pack's BACKGROUND MOUNTAINS are 2 km meshes a
59
+ * kilometre out that Unity's shadow distance never reaches; here they are casters like any other, and
60
+ * their shadow lay across the whole map. */
61
+ noShadows?: RegExp
62
+ /** Skip the alpha-card trim (`doctor --trim-cards`), which the import otherwise always runs. It
63
+ * DELETES a quad with no opaque texel in its UV rect, and a pack whose decal atlas is 94 % clear
64
+ * can lose a whole kind that way — silently, since the GLB keeps its nodes and textures. */
65
+ noTrimCards?: boolean
66
+ /** Triangles a kind may have before the import simplifies it (triBudget.ts; default 50 000, 0 = no cap):
67
+ * what keeps a photogrammetry scan from arriving as a million-triangle prop and mesh collider. */
68
+ maxTris?: number
69
+ /** IMPOSTORS (doctor/impostor.ts): every foliage kind whose level-0 height is ≥ 4 m gets its far LOD level as one
70
+ * camera-facing quad over an atlas of views — `cols` azimuths × `rows` elevation rows, or the N×N hemi-octahedron
71
+ * of N = `cols` when `rows` is 0 — `px` pixels a frame (default 256 for a plant ≥ 8 m, 128 under), at LOD slot
72
+ * `level` (default 2: the engine shows it under 0.12 of the viewport height; 1 = under 0.30, the pack's own LOD1 goes). */
73
+ impostor?: { cols: number, rows: number, px?: number, level?: number }
74
+ /** The command that produced this import, written into the scene file's header. A re-import that
75
+ * forgets a flag silently undoes a decision — the dropped kinds come back, the lamps go into the
76
+ * atlas again — and nothing in the project records it, so the line IS the record. */
77
+ command?: string
78
+ }
79
+
80
+ // Bump when a kind GLB built from the same kind comes out different — the cache keys on it.
81
+ // 3: the mesh lookup finds `Book (17)` as `Book` and the sole mesh under a parent node.
82
+ // 4: triplanar Shader Graph materials — `_Diffuse_Tex` & co. read, the projection baked into TEXCOORD_0;
83
+ // materials found past a nested `assets/` folder (guid.ts).
84
+ // 5: overrides on unaddressable model children matched by position (prefabTree.ts TransformGuess).
85
+ // 6: Unity mesh assets as part sources (unityMesh.ts — the tree LOD3 cross); every texture slot picked
86
+ // by the first candidate whose file EXISTS (a stale `_MaskMap` no longer shadows the live pack).
87
+ // (same version, hash element "nocolor": non-foliage kinds lose COLOR_0 — a Unity pack's vertex colour is
88
+ // shader data, never a tint; NatureManufacture's branches came out pure red.)
89
+ // 7: a Unity DETAIL LAYER on another UV set (kindGlb.ts detailLayer: `_DETALUSEUV3_ON` + `_DetailAlbedoMap` under a
90
+ // white `_DetailMask`) becomes the primitive's base/normal/ORM on TEXCOORD_0 = that set × tiling — the pines'
91
+ // bark, which UV0 never covered (the trunks were flat brown above the roots); FBX UV sets 1..3 now read.
92
+ // 8: vegetation kinds carry VERTEX AO — COLOR_0 = (sky visibility, 1, 1, wind weight), doctor/vertexAo.ts.
93
+ // 9: canopy normals on the leaf cards (vertexAo.ts `canopy`, 0.6 toward the crown centre).
94
+ // 10: the pack's per-material WIND PROFILE (`_Drag` / `_Stiffness` / `_InitialBend` / `_ShiverDrag` /
95
+ // `_ShiverDirectionality`) in the glTF material extras `lecodes.wind` (kindGlb.ts windProfile) — creator-gl's
96
+ // foliage tier bends and shivers each material as Unity HDRP's Wind.hlsl does.
97
+ // 11: the bark's CAVITY occlusion in COLOR_0.g (vertexAo.ts `cavity`: short rays in the normal's hemisphere — limb joints).
98
+ // 12 (2026-10-18): a material without a metallic map or `_Metallic` is metallic 0, not glTF's 1 (the LOD3 crosses were black)
99
+ // 13 (2026-11-03): an Amplify leaf graph's additive noise tint folds into the base colour factor (materialWriter.ts: Chernobyl's
100
+ // leaves drew at two thirds); the vertex alpha is the wind weight only under a `lecodes.wind` profile (vertexAo.ts)
101
+ // 14 (2026-11-03): every MASK material of a vegetation kind is double-sided (a pack's leaf shader culls off in its source)
102
+ // 15 (2026-11-03): `--impostor`: foliage kinds ≥ 4 m take an octahedral impostor quad as their last LOD level (doctor/impostor.ts)
103
+ // 16 (2026-11-03): `--impostor-level`: the LOD slot the quad takes is the bake's choice (the engine's thresholds decide the distance)
104
+ // 17 (2026-11-03): the impostor's data map carries the crown's HULL normal (xyz, the cards' shading normal), no depth
105
+ // 18 (2026-11-03): `--impostor <A>x<E>[@px]`: azimuths × elevation rows (default 12x3), `oct<N>` keeps the octahedron; the atlases skip --max-size
106
+ // 19 (2026-11-03): the impostor's albedo atlas is UASTC like its data map (ETC1S shifted the far crown's hue)
107
+ // 20 (2026-11-03): the impostor's data map = octahedral hull normal (rg, object space) + depth (b) + AO (a): the quad self-shadows from its own atlas
108
+ const BUILDER_VERSION = 20
109
+ const v3 = (v: number[]): string => `[${v.map(r3).join(", ")}]`
110
+ const ident = (s: string): string => { const x = s.replace(/[^\w$]/g, "_"); return /^\d/.test(x) ? `_${x}` : x }
111
+
112
+ const transformFields = (pos: number[], rot: number[], scale?: number[]): string => {
113
+ const e = eulerYXZ(rot).map(r2)
114
+ const parts = [ `position: ${v3(pos)}` ]
115
+ if (e.some((v) => v !== 0)) parts.push(`eulerAngles: [${e.join(", ")}]`)
116
+ if (scale && scale.some((v) => Math.abs(v - 1) > 1e-3)) parts.push(Math.abs(scale[0] - scale[1]) < 1e-3 && Math.abs(scale[0] - scale[2]) < 1e-3 ? `scale: ${r3(scale[0])}` : `scale: ${v3(scale)}`)
117
+ return parts.join(", ")
118
+ }
119
+
120
+ // ---- colliders -------------------------------------------------------------------------------------
121
+
122
+ type ColliderSpec = { line: (i: number) => string, box: FitBox | null }
123
+
124
+ const STATIC_ASPECT = "use(Physics, { motion: 'static' })"
125
+
126
+ /** The pack's solid colliders, each as the prefab file will spell it — plus the box's own frame, for
127
+ * the fit test (colliderFit.ts) that decides whether the boxes may be trusted at all. */
128
+ const colliderSpecs = (k: SceneKind): { mesh: (KindCollider & { shape: "mesh" }) | undefined, specs: ColliderSpec[] } => {
129
+ const solid = k.colliders.filter((c) => !c.trigger)
130
+ const mesh = solid.find((c) => c.shape === "mesh") as (KindCollider & { shape: "mesh" }) | undefined
131
+ const specs: ColliderSpec[] = []
132
+ for (const c of solid) {
133
+ if (c.shape === "mesh") continue
134
+ const d = decompose(c.matrix)
135
+ const s = d.s.map(Math.abs)
136
+ const center = c.center
137
+ const origin = [ -center[0] * s[0], center[1] * s[1], center[2] * s[2] ] // Unity local → mirrored
138
+ let shape: string
139
+ let rot = d.r
140
+ let half: [number, number, number] | null = null
141
+ if (c.shape === "box") {
142
+ half = [ c.size[0] * s[0] / 2, c.size[1] * s[1] / 2, c.size[2] * s[2] / 2 ]
143
+ shape = `box: ${v3(half)}`
144
+ } else if (c.shape === "sphere") shape = `sphere: ${r3(c.radius * Math.max(...s))}`
145
+ else {
146
+ const ax = c.direction
147
+ const perp = Math.max(...s.filter((_, j) => j !== ax))
148
+ const radius = c.radius * perp
149
+ const halfH = Math.max(0, c.height * s[ax] / 2 - radius)
150
+ shape = `capsule: { halfHeight: ${r3(halfH)}, radius: ${r3(radius)} }`
151
+ // LeCodes capsules stand on Y: an X / Z capsule turns its node (origin stays in the collider's frame)
152
+ if (ax !== 1) {
153
+ const h = Math.SQRT1_2
154
+ const turn = ax === 0 ? [ 0, 0, h, h ] : [ h, 0, 0, h ]
155
+ rot = qmul(rot, turn)
156
+ // the origin was expressed in the unturned frame — rotate it into the turned one
157
+ const o = origin
158
+ if (ax === 0) { const [ ox, oy ] = o; o[0] = oy; o[1] = -ox } else { const [ , oy, oz ] = o; o[1] = oz; o[2] = -oy }
159
+ }
160
+ }
161
+ const box: FitBox | null = half
162
+ ? { half, origin: [ origin[0], origin[1], origin[2] ], t: [ d.t[0], d.t[1], d.t[2] ], rot: [ rot[0], rot[1], rot[2], rot[3] ] }
163
+ : null
164
+ specs.push({
165
+ line: (i: number) => ` collider_${i}: { ${transformFields(d.t, rot)}, aspects: [use(Shape, { ${shape}, origin: ${v3(origin)} }), ${STATIC_ASPECT}] },`,
166
+ box,
167
+ })
168
+ }
169
+ return { mesh, specs }
170
+ }
171
+
172
+ /** `forceMesh` = the fit test found a box that floats off the art: the whole kind takes the mesh
173
+ * instead, since keeping the good boxes beside it would collide twice. */
174
+ const colliderNodes = (k: SceneKind, forceMesh = false): { modelAspects: string[], children: string[] } => {
175
+ const modelAspects: string[] = []
176
+ const children: string[] = []
177
+ const { mesh, specs } = colliderSpecs(k)
178
+ // the colliders stay PICKABLE: a game's hitscan is Physics.raycast, and the physics raycast skips a body whose
179
+ // Shape has raycast: false (creator-physics PickableBodyFilter) - the range shot through its own walls (2026-09-18)
180
+ if (mesh || forceMesh) modelAspects.push(`use(Shape, { mesh: ${mesh?.convex ? "'convex'" : "true"} })`, STATIC_ASPECT)
181
+ if (!forceMesh) { let i = 0; for (const spec of specs) children.push(spec.line(++i)) }
182
+ return { modelAspects, children }
183
+ }
184
+
185
+ const kindSceneFile = (k: SceneKind, glbRel: string, source: string, foliage: boolean, noLightmap = false, forceMesh = false, noColliders = false, noShadows = false): string => {
186
+ const { modelAspects, children } = noColliders ? { modelAspects: [] as string[], children: [] as string[] } : colliderNodes(k, forceMesh)
187
+ const shadows = !noShadows && k.parts.some((p) => p.castShadows)
188
+ const model = [ `model: asset('${glbRel}')`, shadows ? "castShadows: true" : "castShadows: false" ]
189
+ // the SDK's vegetation tier: wind, touch bending, per-copy tint (Foliage); a level may add `{ fade }` per kind
190
+ if (foliage) model.push("foliage: true")
191
+ // out of the bake entirely (`--no-lightmap <re>`): no atlas rect, lit in real time
192
+ if (noLightmap) model.push("static: false")
193
+ if (modelAspects.length) model.push(`aspects: [${modelAspects.join(", ")}]`)
194
+ return `// Generated by \`lecodes assets scene\` from ${source}${k.prefabPath ? ` (${k.prefabPath.replace(/\\/g, "/").replace(/^.*\/Assets\//, "Assets/")})` : ""}.
195
+ // Regenerated on every re-import — edit the level that instances it, not this file.
196
+
197
+ export default defineScene({
198
+ nodes: {
199
+ ${[ ` model: { ${model.join(", ")} },`, ...children ].join("\n")}
200
+ },
201
+ })
202
+ `
203
+ }
204
+
205
+ // ---- kinds: GLB + prefab scene ---------------------------------------------------------------------------
206
+
207
+ const rel = (from: string, to: string): string => { const x = relative(from, to).replace(/\\/g, "/"); return x.startsWith(".") ? x : `./${x}` }
208
+
209
+ /** WHERE `name:<material>` REFS ARE ANSWERED FROM. A renderer's material list names a GUID only
210
+ * when something REMAPPED it (the model importer's `externalObjects`, or a prefab / scene
211
+ * override); left alone, it carries the FBX's own material NAME and Unity resolves that by
212
+ * SEARCHING THE PROJECT (`materialSearch` in the importer). So the folder to walk is the pack
213
+ * ROOT — the directory holding unity-index.txt — and not the prefab's grandparent: a pack that
214
+ * keeps `Prefabs/` and `Materials/` as SIBLINGS, which is the ordinary layout, has no .mat file
215
+ * anywhere under that grandparent and every one of those refs then falls back to the FBX's
216
+ * placeholder. That failure is silent AND plausible-looking: an FBX placeholder still carries a
217
+ * diffuse COLOUR, so a glass pane comes out flat grey rather than obviously missing, and the
218
+ * pack's own transparency, textures and smoothness are simply gone. */
219
+ const packRootOf = (prefabPath: string): string => dirname(findUnityIndex(dirname(prefabPath)) ?? join(dirname(prefabPath), ".."))
220
+
221
+ /** One GLB per kind under `assetDir`, rebuilt only when the kind changed (kinds.json) or `force`. */
222
+ const buildKindGlbs = async (kinds: SceneKind[], builder: KindGlbBuilder, assetDir: string, opts: WriteLevelOptions, log: (s: string) => void): Promise<{ converted: number, reused: number }> => {
223
+ const manifestPath = join(assetDir, "kinds.json")
224
+ const manifest: Record<string, string> = existsSync(manifestPath) ? JSON.parse(readFileSync(manifestPath, "utf8")) : {}
225
+ const { analyzeDoc, applyPlan } = await import("../doctor/doctor")
226
+ const { ensureCodecs } = await import("../doctor/reduceGlbBones")
227
+ await ensureCodecs()
228
+ // cards trimmed to their opaque texels BEFORE the KTX2 encode (doctor/cardTrim.ts): a foliage pack's
229
+ // quads are mostly transparent, and that discarded fill was the frame on Twig & Twine's map
230
+ const doctor = { ktx2: opts.ktx2 !== false, maxSize: opts.maxSize ?? 1024, fixEmissive: true, trimCards: !opts.noTrimCards }
231
+ let converted = 0, reused = 0
232
+ for (const [ i, k ] of kinds.entries()) {
233
+ const glb = join(assetDir, `${k.name}.glb`)
234
+ const maxTris = opts.maxTris ?? DEFAULT_MAX_TRIS
235
+ // VERTEX COLOURS ARE NOT COLOURS in a Unity pack: the Standard / HDRP Lit shaders never read them, and
236
+ // a custom shader reads them as DATA — NatureManufacture bakes its wind weight there, (1, 0, 0, 1) on
237
+ // every branch vertex. glTF's rule is the opposite (COLOR_0 tints the base colour), so the ubershader
238
+ // painted every broken branch pure red (2026-09-23). Non-foliage kinds lose COLOR_0; the vegetation
239
+ // tier keeps it — its shader ignores the tint, and the alpha IS the wind weight it will read.
240
+ const foliage = decideFoliage(k, builder.materialsOf(k), opts)
241
+ const hash = createHash("sha1").update(JSON.stringify([ BUILDER_VERSION, k.key, opts.maxSize, opts.ktx2, maxTris, ...(foliage ? [] : [ "nocolor" ]), ...(foliage && opts.impostor ? [ "impostor", opts.impostor.cols, opts.impostor.rows, opts.impostor.px ?? 0, opts.impostor.level ?? 2 ] : []) ])).digest("hex").slice(0, 16)
242
+ if (!opts.force && existsSync(glb) && manifest[k.name] === hash) { reused++; continue }
243
+ const { doc, parts, missing } = await builder.build(k)
244
+ if (!foliage) for (const m of doc.getRoot().listMeshes()) for (const p of m.listPrimitives()) { const c = p.getAttribute("COLOR_0"); if (c) { p.setAttribute("COLOR_0", null); if (c.listParents().length <= 1) c.dispose() } }
245
+ // and the vegetation tier gets its VERTEX AO: the sky's visibility from every vertex baked into COLOR_0.r
246
+ // (doctor/vertexAo.ts), the wind weight kept in the alpha — the one thing that makes a crown read as a volume
247
+ if (foliage) {
248
+ // the cards of a plant are two-sided by nature; a pack's leaf shader may cull off in its SOURCE (Chernobyl's
249
+ // Leaves.shader: `Cull Off`), which no .mat property shows - the glTF flag says so for every reader (2026-11-03)
250
+ for (const m of doc.getRoot().listMaterials()) if (m.getAlphaMode() === "MASK") m.setDoubleSided(true)
251
+ const { bakeVertexAo } = await import("../doctor/vertexAo")
252
+ const ao = bakeVertexAo(doc)
253
+ if (ao.vertices) log(` 🌿 ${k.name}: vertex AO on ${ao.vertices} vertices (mean ${ao.meanAo.toFixed(2)})`)
254
+ }
255
+ for (const m of missing) log(` ⚠ ${k.name}: ${m}`)
256
+ if (!parts) { log(` ⚠ ${k.name}: no mesh could be built — skipped`); continue }
257
+ const cut = await fitTriangleBudget(doc, maxTris)
258
+ if (cut) log(` ✂ ${k.name}: ${cut.before.toLocaleString("en")} → ${cut.after.toLocaleString("en")} triangles (--max-tris ${maxTris})`)
259
+ // a TREE's far level as one quad over an atlas of views (doctor/impostor.ts) — before the doctor: the bake reads the
260
+ // PNG / JPEG base colours the KTX2 encode takes away, and the atlases go through that encode like any map
261
+ if (foliage && opts.impostor) {
262
+ const { bakeImpostor, impostorBounds, impostorLabel } = await import("../doctor/impostor")
263
+ const box = impostorBounds(doc)
264
+ const height = box ? box.max[1] - box.min[1] : 0
265
+ if (height >= 4) {
266
+ // px unset: the bake's own default by the same level-0 height (256 for ≥ 8 m, 128 under)
267
+ const imp = await bakeImpostor(doc, { ...opts.impostor, log })
268
+ if (imp) log(` 🌲 ${k.name}: impostor ${impostorLabel(imp)} · ${imp.node} · r ${imp.radius.toFixed(2)} m · ${imp.triangles} triangles in ${imp.ms} ms`)
269
+ }
270
+ }
271
+ await applyPlan(doc, await analyzeDoc(doc, doctor), doctor)
272
+ writeFileSync(glb, await makeIO().writeBinary(doc))
273
+ manifest[k.name] = hash
274
+ converted++
275
+ log(` [${i + 1}/${kinds.length}] ${k.name}.glb · ${parts} part(s) · ${Math.round(readFileSync(glb).length / 1024)} KB`)
276
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 1) + "\n")
277
+ }
278
+ return { converted, reused }
279
+ }
280
+
281
+ /** The LOD0 triangles of a kind's GLB, read once (the fit test below asks for the same file per kind). */
282
+ const triCache = new Map<string, FitTriangles | null>()
283
+ const trianglesOf = (glb: string): FitTriangles | null => {
284
+ const hit = triCache.get(glb)
285
+ if (hit !== undefined) return hit
286
+ let tris: FitTriangles | null = null
287
+ try { tris = readGlbTriangles(readFileSync(glb)) } catch { tris = null }
288
+ triCache.set(glb, tris)
289
+ return tris
290
+ }
291
+
292
+ /** `src/<map>/<Kind>.scene.ts` for every kind that has its GLB, with the foliage / no-lightmap / collider verdicts. */
293
+ const writeKindScenes = (kinds: SceneKind[], builder: KindGlbBuilder, dirs: { assets: string, src: string }, source: string, opts: WriteLevelOptions, log: (s: string) => void): void => {
294
+ const foliageKinds: string[] = []
295
+ const unbakedKinds: string[] = []
296
+ const tol = opts.colliderFit ?? 0.015
297
+ const meshKinds: string[] = []
298
+ let boxKinds = 0
299
+ for (const k of kinds) {
300
+ const foliage = decideFoliage(k, builder.materialsOf(k), opts)
301
+ if (foliage) foliageKinds.push(k.name)
302
+ const noLightmap = !!opts.noLightmap?.test(k.name)
303
+ if (noLightmap) unbakedKinds.push(k.name)
304
+ // DOES THE PACK'S BOX SIT ON THE ART? (colliderFit.ts, `--collider-fit`) — measured against the
305
+ // kind's own GLB, which is on disk by now whether it was rebuilt or reused
306
+ const glb = join(dirs.assets, `${k.name}.glb`)
307
+ const boxes = colliderSpecs(k).specs.map((x) => x.box).filter((b): b is FitBox => b !== null)
308
+ let forceMesh = false
309
+ if (boxes.length > 0 && existsSync(glb) && !opts.noColliders) {
310
+ boxKinds++
311
+ const v = fitVerdict(trianglesOf(glb), boxes, { tolerance: tol })
312
+ if (v.mesh) {
313
+ forceMesh = true
314
+ meshKinds.push(`${k.name} (${v.worst && v.worst.covered >= 0.25 ? `${Math.round(v.worst.gap * 100)} cm off` : "over a void"})`)
315
+ }
316
+ }
317
+ writeFileSync(join(dirs.src, `${k.name}.scene.ts`), kindSceneFile(k, rel(dirs.src, join(dirs.assets, `${k.name}.glb`)), source, foliage, noLightmap, forceMesh, !!opts.noColliders, !!opts.noShadows?.test(k.name)))
318
+ }
319
+ if (boxKinds > 0) {
320
+ log(` 🧱 collider fit (${r3(tol * 100)} cm): ${meshKinds.length} of ${boxKinds} kind(s) with box colliders take a MESH collider instead${meshKinds.length ? `: ${meshKinds.slice(0, 6).join(", ")}${meshKinds.length > 6 ? ` … +${meshKinds.length - 6}` : ""}` : ""}`)
321
+ }
322
+ log(` ${describeFoliage(foliageKinds, opts)}`)
323
+ if (opts.noLightmap) log(` 🔦 static: false on ${unbakedKinds.length} kind(s)${unbakedKinds.length ? `: ${unbakedKinds.slice(0, 8).join(", ")}${unbakedKinds.length > 8 ? " …" : ""}` : ` (nothing matched /${opts.noLightmap.source}/)`}`)
324
+ }
325
+
326
+ // ---- the map scene -----------------------------------------------------------------------------------------
327
+
328
+ const terrainNode = (r: SceneImportFull, terrain: WrittenTerrain, sdir: string): string[] => {
329
+ const data = r.terrainData!
330
+ const layers = terrain.layers.map((f, i) => {
331
+ const l = data.layers.find((x) => x.name.replace(/^LAYER_/i, "").toLowerCase() === f.replace(/^.*[\\/]/, "").replace(/\.(ktx2|png)$/, "")) ?? data.layers[i]
332
+ return `{ albedo: asset('${rel(sdir, f)}'), tiling: ${r3(l.tileSize[0])}, roughness: ${l.mask ? r2(1 - l.smoothness) : 1}${l.normal ? `, normalScale: ${r2(l.normalScale)}` : ""} }`
333
+ })
334
+ const normals = terrain.normals.map((f) => `asset('${rel(sdir, f)}')`)
335
+ return [
336
+ ` terrain: {`,
337
+ ` terrain: { data: asset('${rel(sdir, terrain.file)}'), layers: [${layers.join(", ")}]${normals.length ? `, normals: [${normals.join(", ")}]` : ""} },`,
338
+ ` position: ${v3(r.terrain!.position)}, receiveShadows: true,`,
339
+ ` aspects: [use(Shape, { heightfield: true }), use(Physics, { motion: 'static' })],`,
340
+ ` },`,
341
+ ]
342
+ }
343
+
344
+ /** Placements grouped like the Unity scene: its top-level group objects (+ Trees / Details for the terrain's). */
345
+ const placementGroups = (r: SceneImportFull, usable: Set<string>): string[] => {
346
+ const groups = new Map<string, string[]>()
347
+ const used = new Map<string, number>()
348
+ for (const inst of r.instances) {
349
+ if (!usable.has(inst.kind)) continue
350
+ const g = inst.group.startsWith("Terrain/") ? inst.group.slice(8) : (inst.group.split("/")[0] || "Objects")
351
+ const key = ident(g.replace(/^_+/, "")) || "Objects"
352
+ const n = (used.get(`${key}/${inst.kind}`) ?? 0) + 1
353
+ used.set(`${key}/${inst.kind}`, n)
354
+ const list = groups.get(key) ?? []
355
+ list.push(` ${ident(inst.kind)}_${n}: { prefab: ${ident(inst.kind)}, ${transformFields(inst.position, inst.rotation, inst.scale)} },`)
356
+ groups.set(key, list)
357
+ }
358
+ return [ ...groups ].flatMap(([ g, list ]) => [ ` ${g}: {`, ` children: {`, ...list, ` },`, ` },` ])
359
+ }
360
+
361
+ const mapSceneFile = (r: SceneImportFull, name: string, usable: SceneKind[], terrain: WrittenTerrain | null, sdir: string, opts: WriteLevelOptions): string => {
362
+ const lines: string[] = []
363
+ lines.push(`// Generated by \`lecodes assets scene\` from ${r.source} — ${r.instances.length} placements of ${usable.length} kinds.`)
364
+ lines.push("// Regenerated on every re-import: instance this file as a prefab in a level of your own and put the")
365
+ lines.push("// gameplay there. Frame: Unity mirrored on X, metres, Y up.")
366
+ if (opts.command) {
367
+ lines.push("//")
368
+ lines.push("// RE-IMPORT WITH THE SAME LINE — the flags carry decisions this file cannot show (kinds dropped,")
369
+ lines.push("// placements dropped at a point, kinds kept out of the lightmap); drop one and it silently undoes itself:")
370
+ lines.push(`// ${opts.command}`)
371
+ }
372
+ lines.push("")
373
+ for (const k of usable) lines.push(`import ${ident(k.name)} from './${name}/${k.name}.scene'`)
374
+ lines.push("")
375
+ lines.push("export default defineScene({")
376
+ const cam = r.cameras[0]
377
+ if (cam) {
378
+ const target = cam.position.map((v, i) => v + cam.forward[i] * 30)
379
+ lines.push(` camera: { position: ${v3(cam.position)}, target: ${v3(target)}, fov: ${r2(cam.fov)} },`)
380
+ }
381
+ lines.push(" nodes: {")
382
+ const sun = opts.noSun ? undefined : r.lights.find((l) => l.type === "directional")
383
+ if (sun) lines.push(` sun: { light: { kind: 'sun', direction: ${v3(sun.direction)}, intensity: ${Math.round(Math.min(120000, Math.max(20000, sun.intensity)))}, color: '#${sun.color.map((c) => Math.round(Math.min(1, c) * 255).toString(16).padStart(2, "0")).join("")}', shadowsQuality: 1, shadowDistance: 30 } },`)
384
+ if (terrain && r.terrain) lines.push(...terrainNode(r, terrain, sdir))
385
+ lines.push(...placementGroups(r, new Set(usable.map((k) => k.name))))
386
+ lines.push(" },", "})", "")
387
+ return lines.join("\n")
388
+ }
389
+
390
+ // ---- the whole level ---------------------------------------------------------------------------------
391
+
392
+ export const writeLeCodesLevel = async (r: SceneImportFull, opts: WriteLevelOptions): Promise<{ scene: string, kinds: number, converted: number, reused: number }> => {
393
+ const log = opts.log ?? console.log
394
+ const project = resolve(opts.project)
395
+ const name = (opts.name ?? r.source.replace(/\.unity$/i, "")).toLowerCase().replace(/[^\w-]+/g, "_")
396
+ const dirs = { assets: join(project, "assets", name), src: join(project, "src", name) }
397
+ mkdirSync(dirs.assets, { recursive: true }); mkdirSync(dirs.src, { recursive: true })
398
+
399
+ const kinds = Object.values(r.kinds)
400
+ const anyPrefab = kinds.find((k) => k.prefabPath)?.prefabPath
401
+ const builder = new KindGlbBuilder({
402
+ resolver: resolverFor(anyPrefab ?? project),
403
+ materialDirs: anyPrefab ? [ packRootOf(anyPrefab) ] : [],
404
+ warn: (m) => log(` ⚠ ${m}`),
405
+ })
406
+ const { converted, reused } = await buildKindGlbs(kinds, builder, dirs.assets, opts, log)
407
+ const usable = kinds.filter((k) => existsSync(join(dirs.assets, `${k.name}.glb`)))
408
+ writeKindScenes(usable, builder, dirs, r.source, opts, log)
409
+ const terrain = await writeTerrain(r, join(dirs.assets, "terrain"), name, opts, log)
410
+
411
+ const sceneFile = join(project, "src", `${name}.scene.ts`)
412
+ writeFileSync(sceneFile, mapSceneFile(r, name, usable, terrain, dirname(sceneFile), opts))
413
+ return { scene: sceneFile, kinds: usable.length, converted, reused }
414
+ }
@@ -0,0 +1,125 @@
1
+ // A Unity terrain → the `.terrain` file the SDK's Terrain.parse reads (heights + control) and the
2
+ // packed layer textures. LeCodes' terrain material blends FOUR layers, so a terrain with more has
3
+ // its closest-coloured ones merged; the grid is mirrored on X like everything else in the import.
4
+
5
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"
6
+ import { join } from "node:path"
7
+ import { decodeImage } from "./image"
8
+ import { r3 } from "./math"
9
+ import type { SceneImportFull } from "./scene"
10
+ import type { UnityTerrain } from "./terrain"
11
+
12
+ type LayerPlan = { keep: number[], mergeInto: Map<number, number> }
13
+
14
+ /** Four layers is what the LeCodes terrain material blends: merge the closest-coloured pairs (by
15
+ * mean albedo) until four remain, the lesser-covered layer folding into the other. */
16
+ const planLayers = async (t: UnityTerrain): Promise<LayerPlan> => {
17
+ const n = t.layers.length
18
+ const cover = t.weights.map((w) => { let s = 0; for (let i = 0; i < w.length; i += 7) s += w[i]; return s })
19
+ const mean: number[][] = []
20
+ for (const l of t.layers) {
21
+ const img = l.diffuse ? await decodeImage(new Uint8Array(readFileSync(l.diffuse)), l.diffuse) : null
22
+ if (!img) { mean.push([ 0.5, 0.5, 0.5 ]); continue }
23
+ let r = 0, g = 0, b = 0, c = 0
24
+ for (let i = 0; i < img.data.length; i += 4 * 97) { r += img.data[i]; g += img.data[i + 1]; b += img.data[i + 2]; c++ }
25
+ mean.push([ r / c / 255, g / c / 255, b / c / 255 ])
26
+ }
27
+ const alive = new Set(Array.from({ length: n }, (_, i) => i))
28
+ const mergeInto = new Map<number, number>()
29
+ while (alive.size > 4) {
30
+ let best: [number, number] = [ -1, -1 ], bd = Infinity
31
+ for (const a of alive) for (const b of alive) {
32
+ if (a >= b) continue
33
+ const d = Math.hypot(mean[a][0] - mean[b][0], mean[a][1] - mean[b][1], mean[a][2] - mean[b][2])
34
+ if (d < bd) { bd = d; best = [ a, b ] }
35
+ }
36
+ const [ a, b ] = best
37
+ const [ win, lose ] = cover[a] >= cover[b] ? [ a, b ] : [ b, a ]
38
+ alive.delete(lose)
39
+ mergeInto.set(lose, win)
40
+ cover[win] += cover[lose]
41
+ for (const [ k, v ] of mergeInto) if (v === lose) mergeInto.set(k, win)
42
+ }
43
+ return { keep: [ ...alive ].sort((a, b) => a - b), mergeInto }
44
+ }
45
+
46
+ const LTER_MAGIC = 0x5245544c, LTER_VERSION = 1
47
+
48
+ /** Heights (metres) + control (RGBA per sample) → the `.terrain` file the SDK's Terrain.parse reads. */
49
+ const writeTerrainFile = (file: string, sizeX: number, sizeZ: number, cellSize: number, heights: Float32Array, control: Uint8Array, layers: object[]): void => {
50
+ let lo = Infinity, hi = -Infinity
51
+ for (const h of heights) { if (h < lo) lo = h; if (h > hi) hi = h }
52
+ const range = hi - lo > 1e-6 ? hi - lo : 1
53
+ const n = sizeX * sizeZ
54
+ const meta = { sizeX, sizeZ, cellSize, chunk: 64, heightMin: lo, heightMax: hi, layers, blobs: { heights: { fmt: "u16", off: 0, len: n * 2 }, holes: { off: n * 2, len: n }, control: { off: n * 3, len: n * 4 } } }
55
+ const json = new TextEncoder().encode(JSON.stringify(meta))
56
+ const blobStart = (12 + json.length + 3) & ~3
57
+ const out = new ArrayBuffer(blobStart + n * 7)
58
+ const dv = new DataView(out)
59
+ dv.setUint32(0, LTER_MAGIC, true); dv.setUint32(4, LTER_VERSION, true); dv.setUint32(8, json.length, true)
60
+ new Uint8Array(out, 12, json.length).set(json)
61
+ const u16 = new Uint16Array(out, blobStart, n)
62
+ for (let i = 0; i < n; i++) u16[i] = Math.round(((heights[i] - lo) / range) * 65535)
63
+ new Uint8Array(out, blobStart + n * 3, n * 4).set(control)
64
+ writeFileSync(file, new Uint8Array(out))
65
+ }
66
+
67
+ /** The `.terrain` file, the packed albedo file per kept layer, and the packed normal textures. */
68
+ export type WrittenTerrain = { file: string, layers: string[], normals: string[] }
69
+
70
+ export const writeTerrain = async (r: SceneImportFull, dir: string, name: string, opts: { ktx2?: boolean, terrainRes?: number }, log: (s: string) => void): Promise<WrittenTerrain | null> => {
71
+ const t = r.terrainData
72
+ if (!t || !r.terrain) return null
73
+ mkdirSync(dir, { recursive: true })
74
+ // THE HEIGHTFIELD'S RESOLUTION IS A COST EVERYWHERE: the file (7 bytes a sample), Jolt's height field, the navmesh's
75
+ // input (two triangles a cell: Chernobyl's 4097² = 33 M triangles, past what Recast's wasm can hold) and the render
76
+ // chunks. A pack's terrain is authored at Unity's 4097 over 500 m (12 cm cells) where half-metre cells draw the same
77
+ // ground; `--terrain-res` caps it: every k-th sample along both axes (the samples are exact, no filtering), the
78
+ // largest k whose result is at most the cap — a Unity heightmap is 2^n + 1, so every power of two divides it.
79
+ const src = t.resolution
80
+ let step = 1
81
+ if (opts.terrainRes && opts.terrainRes > 1) while ((src - 1) / step + 1 > opts.terrainRes && (src - 1) % (step * 2) === 0) step *= 2
82
+ const res = (src - 1) / step + 1
83
+ if (step > 1) log(` 🏔 terrain heightfield ${src}² → ${res}² (every ${step}th sample, --terrain-res ${opts.terrainRes})`)
84
+ // mirror the columns: LeCodes sample (0,0) is the Unity terrain's +X far corner
85
+ const heights = new Float32Array(res * res)
86
+ for (let z = 0; z < res; z++) for (let x = 0; x < res; x++) heights[z * res + x] = t.heights[(z * step) * src + (src - 1 - x * step)]
87
+ const plan = await planLayers(t)
88
+ const slot = new Map<number, number>()
89
+ plan.keep.forEach((li, s) => slot.set(li, s))
90
+ for (const [ lose, win ] of plan.mergeInto) slot.set(lose, slot.get(win)!)
91
+ // control: area-average the (finer) alphamaps onto the samples
92
+ const ar = t.alphaResolution, control = new Uint8Array(res * res * 4)
93
+ const f = ar / src
94
+ for (let z = 0; z < res; z++) for (let x = 0; x < res; x++) {
95
+ const ux = (res - 1 - x) * step, uz = z * step // mirrored column, in source samples
96
+ const acc = [ 0, 0, 0, 0 ]
97
+ const ax0 = Math.max(0, Math.floor((ux - step / 2) * f)), ax1 = Math.min(ar - 1, Math.ceil((ux + step / 2) * f))
98
+ const az0 = Math.max(0, Math.floor((uz - step / 2) * f)), az1 = Math.min(ar - 1, Math.ceil((uz + step / 2) * f))
99
+ for (let li = 0; li < t.weights.length; li++) {
100
+ const w = t.weights[li]
101
+ let s = 0
102
+ for (let az = az0; az <= az1; az++) for (let ax = ax0; ax <= ax1; ax++) s += w[az * ar + ax]
103
+ acc[slot.get(li)!] += s
104
+ }
105
+ const tot = acc[0] + acc[1] + acc[2] + acc[3] || 1
106
+ for (let c = 0; c < 4; c++) control[(z * res + x) * 4 + c] = Math.round(acc[c] / tot * 255)
107
+ }
108
+ const kept = plan.keep.map((li) => t.layers[li])
109
+ const file = join(dir, `${name}.terrain`)
110
+ // ROUGHNESS (terrain.mat: a layer's roughness = its factor × the ALPHA of its albedo, per pixel). An HDRP
111
+ // layer with a mask map keeps its smoothness in the mask's ALPHA and `m_Smoothness` is a MULTIPLIER on
112
+ // it (1 in every NatureManufacture layer) — read as the smoothness itself it made the whole terrain a
113
+ // mirror (factor 0: the ground sparkled under the sun, 2026-09-23). Now the mask rides the albedo's
114
+ // alpha as 1 − smoothness (terrainPack.ts `smoothnessAlpha`) and the factor is 1 — the multiplier is
115
+ // not expressible per pixel and is dropped (logged). No mask = the number IS the
116
+ // smoothness (Unity's built-in terrain), so the factor is 1 − it.
117
+ const layers = kept.map((l) => ({ tiling: l.tileSize[0], roughness: l.mask ? 1 : Math.min(1, Math.max(0, 1 - l.smoothness)), normalScale: l.normal ? l.normalScale : 0, triplanar: false }))
118
+ log(` 🪨 terrain roughness: ${kept.map((l, i) => `${l.name} ${l.mask ? "mask alpha" : "flat"} ×${layers[i].roughness.toFixed(2)}`).join(", ")}`)
119
+ writeTerrainFile(file, res, res, t.size[0] / (res - 1), heights, control, layers)
120
+ log(`🏔 terrain ${res}² · ${r3(t.size[0] / (res - 1))} m cells · layers ${kept.map((l) => l.name).join(", ")}${plan.mergeInto.size ? ` (merged: ${[ ...plan.mergeInto ].map(([ a, b ]) => `${t.layers[a].name} → ${t.layers[b].name}`).join(", ")})` : ""}`)
121
+ const { packTerrainTextures } = await import("../terrainPack")
122
+ const packed = await packTerrainTextures(kept.map((l) => ({ name: l.name.replace(/^LAYER_/i, "").toLowerCase(), albedo: l.diffuse!, normal: l.normal ?? undefined, smoothnessAlpha: l.mask ?? undefined, albedoScale: l.albedoScale })), { outDir: dir, size: 1024, png: opts.ktx2 === false, log: () => {} })
123
+ return { file, layers: packed.layers.map((l) => l.file), normals: packed.normals }
124
+ }
125
+