diff --git a/apps/game/public/assets/PROVENANCE.md b/apps/game/public/assets/PROVENANCE.md index 43b0e8f..7d2a3f0 100644 --- a/apps/game/public/assets/PROVENANCE.md +++ b/apps/game/public/assets/PROVENANCE.md @@ -101,11 +101,19 @@ material-slot or `COL_` conventions the generated props use; ## Synty POLYGON Military -| File | Source | Licence | -| ------------------------------ | ------------------------------------------------------- | ------------------------------- | -| `models/fighter_insurgent.glb` | Synty POLYGON Military — `SK_Chr_Insurgent_Male_01` | Synty Store licence (purchased) | -| `models/fighter_soldier.glb` | Synty POLYGON Military — `SK_Chr_Soldier_Male_01` | Synty Store licence (purchased) | -| `textures/synty_atlas.webp` | Synty POLYGON Military — `PolygonMilitary_Texture_01_A` | Synty Store licence (purchased) | +| File | Source | Licence | +| ------------------------------ | ----------------------------------------------------------- | ------------------------------- | +| `models/fighter_insurgent.glb` | Synty POLYGON Military — `SK_Chr_Insurgent_Male_01` | Synty Store licence (purchased) | +| `models/fighter_soldier.glb` | Synty POLYGON Military — `SK_Chr_Soldier_Male_01` | Synty Store licence (purchased) | +| `models/veh_armored_car.glb` | Synty POLYGON Military — `SM_Veh_Light_Armored_Car_01` | Synty Store licence (purchased) | +| `models/veh_technical.glb` | Synty POLYGON Military — `SM_Veh_Pickup_Technical_01` | Synty Store licence (purchased) | +| `models/prop_barrel.glb` | Synty POLYGON Military — `SM_Prop_Barrel_01` | Synty Store licence (purchased) | +| `models/prop_barrel_stack.glb` | Synty POLYGON Military — `SM_Prop_Barrel_Stack_01` | Synty Store licence (purchased) | +| `models/prop_ammo_box.glb` | Synty POLYGON Military — `SM_Prop_AmmoBox_01` | Synty Store licence (purchased) | +| `models/prop_barrier.glb` | Synty POLYGON Military — `SM_Prop_Barrier_Tall_01` | Synty Store licence (purchased) | +| `models/prop_water_tank.glb` | Synty POLYGON Military — `SM_Prop_WaterTank_02` | Synty Store licence (purchased) | +| `textures/synty_atlas.webp` | Synty POLYGON Military — `PolygonMilitary_Texture_01_A` | Synty Store licence (purchased) | +| `textures/synty_vehicles.webp` | Synty POLYGON Military — `PolygonMilitary_Land_Vehicles_03` | Synty Store licence (purchased) | Purchased from https://syntystore.com. The licence grants perpetual, royalty-free commercial use in unlimited titles and permits modification; it @@ -131,3 +139,31 @@ plain glTF path cannot: Kept clips: `Idle_Gun`, `Idle_Gun_Shoot`, `Walk`, `Run`, `Run_Shoot`, `Death`, `HitRecieve`. + +### Vehicles and props (static meshes) + +The vehicles and props carry no rig, so none of the character retarget applies. +They are converted with `tools/art/blender/import_synty_prop.py`, which is the +easy path the characters could not take: + +- **Shared atlases, so props cost no texture at all.** Every prop was authored + against `PolygonMilitary_Texture_01_A`, which already ships as + `synty_atlas.webp` for the characters, so the props reuse it and add only + geometry. The vehicles use Synty's Land_Vehicles atlas; the pack ships ten + recolour variants over one shared UV layout, so **one** desert variant + (`Land_Vehicles_03`) is shipped as `synty_vehicles.webp` (76 KB) and bound to + every vehicle mesh, rather than a separate 2 MB texture per vehicle. Glass was + authored against Texture_01_A, so glass meshes are routed to `synty_atlas` by + mesh name at convert time. +- **Decimated to fit the shell budget.** Synty's vehicle FBX is far denser than + the game needs for set-dressing — the armoured car imports at 24k triangles / + 1.5 MB uncompressed. The two vehicles are decimated (car 0.4, pickup 0.5) to + land near 0.5 MB and 0.4 MB, verified by preview render to be indistinguishable + at gameplay distance. Props ship undecimated; they are already 12–143 KB. +- **No embedded textures**, exactly like the characters and generated props: the + GLB names its material slot and the engine binds the atlas by that name. + +These are licensed static meshes, so — like the characters — they do not follow +the generated props' material-slot or `COL_` conventions; +`apps/game/src/assets.test.ts` exempts them explicitly. They are cosmetic +set-dressing and carry no collision proxy. diff --git a/apps/game/public/assets/manifest.json b/apps/game/public/assets/manifest.json index b1007ce..ccaf091 100644 --- a/apps/game/public/assets/manifest.json +++ b/apps/game/public/assets/manifest.json @@ -1,7 +1,7 @@ { "generator": "tools/art/build-assets.mjs", "blender": "Blender 4.5.12 LTS", - "commit": "af4fbdcb5b822384fb6f8c3f84e98141465453aa", + "commit": "dfb11e7362e0b5e6f91388e6af2457edb96d0764", "license": "Original work, © NIGHTCELL 7. No third-party assets.", "source": "Generated procedurally from the scripts in tools/art. No asset is downloaded, photographed, traced, or derived from another game.", "textureSize": 1024, @@ -16,8 +16,15 @@ "hardpoint.glb", "lamp_mast.glb", "pipe_rack.glb", + "prop_ammo_box.glb", + "prop_barrel.glb", + "prop_barrel_stack.glb", + "prop_barrier.glb", + "prop_water_tank.glb", "stair.glb", "tank.glb", + "veh_armored_car.glb", + "veh_technical.glb", "wall.glb" ], "textures": [ @@ -43,7 +50,8 @@ "steel_albedo.webp", "steel_normal.webp", "steel_orm.webp", - "synty_atlas.webp" + "synty_atlas.webp", + "synty_vehicles.webp" ], "audio": [ "ambience_yard.mp3", @@ -78,9 +86,9 @@ "throngva/the-wolf-called-want.mp3" ], "bytes": { - "models": 2724980, - "textures": 2328964, + "models": 3905884, + "textures": 2405506, "audio": 323166, - "total": 5377110 + "total": 6634556 } } diff --git a/apps/game/public/assets/models/prop_ammo_box.glb b/apps/game/public/assets/models/prop_ammo_box.glb new file mode 100644 index 0000000..7ff2a2e Binary files /dev/null and b/apps/game/public/assets/models/prop_ammo_box.glb differ diff --git a/apps/game/public/assets/models/prop_barrel.glb b/apps/game/public/assets/models/prop_barrel.glb new file mode 100644 index 0000000..606c0bd Binary files /dev/null and b/apps/game/public/assets/models/prop_barrel.glb differ diff --git a/apps/game/public/assets/models/prop_barrel_stack.glb b/apps/game/public/assets/models/prop_barrel_stack.glb new file mode 100644 index 0000000..0d2af0e Binary files /dev/null and b/apps/game/public/assets/models/prop_barrel_stack.glb differ diff --git a/apps/game/public/assets/models/prop_barrier.glb b/apps/game/public/assets/models/prop_barrier.glb new file mode 100644 index 0000000..6f108b1 Binary files /dev/null and b/apps/game/public/assets/models/prop_barrier.glb differ diff --git a/apps/game/public/assets/models/prop_water_tank.glb b/apps/game/public/assets/models/prop_water_tank.glb new file mode 100644 index 0000000..6bcba52 Binary files /dev/null and b/apps/game/public/assets/models/prop_water_tank.glb differ diff --git a/apps/game/public/assets/models/veh_armored_car.glb b/apps/game/public/assets/models/veh_armored_car.glb new file mode 100644 index 0000000..b47fa8f Binary files /dev/null and b/apps/game/public/assets/models/veh_armored_car.glb differ diff --git a/apps/game/public/assets/models/veh_technical.glb b/apps/game/public/assets/models/veh_technical.glb new file mode 100644 index 0000000..7305e70 Binary files /dev/null and b/apps/game/public/assets/models/veh_technical.glb differ diff --git a/apps/game/public/assets/textures/synty_vehicles.webp b/apps/game/public/assets/textures/synty_vehicles.webp new file mode 100644 index 0000000..3d0bc56 Binary files /dev/null and b/apps/game/public/assets/textures/synty_vehicles.webp differ diff --git a/apps/game/src/assets.test.ts b/apps/game/src/assets.test.ts index a52271e..d9abd78 100644 --- a/apps/game/src/assets.test.ts +++ b/apps/game/src/assets.test.ts @@ -78,7 +78,21 @@ describe("generated models", () => { * apply. Their provenance is a licence recorded in PROVENANCE.md rather than * a generator script. */ - const LICENSED = new Set(["fighter_insurgent", "fighter_soldier"]); + const LICENSED = new Set([ + "fighter_insurgent", + "fighter_soldier", + // Synty POLYGON Military static meshes: licensed, and bound by their own + // atlas slots (`synty_vehicles` / `synty_atlas`) rather than the generated + // material set. Their provenance is the Synty licence in PROVENANCE.md, and + // they are cosmetic set-dressing that never collides, so no COL_ proxy. + "veh_armored_car", + "veh_technical", + "prop_barrel", + "prop_barrel_stack", + "prop_ammo_box", + "prop_barrier", + "prop_water_tank", + ]); it("only uses material slots the engine can bind", () => { // A slot the engine does not know about is not an error at load time: the diff --git a/apps/game/src/assets.ts b/apps/game/src/assets.ts index 59a9a75..5f8f0f3 100644 --- a/apps/game/src/assets.ts +++ b/apps/game/src/assets.ts @@ -60,6 +60,17 @@ export const MODELS = [ // generated props follow — see apps/game/public/assets/PROVENANCE.md. "fighter_insurgent", "fighter_soldier", + // Licensed Synty POLYGON Military static meshes. Vehicles bind the shared + // `synty_vehicles` atlas; props reuse the character atlas (`synty_atlas`), + // since both were authored against Synty's Texture_01_A. No new texture ships + // for the props at all — see apps/game/public/assets/PROVENANCE.md. + "veh_armored_car", + "veh_technical", + "prop_barrel", + "prop_barrel_stack", + "prop_ammo_box", + "prop_barrier", + "prop_water_tank", ] as const; export type ModelName = (typeof MODELS)[number]; @@ -148,6 +159,22 @@ export function createMaterials(scene: Scene): Map 32, |z| > 44) and inside the walls. + // + // These are the one deliberate exception to rule 1 at the top of this file: + // they are NOT generated from collision data and carry no collider, so a + // player can walk through them. That is why they are confined to the + // protected spawn ends and never to the three combat lanes — nothing here can + // become fake cover in a firefight. Promoting any of them to real cover is a + // deliberate change to the server map (`packages/multiplayer-sim/src/map.ts`) + // and its checksum, not something to smuggle in as art. One vehicle sits at + // each end so the two factions read as having staged the yard from opposite + // sides. + put("veh_armored_car", "veh_car", [{ position: new Vector3(-31, 0, -52), rotationY: 0.6 }]); + put("veh_technical", "veh_tech", [{ position: new Vector3(31, 0, 52), rotationY: -2.4 }]); + + // Supply depots flanking each vehicle, plus a checkpoint line of T-wall + // barriers along each back wall behind the spawns. + put("prop_barrel_stack", "barrels_n", [ + { position: new Vector3(-34.5, 0, -49.5), rotationY: 0.3 }, + ]); + put("prop_barrel_stack", "barrels_s", [ + { position: new Vector3(34.5, 0, 49.5), rotationY: -2.9 }, + ]); + put("prop_barrel", "barrel", [ + { position: new Vector3(-30, 0, -54), rotationY: 1.1 }, + { position: new Vector3(-28.5, 0, -52.5), rotationY: -0.4 }, + { position: new Vector3(29, 0, 54), rotationY: 2.2 }, + { position: new Vector3(27.5, 0, 52.5), rotationY: 0.7 }, + ]); + put("prop_ammo_box", "ammo", [ + { position: new Vector3(-33, 0, -53), rotationY: 0.5 }, + { position: new Vector3(-35, 0, -46), rotationY: -0.8 }, + { position: new Vector3(33, 0, 53), rotationY: -2.4 }, + { position: new Vector3(35, 0, 46), rotationY: 2.5 }, + ]); + put("prop_water_tank", "watertank", [ + { position: new Vector3(35, 0, -52), rotationY: -2.2 }, + { position: new Vector3(-35, 0, 52), rotationY: 0.9 }, + ]); + put("prop_barrier", "barrier", [ + { position: new Vector3(-6, 0, -56), rotationY: 0 }, + { position: new Vector3(-2, 0, -56), rotationY: 0 }, + { position: new Vector3(2, 0, -56), rotationY: 0 }, + { position: new Vector3(6, 0, -56), rotationY: 0 }, + { position: new Vector3(-6, 0, 56), rotationY: Math.PI }, + { position: new Vector3(-2, 0, 56), rotationY: Math.PI }, + { position: new Vector3(2, 0, 56), rotationY: Math.PI }, + { position: new Vector3(6, 0, 56), rotationY: Math.PI }, + ]); + // Two cold accent lights mark the opposing spawn ends, echoing the split // palette the whole product is built on. const southMark = new PointLight("mark-south", new Vector3(0, 5, 50), scene); diff --git a/docs/HANDOFF-synty.md b/docs/HANDOFF-synty.md index 3bdf7ad..51c1746 100644 --- a/docs/HANDOFF-synty.md +++ b/docs/HANDOFF-synty.md @@ -12,13 +12,16 @@ oversight. The Synty characters are committed, load fine, are textured, and are **not shipped**, because their animation retarget is broken. -| Thing | State | -| ----------------------------------------------------- | ------------------------------------------- | -| `models/fighter_insurgent.glb`, `fighter_soldier.glb` | committed, load, textured, animation wrong | -| `textures/synty_atlas.webp` | working, 357 KB, shared by both | -| `tools/art/blender/import_synty.py` | converter; maths fixed, export broken | -| Vehicles, weapons, props | **none integrated** — 2 of 1,954 files used | -| Shell budget | 5.13 MB against a 9 MB guard | +| Thing | State | +| ----------------------------------------------------- | ----------------------------------------------- | +| `models/fighter_insurgent.glb`, `fighter_soldier.glb` | committed, load, textured, animation wrong | +| `textures/synty_atlas.webp` | working, 357 KB, shared by characters + props | +| `tools/art/blender/import_synty.py` | character converter; maths fixed, export broken | +| `tools/art/blender/import_synty_prop.py` | static-mesh converter; **working, shipped** | +| 2 vehicles + 5 props (`veh_*`, `prop_*`) | **integrated** — converted, textured, placed | +| `textures/synty_vehicles.webp` | working, 76 KB, shared by all vehicles | +| Weapons | **none integrated** — still the next win | +| Shell budget | 6.33 MB against a 9 MB guard | Source pack is at `~/src/nightcell7-assets/SourceFiles/` (406 MB, outside the repo, not committed). @@ -78,15 +81,29 @@ single animation named `target_rig`** — after the object, not the actions. names change from lowercase (`walk`/`run`/`idle`/`death`) to Synty's (`Walk`/`Run`/`Idle_Gun`/`Death`). -4. **Then vehicles and props.** `SM_Veh_*` and `SM_Prop_*` are **static - meshes** — no retargeting, so far easier than the characters and a bigger - visual win. Good candidates: `SM_Veh_Pickup_Technical_01`, - `SM_Veh_Truck_01_Tanker`, `SM_Veh_Light_Armored_Car_01`, - `SM_Veh_Helicopter_Attack_02`. Textures come from - `PolygonMilitary_Land_Vehicles_*` and `Veh_Heli_*`. +4. ~~**Then vehicles and props.**~~ **Done.** `import_synty_prop.py` converts + the static `SM_Veh_*` / `SM_Prop_*` meshes: no retarget, just unit apply, + recentre-to-ground, per-mesh material naming, weld and (for vehicles) + decimate. Shipped: `veh_armored_car`, `veh_technical` and five props + (`prop_barrel`, `prop_barrel_stack`, `prop_ammo_box`, `prop_barrier`, + `prop_water_tank`). Two lessons for weapons next: + - **The FBX is already metres and uses one placeholder material.** No 0.01 + scale (that was the _skeletal_ path); split body vs glass by mesh name. + - **Land_Vehicles_NN are one UV layout in ten recolours,** so one variant + (`03`, desert) is bound to every vehicle as `synty_vehicles`. Props reuse + `synty_atlas` (Texture_01_A) and cost no new texture at all. + - **Vehicles decimate hard without visible loss** (car 24k→~10k tris, + 1.5 MB→0.5 MB). Verified by preview render, not by eye. + + Placement is cosmetic set-dressing in the spawn zones and back corners + (`world.ts`, "cosmetic set-dressing" block) — deliberately non-colliding and + kept out of the three lanes, so rule 1 still holds for the play space. + Promoting any to real cover is a `map.ts` collision + checksum change. 5. **Then weapons.** `SM_Wep_*` with the `PolygonMilitary_Weapons_*` atlases, - replacing the generated carbine. + replacing the generated carbine. `import_synty_prop.py` is the right starting + point, but a weapon needs a `SOCKET_MUZZLE` node and is exempt from the + `COL_` rule — see how `assets.test.ts` handles the carbine. ## Traps already hit — do not repeat @@ -103,7 +120,10 @@ single animation named `target_rig`** — after the object, not the actions. - **Blender appends `.001`** if an action name is taken, and the engine looks clips up by exact name. Rename the source out of the way first, then delete the renamed sources before export or they ship alongside and double the file. -- **Synty FBX is centimetres**; scale by 0.01 on import. +- **The _skeletal_ (`SK_Chr_*`) FBX imports at centimetres**; scale by 0.01. + The **static** (`SM_*`) FBX does not — Blender reads its unit metadata and + lands it at metres already (`import_synty_prop.py` only _applies_ the + importer's transform, and range-checks the result). Do not blanket-scale. - **Budget accounting counts flat `.mp3` files in the audio root only.** Music lives in `audio/music//` and is streamed, deliberately outside the shell budget. diff --git a/tools/art/blender/import_synty_prop.py b/tools/art/blender/import_synty_prop.py new file mode 100644 index 0000000..1bb14e6 --- /dev/null +++ b/tools/art/blender/import_synty_prop.py @@ -0,0 +1,302 @@ +""" +Import a Synty POLYGON static mesh (vehicle or prop) and bind it to a shared +engine material slot. + +This is the easy sibling of `import_synty.py`. That script exists only because +Synty ships its *characters* without animation and the clips have to be +retargeted onto a foreign skeleton — a hard, brittle job. Vehicles and props +carry no rig and no animation, so there is nothing to retarget: the whole job +is unit conversion, material naming, and recentring so the engine can place the +model by its ground contact point. + +Two facts about the raw SourceFiles FBX, both confirmed by probing, shape this: + + 1. **The FBX is already in metres.** Blender's FBX importer reads Synty's unit + metadata and lands the mesh at real-world scale (an armoured car imports at + 2.5 x 1.5 x 4.65 m). `import_synty.py`'s manual 0.01 was for the *skeletal* + character FBX, which imports differently; applying it here would shrink a + vehicle to centimetres. So this script scales nothing — it only *applies* + the importer's transform so it bakes into the vertices. + + 2. **Every mesh shares one placeholder material ("VehicleSHD").** The Unity + prefab assigns real materials per mesh (body -> a Land_Vehicles atlas, + windows -> the shared Texture_01_A atlas), but that assignment does not + survive to the FBX — it is one generic shader on everything. The mesh + *names* do survive, though, and they carry the distinction: a windscreen is + "..._Glass...". So materials are assigned by mesh name, not by the source + material name, which does not exist here. + +The engine binds textures by material-slot name at load time exactly as it does +for the characters and the generated props (see `apps/game/src/assets.ts`), so +the GLB ships no embedded image. That is what lets every vehicle share one +357 KB atlas instead of embedding a 2 MB texture apiece. + +Usage: + blender --background --factory-startup --python import_synty_prop.py -- \ + --fbx --out \ + --primary-slot synty_vehicles \ + --primary-atlas \ + --glass-atlas \ + [--preview ] + +`--primary-slot` defaults to `synty_atlas`, which is right for props: they were +authored against Texture_01_A, which the game already ships under that name, so +a prop needs no new texture at all. The two `--atlas` arguments are only used to +render the preview faithfully; the exported GLB never carries them. +""" + +from __future__ import annotations + +import math +import os +import sys + +import bpy +from mathutils import Vector + + +def arg(name: str, fallback=None): + argv = sys.argv + return argv[argv.index(name) + 1] if name in argv else fallback + + +def has(name: str) -> bool: + return name in sys.argv + + +def visible_meshes(): + return [o for o in bpy.data.objects if o.type == "MESH"] + + +def combined_bounds(objects): + """World-space bounding box over `objects`.""" + lo = Vector((1e9, 1e9, 1e9)) + hi = Vector((-1e9, -1e9, -1e9)) + for obj in objects: + for corner in obj.bound_box: + world = obj.matrix_world @ Vector(corner) + lo = Vector((min(lo[i], world[i]) for i in range(3))) + hi = Vector((max(hi[i], world[i]) for i in range(3))) + return lo, hi + + +def bind_atlas(material, atlas_path): + """Attach `atlas_path` to the material's base colour for a faithful preview. + + The exported GLB drops this again (`export_image_format="NONE"`); it exists + so the preview render shows the real UVs sampling the real atlas, which is + the only honest test that a single shared atlas maps a given vehicle + correctly. + """ + material.use_nodes = True + bsdf = material.node_tree.nodes.get("Principled BSDF") + if not bsdf: + return + image = bpy.data.images.load(atlas_path, check_existing=True) + tex = material.node_tree.nodes.new("ShaderNodeTexImage") + tex.image = image + material.node_tree.links.new(bsdf.inputs["Base Color"], tex.outputs["Color"]) + bsdf.inputs["Metallic"].default_value = 0.0 + bsdf.inputs["Roughness"].default_value = 0.75 + + +def render_preview(out_png: str) -> None: + """A cheap hero shot to eyeball geometry and texturing. + + Deliberately a near-copy of `preview.py`'s framing so previews across the + asset set read consistently. Cycles on CPU: a headless box has no GL + context for EEVEE. + """ + lo, hi = combined_bounds(visible_meshes()) + centre = (lo + hi) / 2 + radius = max((hi - lo).length / 2, 0.5) + + bpy.ops.mesh.primitive_plane_add(size=radius * 20, location=(centre.x, centre.y, lo.z)) + ground = bpy.context.active_object + ground_mat = bpy.data.materials.new("preview_ground") + ground_mat.use_nodes = True + gb = ground_mat.node_tree.nodes["Principled BSDF"] + gb.inputs["Base Color"].default_value = (0.05, 0.055, 0.065, 1.0) + gb.inputs["Roughness"].default_value = 0.7 + ground.data.materials.append(ground_mat) + + aim = bpy.data.objects.new("aim", None) + aim.location = centre + bpy.context.collection.objects.link(aim) + + def lamp(name, energy, color, offset): + light = bpy.data.lights.new(name, type="AREA") + light.energy = energy * radius * radius + light.size = radius * 3 + light.color = color + obj = bpy.data.objects.new(name, light) + obj.location = centre + Vector(offset) * radius + bpy.context.collection.objects.link(obj) + track = obj.constraints.new("TRACK_TO") + track.target = aim + + lamp("key", 130, (1.0, 0.82, 0.6), (2.2, -2.4, 2.6)) + lamp("fill", 38, (0.55, 0.72, 1.0), (-2.8, -1.6, 1.4)) + lamp("rim", 70, (0.6, 0.8, 1.0), (-1.2, 3.0, 1.8)) + + cam_data = bpy.data.cameras.new("preview") + cam_data.lens = 55 + cam = bpy.data.objects.new("preview", cam_data) + cam.location = centre + Vector((radius * 2.6, -radius * 3.0, radius * 1.9)) + bpy.context.collection.objects.link(cam) + track = cam.constraints.new("TRACK_TO") + track.target = aim + bpy.context.scene.camera = cam + + scene = bpy.context.scene + scene.render.engine = "CYCLES" + scene.cycles.samples = 48 + scene.render.resolution_x = 768 + scene.render.resolution_y = 768 + scene.render.film_transparent = False + scene.world = bpy.data.worlds.new("preview_world") + scene.world.use_nodes = True + scene.world.node_tree.nodes["Background"].inputs["Color"].default_value = ( + 0.02, + 0.025, + 0.035, + 1.0, + ) + scene.render.filepath = out_png + os.makedirs(os.path.dirname(os.path.abspath(out_png)), exist_ok=True) + bpy.ops.render.render(write_still=True) + print(f"PREVIEW {out_png}") + + +def main() -> None: + fbx, out = arg("--fbx"), arg("--out") + if not fbx or not out: + raise SystemExit("import_synty_prop: --fbx and --out are required") + + primary_slot = arg("--primary-slot", "synty_atlas") + primary_atlas = arg("--primary-atlas") + glass_atlas = arg("--glass-atlas") + preview = arg("--preview") + + bpy.ops.wm.read_factory_settings(use_empty=True) + bpy.ops.import_scene.fbx(filepath=fbx) + + meshes = visible_meshes() + if not meshes: + raise SystemExit("import_synty_prop: no mesh in FBX") + + # ---- geometry cleanup ------------------------------------------------ + # + # Synty's SourceFiles FBX exports every face all but isolated: the armoured + # car arrives as 24k triangles carried by 44k vertices, a 3.6x blow-up from + # smoothing splits and doubled seams. Uncompressed glTF stores one vertex + # record per unique vertex, so that duplication is paid for byte-for-byte in + # the download. Welding coincident vertices recovers most of it with no + # visible change — UVs live per face-corner and survive the merge, and + # normals are recomputed from Synty's own smoothing angle afterwards. + weld = float(arg("--weld", "0.0002")) + decimate = float(arg("--decimate", "0")) if has("--decimate") else 0.0 + for mesh in meshes: + bpy.ops.object.select_all(action="DESELECT") + mesh.select_set(True) + bpy.context.view_layer.objects.active = mesh + if weld > 0: + bpy.ops.object.mode_set(mode="EDIT") + bpy.ops.mesh.select_all(action="SELECT") + bpy.ops.mesh.remove_doubles(threshold=weld) + bpy.ops.object.mode_set(mode="OBJECT") + # Re-smooth from Synty's own crease angle. The auto-smooth operator + # was renamed in Blender 4.1; fall back for older builds. + try: + bpy.ops.object.shade_auto_smooth(angle=math.radians(40)) + except (AttributeError, RuntimeError, TypeError): + bpy.ops.object.shade_smooth() + if decimate > 0: + mod = mesh.modifiers.new("decimate", "DECIMATE") + mod.ratio = decimate + bpy.ops.object.modifier_apply(modifier=mod.name) + + # The importer already brought the mesh in at metres; apply its transform so + # the 0.01 unit scale bakes into the vertices rather than riding on the + # object, then recentre on the ground so the engine can place by the model's + # foot. Sanity-check the result: a vehicle or prop is metres, not + # centimetres and not tens of metres. + bpy.ops.object.select_all(action="SELECT") + bpy.context.view_layer.objects.active = meshes[0] + bpy.ops.object.transform_apply(location=False, rotation=True, scale=True) + + lo, hi = combined_bounds(meshes) + if max(hi - lo) > 60 or max(hi - lo) < 0.05: + raise SystemExit( + f"import_synty_prop: implausible size {tuple(round(v, 2) for v in (hi - lo))} — " + "unit metadata not as expected" + ) + + centre = (lo + hi) / 2 + for obj in meshes: + if obj.parent is None: + obj.location -= Vector((centre.x, centre.y, lo.z)) + bpy.context.view_layer.update() + + # ---- materials: assigned by MESH name ------------------------------- + # + # The FBX carries one placeholder shader on every mesh, so the body/glass + # split has to come from the mesh name. Glass was authored against the + # shared Texture_01_A atlas (which ships as `synty_atlas`); everything else + # samples the primary slot. + slot_materials: dict[str, bpy.types.Material] = {} + + def material_for(slot: str) -> bpy.types.Material: + mat = slot_materials.get(slot) + if mat is None: + mat = bpy.data.materials.new(slot) + atlas = glass_atlas if slot == "synty_atlas" else primary_atlas + if atlas and os.path.exists(atlas): + bind_atlas(mat, atlas) + slot_materials[slot] = mat + return mat + + for mesh in meshes: + slot = "synty_atlas" if "glass" in mesh.name.lower() else primary_slot + mat = material_for(slot) + if not mesh.data.materials: + mesh.data.materials.append(mat) + else: + for i in range(len(mesh.data.materials)): + mesh.data.materials[i] = mat + + # Drop any orphaned placeholder materials the FBX brought in. + for m in list(bpy.data.materials): + if m.name not in slot_materials and m.name != "preview_ground": + if m.users == 0: + bpy.data.materials.remove(m) + + os.makedirs(os.path.dirname(os.path.abspath(out)), exist_ok=True) + bpy.ops.export_scene.gltf( + filepath=out, + export_format="GLB", + export_apply=True, + export_yup=True, + export_skins=False, + export_animations=False, + export_cameras=False, + export_lights=False, + export_extras=False, + # No embedded images: the engine binds the shared atlas by material + # name, exactly as it does for the characters and generated props. + export_image_format="NONE", + ) + + size = os.path.getsize(out) + slots = ", ".join(sorted(slot_materials)) + print(f"SYNTY_PROP {os.path.basename(out)} bytes={size} slots=[{slots}]") + + # Preview last: it drops a ground plane and lights into the live scene, and + # exporting after that would ship the ground plane inside the GLB. The + # export above does not touch the in-memory materials, so the atlas is still + # bound for a faithful render. + if preview: + render_preview(preview) + + +main()