refactor(tools): one FBX export preset instead of six copies

The bpy.ops.export_scene.fbx argument block was duplicated in six scripts (hero,
block frontage, north buildings, site, scale test, MPFB character) and all six
carried mesh_smooth_type="OFF", which drops smoothing groups and made every
Unreal import warn and every round part faceted.

Tools/fbx_export_preset.py now owns the presets:
- preset_kwargs(preset, selection, **overrides) is bpy-free and unit-tested;
- export_static()/export_character() are thin wrappers that import bpy lazily;
- ue_metric_v1 and ue_character_v1 keep the recorded scale contract
  (FBX_SCALE_NONE, apply_unit_scale, -Y forward / Z up) and use FACE smoothing.

Callers switched: hero_facade_build, export_block_frontage, export_north_buildings,
scale_test_export, mpfb_character_export, Art/blender/site/export_site_fbx.

Verified by re-exporting every asset through the preset and re-importing all of
them: 0 smoothing warnings on site, frontage and north buildings; packaging and
-qaselftest all PASS (walk, transect 0 holes, wall probe 0/35, facade probe 0/9).
49 tool tests pass (9 new for the preset contract).
This commit is contained in:
zwitschi committed 2026-11-08 18:00:59 +01:00
1 parent 160e9144f9
commit 286d9f6479
49 files changed
+283 -158

No files matched your search

