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:
1 parent
160e9144f9
commit
286d9f6479
49 files changed
+283
-158
No files matched your search
Binary file not shown.
@@ -15,6 +15,9 @@ import os
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
sys.path.insert(0, os.path.join(os.path.join(os.path.dirname(__file__), "..", "..", ".."), "Tools"))
|
||||
import fbx_export_preset as fbx_preset # noqa: E402
|
||||
|
||||
|
||||
MESHES = ("Terrain_DEM", "Road_Hollywood", "Sidewalk_South", "Sidewalk_North",
|
||||
"Kerb_South", "Kerb_North", "Crossing_Markings")
|
||||
@@ -60,20 +63,7 @@ def main():
|
||||
_ = bpy.context.view_layer
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
path = os.path.join(args.out, 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)
|
||||
if mirrored:
|
||||
# The flip is an export correction, not a change to the source: undo it.
|
||||
obj.scale.y *= -1.0
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -460,3 +460,26 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO.
|
||||
- Probe expectations must mirror the staged buildings: the facade probe now carries
|
||||
explicit X spans per frontage (and the TCL/Tussaud's spans on the north side) so
|
||||
a gap between buildings is not reported as a defect.
|
||||
|
||||
## 2026-11-09 — 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"` — hence the "No smoothing group
|
||||
information was found" warning on every import.
|
||||
- Now `Tools/fbx_export_preset.py` holds `ue_metric_v1` and `ue_character_v1`:
|
||||
`preset_kwargs()` (bpy-free, unit-tested) plus `export_static()` /
|
||||
`export_character()` wrappers that import `bpy` lazily. Every caller imports it
|
||||
and passes `selection=True/False`; the seven duplicated blocks are gone.
|
||||
- Callers in `Tools/` resolve the module with
|
||||
`sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))`; the Blender-side
|
||||
`Art/blender/site/export_site_fbx.py` computes the repo root
|
||||
(`..`, `..`, `..`) and joins `Tools`. Getting that wrong silently inserts
|
||||
`Tools/Tools` — check the import path when adding a caller.
|
||||
- Verified end to end: scale test, hero, frontage (6/6), site (7/7) and north
|
||||
buildings all re-exported through the preset, re-imported with **0 smoothing
|
||||
warnings**, DemoMap rebuilt and `-qaselftest` all PASS (walk, transect 0 holes,
|
||||
wall probe 0/35, facade probe 0/9).
|
||||
- `mpfb_character_export.py` was not re-run: it needs the MPFB add-on on
|
||||
`BLENDER_USER_SCRIPTS` ("Add-on not loaded: bl_ext.blender_org.mpfb"), which is
|
||||
pre-existing and unrelated; the character is not in `0.1.x` gameplay.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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 |
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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)
|
||||
@@ -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}")
|
||||
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user