the symptom, in your words

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

✓ verified on 26.07.08e
lane 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.08e: seen in a real project.
s&box moves fast; an undated fix is a liability. Spot a stale detail?

Want to know when new guides or fixes drop? Join the community to help build this out. Report gotchas, flag outdated fixes, or just lurk.

Join the Discord