+1
View File
@@ -16,6 +16,7 @@ Geographic tools live in [`Geo/tools/`](../Geo/README.md) and the Blender-side b
| Tool | Purpose |
| --- | --- |
| `check-lfs.sh` | Fails if the `lfs` filter is missing or an LFS-tracked file was committed as a plain blob |
| `fbx_export_preset.py` | The **single** FBX export preset (`ue_metric_v1` / `ue_character_v1`): metres→centimetres via `FBX_SCALE_NONE`, axis mapping east +X / north −Y / up +Z, `mesh_smooth_type="FACE"` (smoothing groups survive the import). Every Blender exporter imports it instead of copying the argument block; `preset_kwargs()` is bpy-free and unit-tested. |
| `sibling_asset_audit.py` | Reads the sibling project's prop library from GLB headers into `Art/props/reuse-manifest.json`; copies nothing |
| `scale_test_export.py` / `ue_scale_test_import.py` | The 1 m = 100 uu scale test; the named presets `ue_metric_v1` live in these two files |
+4 -14
View File
@@ -19,6 +19,9 @@ import os
import sys
import bpy
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import fbx_export_preset as fbx_preset # noqa: E402
# blockout object name -> (asset name, note). Addresses from the verification doc.
BUILDINGS = {
@@ -69,20 +72,7 @@ def main():
bpy.context.view_layer.objects.active = new_obj
path = os.path.join(args.out, asset_name + ".fbx")
bpy.ops.export_scene.fbx(
filepath=path,
use_selection=True,
object_types={"MESH"},
apply_unit_scale=True,
apply_scale_options="FBX_SCALE_NONE",
axis_forward="-Y",
axis_up="Z",
use_space_transform=True,
use_mesh_modifiers=True,
mesh_smooth_type="OFF",
path_mode="AUTO",
bake_space_transform=False,
)
fbx_preset.export_static(path, selection=True)
world = new_obj.matrix_world
zs = [(world @ v.co).z for v in new_mesh.vertices]
ys = [(world @ v.co).y for v in new_mesh.vertices]
+4 -14
View File
@@ -13,6 +13,9 @@ import os
import sys
import bpy
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import fbx_export_preset as fbx_preset # noqa: E402
BUILDINGS = ("TCL Chinese Theatre", "Madame Tussaud's")
@@ -50,20 +53,7 @@ def main():
bpy.context.view_layer.objects.active = new_obj
path = os.path.join(args.out, name.replace(" ", "_").replace("'", "") + ".fbx")
bpy.ops.export_scene.fbx(
filepath=path,
use_selection=True,
object_types={"MESH"},
apply_unit_scale=True,
apply_scale_options="FBX_SCALE_NONE",
axis_forward="-Y",
axis_up="Z",
use_space_transform=True,
use_mesh_modifiers=True,
mesh_smooth_type="OFF",
path_mode="AUTO",
bake_space_transform=False,
)
fbx_preset.export_static(path, selection=True)
world = new_obj.matrix_world
zs = [(world @ v.co).z for v in new_mesh.vertices]
print(f"[north] {name:22} verts {len(new_mesh.vertices):5} z {min(zs):+.2f}..{max(zs):+.2f} m "
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""The one FBX export preset for this project ("ue_metric_v1"/"ue_character_v1").
Before this module the same `bpy.ops.export_scene.fbx(...)` argument block was
copied into six scripts (hero, frontage, north buildings, site, scale test, MPFB
character). They had already drifted only by accident, and every one of them
carried `mesh_smooth_type="OFF"` — which is why Unreal logged "No smoothing group
information was found for this mesh" on every import and round parts rendered
faceted. One preset, one place to fix that.
Use from inside Blender:
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "Tools"))
import fbx_export_preset as preset
preset.export_static(path) # whole scene, MESH only
preset.export_static(path, selection=True) # selected objects only
preset.export_character(path) # armature + mesh, character flags
`preset_kwargs()` is pure (no `bpy`), so the argument set can be unit-tested
without Blender — see Tools/tests/test_fbx_export_preset.py.
Why these values:
* `FBX_SCALE_NONE` + `apply_unit_scale` keeps Blender metres → Unreal centimetres
at exactly ×100, verified by the three-cube scale test (1 m → 100.00 uu).
* `axis_forward="-Y"`, `axis_up="Z"` is the project's recorded mapping: east → +X,
north → −Y, up → +Z.
* `mesh_smooth_type="FACE"` exports smoothing groups. `"OFF"` (the old value)
drops them, so Unreal rebuilds flat normals and every cylinder is faceted.
* `use_mesh_modifiers=True` bakes the bevel/modifier stack at export; the source
`.blend` stays non-destructive.
"""
from __future__ import annotations
# Shared by every mesh export. Values are the recorded ue_metric_v1 preset.
_METRIC = {
"object_types": {"MESH"},
"apply_unit_scale": True,
"apply_scale_options": "FBX_SCALE_NONE",
"axis_forward": "-Y",
"axis_up": "Z",
"use_space_transform": True,
"use_mesh_modifiers": True,
"mesh_smooth_type": "FACE",
"path_mode": "AUTO",
"bake_space_transform": False,
}
# Character exports differ only in the object set and the armature/path flags.
_CHARACTER = dict(
_METRIC,
object_types={"ARMATURE", "MESH"},
add_leaf_bones=False,
path_mode="COPY",
embed_textures=False,
)
PRESETS = {"ue_metric_v1": _METRIC, "ue_character_v1": _CHARACTER}
def preset_kwargs(preset: str = "ue_metric_v1", selection: bool = False, **overrides) -> dict:
"""Return the `bpy.ops.export_scene.fbx` argument dict (no `bpy` needed).
`preset` selects the base set, `selection` decides whether only selected
objects are exported, and `overrides` wins last for the rare special case.
"""
if preset not in PRESETS:
raise KeyError(f"unknown preset {preset!r}; known: {sorted(PRESETS)}")
kwargs = dict(PRESETS[preset])
kwargs["use_selection"] = bool(selection)
kwargs.update(overrides)
if "filepath" in kwargs:
raise ValueError("pass the path to export_static/export_character, not as an override")
return kwargs
def _export(path: str, preset: str, selection: bool, overrides: dict) -> str:
import bpy # imported here so `preset_kwargs` stays usable without Blender
kwargs = preset_kwargs(preset, selection=selection, **overrides)
bpy.ops.export_scene.fbx(filepath=path, **kwargs)
return path
def export_static(path: str, selection: bool = False, **overrides) -> str:
"""Export meshes with the ue_metric_v1 preset. Returns the path."""
return _export(path, "ue_metric_v1", selection, overrides)
def export_character(path: str, selection: bool = False, **overrides) -> str:
"""Export armature + mesh with the ue_character_v1 preset. Returns the path."""
return _export(path, "ue_character_v1", selection, overrides)
+5 -16
View File
@@ -33,6 +33,9 @@ import os
import sys
import bpy
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import fbx_export_preset as fbx_preset # noqa: E402
# --- measured / derived ------------------------------------------------------
FRONTAGE_W = 25.75
@@ -306,24 +309,10 @@ def build():
def export_fbx(path):
# Preset lives in Tools/fbx_export_preset.py (ue_metric_v1).
os.makedirs(os.path.dirname(path), exist_ok=True)
# Named export preset "ue_metric_v1" - same settings as Tools/scale_test_export.py.
bpy.ops.export_scene.fbx(
filepath=path,
use_selection=False,
object_types={"MESH"},
apply_unit_scale=True,
apply_scale_options="FBX_SCALE_NONE",
axis_forward="-Y",
axis_up="Z",
use_space_transform=True,
use_mesh_modifiers=True,
# "FACE" exports smoothing groups; without it every import logs
# "No smoothing group information was found for this mesh".
mesh_smooth_type="FACE",
path_mode="AUTO",
bake_space_transform=False,
)
fbx_preset.export_static(path)
print(f"[hero] exported FBX preset ue_metric_v1 -> {path}")
+4 -16
View File
@@ -19,6 +19,9 @@ import sys
import zlib
import bpy
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import fbx_export_preset as fbx_preset # noqa: E402
import numpy
from mathutils import Euler
@@ -149,22 +152,7 @@ def make_diffuse(human, size=1024):
def export_fbx(human, path):
os.makedirs(os.path.dirname(path), exist_ok=True)
# Named export preset "ue_character_v1" — change only via a new preset name.
bpy.ops.export_scene.fbx(
filepath=path,
use_selection=False,
object_types={"ARMATURE", "MESH"},
apply_unit_scale=True,
apply_scale_options="FBX_SCALE_NONE",
axis_forward="-Y",
axis_up="Z",
use_space_transform=True,
use_mesh_modifiers=True,
add_leaf_bones=False,
path_mode="COPY",
embed_textures=False,
mesh_smooth_type="OFF",
bake_space_transform=False,
)
fbx_preset.export_character(path)
log(f"export: FBX preset ue_character_v1 -> {path}")
+4 -14
View File
@@ -16,6 +16,9 @@ import os
import sys
import bpy
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import fbx_export_preset as fbx_preset # noqa: E402
def build_scene():
@@ -44,20 +47,7 @@ def build_scene():
def export_fbx(path):
os.makedirs(os.path.dirname(path), exist_ok=True)
# Named export preset "ue_metric_v1" - change only via a new preset name.
bpy.ops.export_scene.fbx(
filepath=path,
use_selection=False,
object_types={"MESH"},
apply_unit_scale=True,
apply_scale_options="FBX_SCALE_NONE",
axis_forward="-Y",
axis_up="Z",
use_space_transform=True,
use_mesh_modifiers=True,
mesh_smooth_type="OFF",
path_mode="AUTO",
bake_space_transform=False,
)
fbx_preset.export_static(path)
print(f"[scale_test] exported FBX preset ue_metric_v1 -> {path}")
+70
View File
@@ -0,0 +1,70 @@
"""Tests for Tools/fbx_export_preset.py.
The preset module keeps its argument set in plain dicts and builds it in
`preset_kwargs()`, so the export contract can be checked without starting
Blender. Only `export_static`/`export_character` import `bpy`.
"""
import os
import sys
import unittest
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
sys.path.insert(0, os.path.join(ROOT, "Tools"))
import fbx_export_preset as preset # noqa: E402
class MetricPresetTest(unittest.TestCase):
def test_scale_and_axis_contract(self):
# These three are the project's recorded conversion contract: metres ->
# centimetres by a factor of exactly 100, east +X, north -Y, up +Z.
kwargs = preset.preset_kwargs()
self.assertTrue(kwargs["apply_unit_scale"])
self.assertEqual(kwargs["apply_scale_options"], "FBX_SCALE_NONE")
self.assertEqual(kwargs["axis_forward"], "-Y")
self.assertEqual(kwargs["axis_up"], "Z")
def test_smoothing_groups_are_exported(self):
# "OFF" (the pre-refactor value) drops smoothing groups and every Unreal
# import warns about it, with round parts faceted.
self.assertEqual(preset.preset_kwargs()["mesh_smooth_type"], "FACE")
def test_selection_flag(self):
self.assertFalse(preset.preset_kwargs()["use_selection"])
self.assertTrue(preset.preset_kwargs(selection=True)["use_selection"])
def test_object_types_are_meshes_only(self):
self.assertEqual(preset.preset_kwargs()["object_types"], {"MESH"})
class CharacterPresetTest(unittest.TestCase):
def test_character_exports_armature_and_mesh(self):
kwargs = preset.preset_kwargs("ue_character_v1")
self.assertEqual(kwargs["object_types"], {"ARMATURE", "MESH"})
self.assertFalse(kwargs["add_leaf_bones"])
self.assertEqual(kwargs["path_mode"], "COPY")
self.assertFalse(kwargs["embed_textures"])
def test_character_keeps_the_scale_contract(self):
kwargs = preset.preset_kwargs("ue_character_v1")
self.assertEqual(kwargs["apply_scale_options"], "FBX_SCALE_NONE")
self.assertEqual(kwargs["mesh_smooth_type"], "FACE")
class OverrideTest(unittest.TestCase):
def test_overrides_win(self):
kwargs = preset.preset_kwargs(mesh_smooth_type="OFF")
self.assertEqual(kwargs["mesh_smooth_type"], "OFF")
def test_unknown_preset_raises(self):
with self.assertRaises(KeyError):
preset.preset_kwargs("ue_imperial_v9")
def test_filepath_must_not_be_an_override(self):
with self.assertRaises(ValueError):
preset.preset_kwargs(filepath="/tmp/x.fbx")
if __name__ == "__main__":
unittest.main()