s&box/field-guide
the symptom, in your words

"A complete Blender-headless → s&box pipeline"

✓ verified on engine 26.07lane: Getting art inposted
▸ SYMPTOM

You need a repeatable path from authored (or generated) meshes into s&box with correct scale, facing, materials, and a single source of truth for sizes/colliders — without hand-clicking ModelDoc for every prop.

▸ CAUSE

Ad-hoc exports drift: wrong axes, meters vs inches, missing remaps, no catalog of world-space bounds. The fix is a two-stage pipeline — Blender only builds meshes; a second pure-Python stage owns engine wrappers and codegen.

▸ FIX

Stage 1 — model generation (Blender headless)

snippet
blender --background --python tools/gen_models.py

Conventions per model function:

  • bpy.ops.wm.read_factory_settings(use_empty=True) — clean slate each model.
  • Build from primitives in real-world meters; chunky low-poly; shared flat-color palette.
  • Materials: use_nodes=True, set Principled Base Color and diffuse_color (that's what the OBJ exporter writes as Kd).
  • Join everything, transform_apply, ground at z=0 (obj.location.z -= min_z) — except parts with joint pivots.
  • Export:
snippet
bpy.ops.wm.obj_export(
    filepath=path,
    export_materials=True,
    forward_axis='NEGATIVE_Z',
    up_axis='Y',
)

Animated parts: export limbs/rotors as separate models with the origin at the joint/axle (ground=False). Assemble in C# as child GameObjects and drive LocalRotation. Map Blender child offsets through:

snippet
world = (-bY * s, -bX * s, bZ * s)

State-swap models (e.g. picked bush): generate variants with the same random seed so the silhouette matches; swap renderer.Model at runtime.

Stage 2 — asset conversion (pure Python, no Blender)

snippet
python tools/gen_assets.py

Does four things:

  1. Textures — PNG writer + tileable noise for ground/sky as needed.
  2. Materials — one .vmat per palette color using shared white.png + g_vColorTint (constant-color TextureColor does not reliably compile).
  3. Models — copy OBJ+MTL into Assets/models/<project>/, emit a .vmdl per model: KV3 header + MaterialGroupList remaps (both Name and Name.vmat) + RenderMeshFile with import_scale and zero rotation/translation.
  4. C# catalog — e.g. Models.generated.cs: name → vmdl path + world-space Size/Center via world = (-objZ, -objX, objY) * scale. Colliders and placement footprints read from this — regenerates with the art.

Scaling: two approaches

ApproachWhenRule
Uniform scale (preferred)You author in metersimport_scale = 39.37 for everything
Per-model target sizeImported kits (Kenney) with unknown native scalescale = target_units / native_extent from OBJ bounds

Checklist — new model

  1. Add generator fn + register in GENERATORS.
  2. Add its name to MODELS in the assets script.
  3. Run both tools; check printed world-space size is sane.
  4. Reference via your catalog (PathOf("name")), not a hand-typed path.

Sanity before batching

  • One angled piece (roof/ramp): confirm rotation sign visually.
  • One placed instance: confirm facing (Blender +X → world −Y; yaw +90 → +X).
  • Flat ground decals: top surface must sit above the ground plane top.
▸ WHY IT WORKS

Splitting mesh authoring from engine packaging keeps Blender ignorant of inches/vmdl/KV3 while the Python stage enforces the remaps, 39.37 scale, and a generated catalog so gameplay code never hardcodes bounds that drift from the mesh. The axis map is applied once in catalog math and once by the OBJ importer — matching conventions keep facing yaw constants valid across the whole set.

Verified on engine 26.07 — seen in a real project.
s&box moves fast; an undated fix is a liability. Spot a stale detail?