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,381 @@
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
+ log?: (s: string) => void
33
+ /** Leave the pack's directional light out of the scene file: the level owns its sun — filament renders the
34
+ * brightest directional light of a scene and the bake follows it, so a second one only confuses. */
35
+ noSun?: boolean
36
+ /** Kinds (by name) that take `lightmap: false` — no atlas rect, lit in real time. The case it was
37
+ * written for is a LAMP FIXTURE: the pack models the bulb inside its housing, so the fixture's own
38
+ * faces are the brightest texels in the level (the range's `SM_Door_lamp_a` read 18 000 lx against
39
+ * a 1 700 lx floor) and the bake spends a rect on each. Also the right switch for anything the game
40
+ * moves or swaps. */
41
+ noLightmap?: RegExp
42
+ /** METRES A BOX COLLIDER'S FACE MAY STAND OFF THE GEOMETRY under it before the kind takes a MESH
43
+ * collider instead (default 0.015; colliderFit.ts measures it, a huge value keeps every box).
44
+ * A pack's box is an approximation, and where it is a loose one everything that lands on the hit
45
+ * point lands in mid-air: the shooting range's wall module is flat at ±0.100 m while its box was
46
+ * fitted to the ±0.138 m of its edge trim, so bullets stopped 3.8 cm in front of the wall, their
47
+ * impact particles hung there and the bullet-hole decals (a ±3 cm projection box) never reached
48
+ * the wall at all. */
49
+ colliderFit?: number
50
+ /** Write NO colliders at all: every kind is pure visuals, no node carries a Physics body. A viewer
51
+ * wants that, and so does any level whose placements outnumber what Jolt is configured for
52
+ * (the Mountain scene: 78 000 placements, ~20 000 of them with a collider — the body cap fired
53
+ * before the level finished loading). */
54
+ noColliders?: boolean
55
+ /** Kinds whose name matches take `castShadows: false`. A pack's BACKGROUND MOUNTAINS are 2 km meshes a
56
+ * kilometre out that Unity's shadow distance never reaches; here they are casters like any other, and
57
+ * their shadow lay across the whole map. */
58
+ noShadows?: RegExp
59
+ /** Skip the alpha-card trim (`doctor --trim-cards`), which the import otherwise always runs. It
60
+ * DELETES a quad with no opaque texel in its UV rect, and a pack whose decal atlas is 94 % clear
61
+ * can lose a whole kind that way — silently, since the GLB keeps its nodes and textures. */
62
+ noTrimCards?: boolean
63
+ /** Triangles a kind may have before the import simplifies it (triBudget.ts; default 50 000, 0 = no cap):
64
+ * what keeps a photogrammetry scan from arriving as a million-triangle prop and mesh collider. */
65
+ maxTris?: number
66
+ /** The command that produced this import, written into the scene file's header. A re-import that
67
+ * forgets a flag silently undoes a decision — the dropped kinds come back, the lamps go into the
68
+ * atlas again — and nothing in the project records it, so the line IS the record. */
69
+ command?: string
70
+ }
71
+
72
+ // Bump when a kind GLB built from the same kind comes out different — the cache keys on it.
73
+ // 3: the mesh lookup finds `Book (17)` as `Book` and the sole mesh under a parent node.
74
+ // 4: triplanar Shader Graph materials — `_Diffuse_Tex` & co. read, the projection baked into TEXCOORD_0;
75
+ // materials found past a nested `assets/` folder (guid.ts).
76
+ // 5: overrides on unaddressable model children matched by position (prefabTree.ts TransformGuess).
77
+ // 6: Unity mesh assets as part sources (unityMesh.ts — the tree LOD3 cross); every texture slot picked
78
+ // by the first candidate whose file EXISTS (a stale `_MaskMap` no longer shadows the live pack).
79
+ // (same version, hash element "nocolor": non-foliage kinds lose COLOR_0 — a Unity pack's vertex colour is
80
+ // shader data, never a tint; NatureManufacture's branches came out pure red.)
81
+ // 7: a Unity DETAIL LAYER on another UV set (kindGlb.ts detailLayer: `_DETALUSEUV3_ON` + `_DetailAlbedoMap` under a
82
+ // white `_DetailMask`) becomes the primitive's base/normal/ORM on TEXCOORD_0 = that set × tiling — the pines'
83
+ // bark, which UV0 never covered (the trunks were flat brown above the roots); FBX UV sets 1..3 now read.
84
+ // 8: vegetation kinds carry VERTEX AO — COLOR_0 = (sky visibility, 1, 1, wind weight), doctor/vertexAo.ts.
85
+ // 9: canopy normals on the leaf cards (vertexAo.ts `canopy`, 0.6 toward the crown centre).
86
+ // 10: the pack's per-material WIND PROFILE (`_Drag` / `_Stiffness` / `_InitialBend` / `_ShiverDrag` /
87
+ // `_ShiverDirectionality`) in the glTF material extras `lecodes.wind` (kindGlb.ts windProfile) — creator-gl's
88
+ // foliage tier bends and shivers each material as Unity HDRP's Wind.hlsl does.
89
+ // 11: the bark's CAVITY occlusion in COLOR_0.g (vertexAo.ts `cavity`: short rays in the normal's hemisphere — limb joints).
90
+ const BUILDER_VERSION = 12 // 12 (2026-10-18): a material without a metallic map or `_Metallic` is metallic 0, not glTF's 1 (the LOD3 crosses were black)
91
+ const v3 = (v: number[]): string => `[${v.map(r3).join(", ")}]`
92
+ const ident = (s: string): string => { const x = s.replace(/[^\w$]/g, "_"); return /^\d/.test(x) ? `_${x}` : x }
93
+
94
+ const transformFields = (pos: number[], rot: number[], scale?: number[]): string => {
95
+ const e = eulerYXZ(rot).map(r2)
96
+ const parts = [ `position: ${v3(pos)}` ]
97
+ if (e.some((v) => v !== 0)) parts.push(`eulerAngles: [${e.join(", ")}]`)
98
+ 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)}`)
99
+ return parts.join(", ")
100
+ }
101
+
102
+ // ---- colliders -------------------------------------------------------------------------------------
103
+
104
+ type ColliderSpec = { line: (i: number) => string, box: FitBox | null }
105
+
106
+ const STATIC_ASPECT = "use(Physics, { motion: 'static' })"
107
+
108
+ /** The pack's solid colliders, each as the prefab file will spell it — plus the box's own frame, for
109
+ * the fit test (colliderFit.ts) that decides whether the boxes may be trusted at all. */
110
+ const colliderSpecs = (k: SceneKind): { mesh: (KindCollider & { shape: "mesh" }) | undefined, specs: ColliderSpec[] } => {
111
+ const solid = k.colliders.filter((c) => !c.trigger)
112
+ const mesh = solid.find((c) => c.shape === "mesh") as (KindCollider & { shape: "mesh" }) | undefined
113
+ const specs: ColliderSpec[] = []
114
+ for (const c of solid) {
115
+ if (c.shape === "mesh") continue
116
+ const d = decompose(c.matrix)
117
+ const s = d.s.map(Math.abs)
118
+ const center = c.center
119
+ const origin = [ -center[0] * s[0], center[1] * s[1], center[2] * s[2] ] // Unity local → mirrored
120
+ let shape: string
121
+ let rot = d.r
122
+ let half: [number, number, number] | null = null
123
+ if (c.shape === "box") {
124
+ half = [ c.size[0] * s[0] / 2, c.size[1] * s[1] / 2, c.size[2] * s[2] / 2 ]
125
+ shape = `box: ${v3(half)}`
126
+ } else if (c.shape === "sphere") shape = `sphere: ${r3(c.radius * Math.max(...s))}`
127
+ else {
128
+ const ax = c.direction
129
+ const perp = Math.max(...s.filter((_, j) => j !== ax))
130
+ const radius = c.radius * perp
131
+ const halfH = Math.max(0, c.height * s[ax] / 2 - radius)
132
+ shape = `capsule: { halfHeight: ${r3(halfH)}, radius: ${r3(radius)} }`
133
+ // LeCodes capsules stand on Y: an X / Z capsule turns its node (origin stays in the collider's frame)
134
+ if (ax !== 1) {
135
+ const h = Math.SQRT1_2
136
+ const turn = ax === 0 ? [ 0, 0, h, h ] : [ h, 0, 0, h ]
137
+ rot = qmul(rot, turn)
138
+ // the origin was expressed in the unturned frame — rotate it into the turned one
139
+ const o = origin
140
+ if (ax === 0) { const [ ox, oy ] = o; o[0] = oy; o[1] = -ox } else { const [ , oy, oz ] = o; o[1] = oz; o[2] = -oy }
141
+ }
142
+ }
143
+ const box: FitBox | null = half
144
+ ? { 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] ] }
145
+ : null
146
+ specs.push({
147
+ line: (i: number) => ` collider_${i}: { ${transformFields(d.t, rot)}, aspects: [use(Shape, { ${shape}, origin: ${v3(origin)} }), ${STATIC_ASPECT}] },`,
148
+ box,
149
+ })
150
+ }
151
+ return { mesh, specs }
152
+ }
153
+
154
+ /** `forceMesh` = the fit test found a box that floats off the art: the whole kind takes the mesh
155
+ * instead, since keeping the good boxes beside it would collide twice. */
156
+ const colliderNodes = (k: SceneKind, forceMesh = false): { modelAspects: string[], children: string[] } => {
157
+ const modelAspects: string[] = []
158
+ const children: string[] = []
159
+ const { mesh, specs } = colliderSpecs(k)
160
+ // the colliders stay PICKABLE: a game's hitscan is Physics.raycast, and the physics raycast skips a body whose
161
+ // Shape has raycast: false (creator-physics PickableBodyFilter) - the range shot through its own walls (2026-09-18)
162
+ if (mesh || forceMesh) modelAspects.push(`use(Shape, { mesh: ${mesh?.convex ? "'convex'" : "true"} })`, STATIC_ASPECT)
163
+ if (!forceMesh) { let i = 0; for (const spec of specs) children.push(spec.line(++i)) }
164
+ return { modelAspects, children }
165
+ }
166
+
167
+ const kindSceneFile = (k: SceneKind, glbRel: string, source: string, foliage: boolean, noLightmap = false, forceMesh = false, noColliders = false, noShadows = false): string => {
168
+ const { modelAspects, children } = noColliders ? { modelAspects: [] as string[], children: [] as string[] } : colliderNodes(k, forceMesh)
169
+ const shadows = !noShadows && k.parts.some((p) => p.castShadows)
170
+ const model = [ `model: asset('${glbRel}')`, shadows ? "castShadows: true" : "castShadows: false" ]
171
+ // the SDK's vegetation tier: wind, touch bending, per-copy tint (Foliage); a level may add `{ fade }` per kind
172
+ if (foliage) model.push("foliage: true")
173
+ // out of the bake entirely (`--no-lightmap <re>`): no atlas rect, lit in real time
174
+ if (noLightmap) model.push("lightmap: false")
175
+ if (modelAspects.length) model.push(`aspects: [${modelAspects.join(", ")}]`)
176
+ return `// Generated by \`lecodes assets scene\` from ${source}${k.prefabPath ? ` (${k.prefabPath.replace(/\\/g, "/").replace(/^.*\/Assets\//, "Assets/")})` : ""}.
177
+ // Regenerated on every re-import — edit the level that instances it, not this file.
178
+
179
+ export default defineScene({
180
+ nodes: {
181
+ ${[ ` model: { ${model.join(", ")} },`, ...children ].join("\n")}
182
+ },
183
+ })
184
+ `
185
+ }
186
+
187
+ // ---- kinds: GLB + prefab scene ---------------------------------------------------------------------------
188
+
189
+ const rel = (from: string, to: string): string => { const x = relative(from, to).replace(/\\/g, "/"); return x.startsWith(".") ? x : `./${x}` }
190
+
191
+ /** WHERE `name:<material>` REFS ARE ANSWERED FROM. A renderer's material list names a GUID only
192
+ * when something REMAPPED it (the model importer's `externalObjects`, or a prefab / scene
193
+ * override); left alone, it carries the FBX's own material NAME and Unity resolves that by
194
+ * SEARCHING THE PROJECT (`materialSearch` in the importer). So the folder to walk is the pack
195
+ * ROOT — the directory holding unity-index.txt — and not the prefab's grandparent: a pack that
196
+ * keeps `Prefabs/` and `Materials/` as SIBLINGS, which is the ordinary layout, has no .mat file
197
+ * anywhere under that grandparent and every one of those refs then falls back to the FBX's
198
+ * placeholder. That failure is silent AND plausible-looking: an FBX placeholder still carries a
199
+ * diffuse COLOUR, so a glass pane comes out flat grey rather than obviously missing, and the
200
+ * pack's own transparency, textures and smoothness are simply gone. */
201
+ const packRootOf = (prefabPath: string): string => dirname(findUnityIndex(dirname(prefabPath)) ?? join(dirname(prefabPath), ".."))
202
+
203
+ /** One GLB per kind under `assetDir`, rebuilt only when the kind changed (kinds.json) or `force`. */
204
+ const buildKindGlbs = async (kinds: SceneKind[], builder: KindGlbBuilder, assetDir: string, opts: WriteLevelOptions, log: (s: string) => void): Promise<{ converted: number, reused: number }> => {
205
+ const manifestPath = join(assetDir, "kinds.json")
206
+ const manifest: Record<string, string> = existsSync(manifestPath) ? JSON.parse(readFileSync(manifestPath, "utf8")) : {}
207
+ const { analyzeDoc, applyPlan } = await import("../doctor/doctor")
208
+ const { ensureCodecs } = await import("../doctor/reduceGlbBones")
209
+ await ensureCodecs()
210
+ // cards trimmed to their opaque texels BEFORE the KTX2 encode (doctor/cardTrim.ts): a foliage pack's
211
+ // quads are mostly transparent, and that discarded fill was the frame on Twig & Twine's map
212
+ const doctor = { ktx2: opts.ktx2 !== false, maxSize: opts.maxSize ?? 1024, fixEmissive: true, trimCards: !opts.noTrimCards }
213
+ let converted = 0, reused = 0
214
+ for (const [ i, k ] of kinds.entries()) {
215
+ const glb = join(assetDir, `${k.name}.glb`)
216
+ const maxTris = opts.maxTris ?? DEFAULT_MAX_TRIS
217
+ // VERTEX COLOURS ARE NOT COLOURS in a Unity pack: the Standard / HDRP Lit shaders never read them, and
218
+ // a custom shader reads them as DATA — NatureManufacture bakes its wind weight there, (1, 0, 0, 1) on
219
+ // every branch vertex. glTF's rule is the opposite (COLOR_0 tints the base colour), so the ubershader
220
+ // painted every broken branch pure red (2026-09-23). Non-foliage kinds lose COLOR_0; the vegetation
221
+ // tier keeps it — its shader ignores the tint, and the alpha IS the wind weight it will read.
222
+ const foliage = decideFoliage(k, builder.materialsOf(k), opts)
223
+ const hash = createHash("sha1").update(JSON.stringify([ BUILDER_VERSION, k.key, opts.maxSize, opts.ktx2, maxTris, ...(foliage ? [] : [ "nocolor" ]) ])).digest("hex").slice(0, 16)
224
+ if (!opts.force && existsSync(glb) && manifest[k.name] === hash) { reused++; continue }
225
+ const { doc, parts, missing } = await builder.build(k)
226
+ 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() } }
227
+ // and the vegetation tier gets its VERTEX AO: the sky's visibility from every vertex baked into COLOR_0.r
228
+ // (doctor/vertexAo.ts), the wind weight kept in the alpha — the one thing that makes a crown read as a volume
229
+ if (foliage) {
230
+ const { bakeVertexAo } = await import("../doctor/vertexAo")
231
+ const ao = bakeVertexAo(doc)
232
+ if (ao.vertices) log(` 🌿 ${k.name}: vertex AO on ${ao.vertices} vertices (mean ${ao.meanAo.toFixed(2)})`)
233
+ }
234
+ for (const m of missing) log(` ⚠ ${k.name}: ${m}`)
235
+ if (!parts) { log(` ⚠ ${k.name}: no mesh could be built — skipped`); continue }
236
+ const cut = await fitTriangleBudget(doc, maxTris)
237
+ if (cut) log(` ✂ ${k.name}: ${cut.before.toLocaleString("en")} → ${cut.after.toLocaleString("en")} triangles (--max-tris ${maxTris})`)
238
+ await applyPlan(doc, await analyzeDoc(doc, doctor), doctor)
239
+ writeFileSync(glb, await makeIO().writeBinary(doc))
240
+ manifest[k.name] = hash
241
+ converted++
242
+ log(` [${i + 1}/${kinds.length}] ${k.name}.glb · ${parts} part(s) · ${Math.round(readFileSync(glb).length / 1024)} KB`)
243
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 1) + "\n")
244
+ }
245
+ return { converted, reused }
246
+ }
247
+
248
+ /** The LOD0 triangles of a kind's GLB, read once (the fit test below asks for the same file per kind). */
249
+ const triCache = new Map<string, FitTriangles | null>()
250
+ const trianglesOf = (glb: string): FitTriangles | null => {
251
+ const hit = triCache.get(glb)
252
+ if (hit !== undefined) return hit
253
+ let tris: FitTriangles | null = null
254
+ try { tris = readGlbTriangles(readFileSync(glb)) } catch { tris = null }
255
+ triCache.set(glb, tris)
256
+ return tris
257
+ }
258
+
259
+ /** `src/<map>/<Kind>.scene.ts` for every kind that has its GLB, with the foliage / no-lightmap / collider verdicts. */
260
+ const writeKindScenes = (kinds: SceneKind[], builder: KindGlbBuilder, dirs: { assets: string, src: string }, source: string, opts: WriteLevelOptions, log: (s: string) => void): void => {
261
+ const foliageKinds: string[] = []
262
+ const unbakedKinds: string[] = []
263
+ const tol = opts.colliderFit ?? 0.015
264
+ const meshKinds: string[] = []
265
+ let boxKinds = 0
266
+ for (const k of kinds) {
267
+ const foliage = decideFoliage(k, builder.materialsOf(k), opts)
268
+ if (foliage) foliageKinds.push(k.name)
269
+ const noLightmap = !!opts.noLightmap?.test(k.name)
270
+ if (noLightmap) unbakedKinds.push(k.name)
271
+ // DOES THE PACK'S BOX SIT ON THE ART? (colliderFit.ts, `--collider-fit`) — measured against the
272
+ // kind's own GLB, which is on disk by now whether it was rebuilt or reused
273
+ const glb = join(dirs.assets, `${k.name}.glb`)
274
+ const boxes = colliderSpecs(k).specs.map((x) => x.box).filter((b): b is FitBox => b !== null)
275
+ let forceMesh = false
276
+ if (boxes.length > 0 && existsSync(glb) && !opts.noColliders) {
277
+ boxKinds++
278
+ const v = fitVerdict(trianglesOf(glb), boxes, { tolerance: tol })
279
+ if (v.mesh) {
280
+ forceMesh = true
281
+ meshKinds.push(`${k.name} (${v.worst && v.worst.covered >= 0.25 ? `${Math.round(v.worst.gap * 100)} cm off` : "over a void"})`)
282
+ }
283
+ }
284
+ 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)))
285
+ }
286
+ if (boxKinds > 0) {
287
+ 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}` : ""}` : ""}`)
288
+ }
289
+ log(` ${describeFoliage(foliageKinds, opts)}`)
290
+ if (opts.noLightmap) log(` 🔦 lightmap: false on ${unbakedKinds.length} kind(s)${unbakedKinds.length ? `: ${unbakedKinds.slice(0, 8).join(", ")}${unbakedKinds.length > 8 ? " …" : ""}` : ` (nothing matched /${opts.noLightmap.source}/)`}`)
291
+ }
292
+
293
+ // ---- the map scene -----------------------------------------------------------------------------------------
294
+
295
+ const terrainNode = (r: SceneImportFull, terrain: WrittenTerrain, sdir: string): string[] => {
296
+ const data = r.terrainData!
297
+ const layers = terrain.layers.map((f, i) => {
298
+ const l = data.layers.find((x) => x.name.replace(/^LAYER_/i, "").toLowerCase() === f.replace(/^.*[\\/]/, "").replace(/\.(ktx2|png)$/, "")) ?? data.layers[i]
299
+ 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)}` : ""} }`
300
+ })
301
+ const normals = terrain.normals.map((f) => `asset('${rel(sdir, f)}')`)
302
+ return [
303
+ ` terrain: {`,
304
+ ` terrain: { data: asset('${rel(sdir, terrain.file)}'), layers: [${layers.join(", ")}]${normals.length ? `, normals: [${normals.join(", ")}]` : ""} },`,
305
+ ` position: ${v3(r.terrain!.position)}, receiveShadows: true,`,
306
+ ` aspects: [use(Shape, { heightfield: true }), use(Physics, { motion: 'static' })],`,
307
+ ` },`,
308
+ ]
309
+ }
310
+
311
+ /** Placements grouped like the Unity scene: its top-level group objects (+ Trees / Details for the terrain's). */
312
+ const placementGroups = (r: SceneImportFull, usable: Set<string>): string[] => {
313
+ const groups = new Map<string, string[]>()
314
+ const used = new Map<string, number>()
315
+ for (const inst of r.instances) {
316
+ if (!usable.has(inst.kind)) continue
317
+ const g = inst.group.startsWith("Terrain/") ? inst.group.slice(8) : (inst.group.split("/")[0] || "Objects")
318
+ const key = ident(g.replace(/^_+/, "")) || "Objects"
319
+ const n = (used.get(`${key}/${inst.kind}`) ?? 0) + 1
320
+ used.set(`${key}/${inst.kind}`, n)
321
+ const list = groups.get(key) ?? []
322
+ list.push(` ${ident(inst.kind)}_${n}: { prefab: ${ident(inst.kind)}, ${transformFields(inst.position, inst.rotation, inst.scale)} },`)
323
+ groups.set(key, list)
324
+ }
325
+ return [ ...groups ].flatMap(([ g, list ]) => [ ` ${g}: {`, ` children: {`, ...list, ` },`, ` },` ])
326
+ }
327
+
328
+ const mapSceneFile = (r: SceneImportFull, name: string, usable: SceneKind[], terrain: WrittenTerrain | null, sdir: string, opts: WriteLevelOptions): string => {
329
+ const lines: string[] = []
330
+ lines.push(`// Generated by \`lecodes assets scene\` from ${r.source} — ${r.instances.length} placements of ${usable.length} kinds.`)
331
+ lines.push("// Regenerated on every re-import: instance this file as a prefab in a level of your own and put the")
332
+ lines.push("// gameplay there. Frame: Unity mirrored on X, metres, Y up.")
333
+ if (opts.command) {
334
+ lines.push("//")
335
+ lines.push("// RE-IMPORT WITH THE SAME LINE — the flags carry decisions this file cannot show (kinds dropped,")
336
+ lines.push("// placements dropped at a point, kinds kept out of the lightmap); drop one and it silently undoes itself:")
337
+ lines.push(`// ${opts.command}`)
338
+ }
339
+ lines.push("")
340
+ for (const k of usable) lines.push(`import ${ident(k.name)} from './${name}/${k.name}.scene'`)
341
+ lines.push("")
342
+ lines.push("export default defineScene({")
343
+ const cam = r.cameras[0]
344
+ if (cam) {
345
+ const target = cam.position.map((v, i) => v + cam.forward[i] * 30)
346
+ lines.push(` camera: { position: ${v3(cam.position)}, target: ${v3(target)}, fov: ${r2(cam.fov)} },`)
347
+ }
348
+ lines.push(" nodes: {")
349
+ const sun = opts.noSun ? undefined : r.lights.find((l) => l.type === "directional")
350
+ 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 } },`)
351
+ if (terrain && r.terrain) lines.push(...terrainNode(r, terrain, sdir))
352
+ lines.push(...placementGroups(r, new Set(usable.map((k) => k.name))))
353
+ lines.push(" },", "})", "")
354
+ return lines.join("\n")
355
+ }
356
+
357
+ // ---- the whole level ---------------------------------------------------------------------------------
358
+
359
+ export const writeLeCodesLevel = async (r: SceneImportFull, opts: WriteLevelOptions): Promise<{ scene: string, kinds: number, converted: number, reused: number }> => {
360
+ const log = opts.log ?? console.log
361
+ const project = resolve(opts.project)
362
+ const name = (opts.name ?? r.source.replace(/\.unity$/i, "")).toLowerCase().replace(/[^\w-]+/g, "_")
363
+ const dirs = { assets: join(project, "assets", name), src: join(project, "src", name) }
364
+ mkdirSync(dirs.assets, { recursive: true }); mkdirSync(dirs.src, { recursive: true })
365
+
366
+ const kinds = Object.values(r.kinds)
367
+ const anyPrefab = kinds.find((k) => k.prefabPath)?.prefabPath
368
+ const builder = new KindGlbBuilder({
369
+ resolver: resolverFor(anyPrefab ?? project),
370
+ materialDirs: anyPrefab ? [ packRootOf(anyPrefab) ] : [],
371
+ warn: (m) => log(` ⚠ ${m}`),
372
+ })
373
+ const { converted, reused } = await buildKindGlbs(kinds, builder, dirs.assets, opts, log)
374
+ const usable = kinds.filter((k) => existsSync(join(dirs.assets, `${k.name}.glb`)))
375
+ writeKindScenes(usable, builder, dirs, r.source, opts, log)
376
+ const terrain = await writeTerrain(r, join(dirs.assets, "terrain"), name, opts, log)
377
+
378
+ const sceneFile = join(project, "src", `${name}.scene.ts`)
379
+ writeFileSync(sceneFile, mapSceneFile(r, name, usable, terrain, dirname(sceneFile), opts))
380
+ return { scene: sceneFile, kinds: usable.length, converted, reused }
381
+ }
@@ -0,0 +1,116 @@
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 }, 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
+ const res = t.resolution
75
+ // mirror the columns: LeCodes sample (0,0) is the Unity terrain's +X far corner
76
+ const heights = new Float32Array(res * res)
77
+ for (let z = 0; z < res; z++) for (let x = 0; x < res; x++) heights[z * res + x] = t.heights[z * res + (res - 1 - x)]
78
+ const plan = await planLayers(t)
79
+ const slot = new Map<number, number>()
80
+ plan.keep.forEach((li, s) => slot.set(li, s))
81
+ for (const [ lose, win ] of plan.mergeInto) slot.set(lose, slot.get(win)!)
82
+ // control: area-average the (finer) alphamaps onto the samples
83
+ const ar = t.alphaResolution, control = new Uint8Array(res * res * 4)
84
+ const f = ar / res
85
+ for (let z = 0; z < res; z++) for (let x = 0; x < res; x++) {
86
+ const ux = res - 1 - x // mirrored column
87
+ const acc = [ 0, 0, 0, 0 ]
88
+ const ax0 = Math.max(0, Math.floor((ux - 0.5) * f)), ax1 = Math.min(ar - 1, Math.ceil((ux + 0.5) * f))
89
+ const az0 = Math.max(0, Math.floor((z - 0.5) * f)), az1 = Math.min(ar - 1, Math.ceil((z + 0.5) * f))
90
+ for (let li = 0; li < t.weights.length; li++) {
91
+ const w = t.weights[li]
92
+ let s = 0
93
+ for (let az = az0; az <= az1; az++) for (let ax = ax0; ax <= ax1; ax++) s += w[az * ar + ax]
94
+ acc[slot.get(li)!] += s
95
+ }
96
+ const tot = acc[0] + acc[1] + acc[2] + acc[3] || 1
97
+ for (let c = 0; c < 4; c++) control[(z * res + x) * 4 + c] = Math.round(acc[c] / tot * 255)
98
+ }
99
+ const kept = plan.keep.map((li) => t.layers[li])
100
+ const file = join(dir, `${name}.terrain`)
101
+ // ROUGHNESS (terrain.mat: a layer's roughness = its factor × the ALPHA of its albedo, per pixel). An HDRP
102
+ // layer with a mask map keeps its smoothness in the mask's ALPHA and `m_Smoothness` is a MULTIPLIER on
103
+ // it (1 in every NatureManufacture layer) — read as the smoothness itself it made the whole terrain a
104
+ // mirror (factor 0: the ground sparkled under the sun, 2026-09-23). Now the mask rides the albedo's
105
+ // alpha as 1 − smoothness (terrainPack.ts `smoothnessAlpha`) and the factor is 1 — the multiplier is
106
+ // not expressible per pixel and is dropped (logged). No mask = the number IS the
107
+ // smoothness (Unity's built-in terrain), so the factor is 1 − it.
108
+ 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 }))
109
+ log(` 🪨 terrain roughness: ${kept.map((l, i) => `${l.name} ${l.mask ? "mask alpha" : "flat"} ×${layers[i].roughness.toFixed(2)}`).join(", ")}`)
110
+ writeTerrainFile(file, res, res, t.size[0] / (res - 1), heights, control, layers)
111
+ 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(", ")})` : ""}`)
112
+ const { packTerrainTextures } = await import("../terrainPack")
113
+ 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: () => {} })
114
+ return { file, layers: packed.layers.map((l) => l.file), normals: packed.normals }
115
+ }
116
+