From bf8c26c2a38330a64afbcacd82ad266c6e91607e Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Fri, 11 Sep 2026 19:45:18 -0400 Subject: [PATCH 1/3] feat: add engine-export-presets skill and Unity/Godot/Unreal snippets glTF axis is export_yup; FBX is axis_forward/axis_up plus centimeter scale. The presets compose ai-mesh-cleanup and depsgraph-and-evaluated-data instead of restating them. Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Co-authored-by: Cursor --- skills/engine-export-presets/SKILL.md | 139 ++++++++++++++++++++++++++ snippets/export_preset_godot.py | 47 +++++++++ snippets/export_preset_unity.py | 47 +++++++++ snippets/export_preset_unreal.py | 67 +++++++++++++ 4 files changed, 300 insertions(+) create mode 100644 skills/engine-export-presets/SKILL.md create mode 100644 snippets/export_preset_godot.py create mode 100644 snippets/export_preset_unity.py create mode 100644 snippets/export_preset_unreal.py diff --git a/skills/engine-export-presets/SKILL.md b/skills/engine-export-presets/SKILL.md new file mode 100644 index 0000000..f900fb3 --- /dev/null +++ b/skills/engine-export-presets/SKILL.md @@ -0,0 +1,139 @@ +--- +name: engine-export-presets +description: Unity, Godot, and Unreal glTF/FBX export presets. glTF uses export_yup; FBX uses axis_forward/axis_up plus centimeter scale. Targets 5.2 LTS with 4.5 LTS fallback. +standards-version: 1.10.0 +--- + +# Engine Export Presets + +## Trigger + +Use this skill when the user: + +- Needs a Unity, Godot, or Unreal export from Blender Python +- Mentions Y-up, Z-up, centimeter scale, `export_yup`, `axis_forward`, or `axis_up` +- Is about to pass FBX axis kwargs to `bpy.ops.export_scene.gltf` +- Wants a headless preset the later `ai-asset-pipeline-template` can call + +This skill is the export layer. It composes `ai-mesh-cleanup` (apply transforms, units), `depsgraph-and-evaluated-data` (`export_apply` ships evaluated mesh), and the `unapplied-scale-gltf` witness (`export_apply` does not bake object scale). It does not generate meshes. + +## The core misunderstanding + +glTF and FBX do not share axis RNA. `bpy.ops.export_scene.gltf` has `export_yup` (boolean, "+Y Up"). It does not have `axis_forward` or `axis_up`. `bpy.ops.export_scene.fbx` has `axis_forward` and `axis_up` (axis enums) and `global_scale`. It does not have `export_yup`. Passing the other exporter's kwargs is a TypeError or a silent no-op depending on how the call is built. + +That split is the contract. Verified on current RNA: + +- glTF: https://docs.blender.org/api/current/bpy.ops.export_scene.html#bpy.ops.export_scene.gltf (`export_yup`, no axis enums) +- FBX: https://docs.blender.org/api/current/bpy.ops.export_scene.html#bpy.ops.export_scene.fbx (`axis_forward`, `axis_up`, `global_scale`) + +## Shared prelude + +Before any preset: meters in the scene (`scale_length == 1.0`), identity object scale via `transform_apply` through `temp_override`, `export_apply=True` / `use_mesh_modifiers=True` so modifiers ship. See `ai-mesh-cleanup` and `unapplied-scale-gltf`. + +```python +def apply_selected_mesh_transforms(): + for obj in list(bpy.context.selected_objects): + if obj.type != "MESH": + continue + with bpy.context.temp_override( + object=obj, active_object=obj, selected_objects=[obj] + ): + bpy.ops.object.transform_apply( + location=False, rotation=True, scale=True + ) +``` + +`use_selection=True` on every preset. Draco is opt-in on glTF; do not copy `gltf_draco_export.py` wholesale. + +## Unity (Y-up, meters, glTF) + +```python +bpy.ops.export_scene.gltf( + filepath=path, + use_selection=True, + export_yup=True, + export_apply=True, + export_draco_mesh_compression_enable=draco, + export_animations=False, +) +``` + +`export_yup=True` bakes `(x, y, z) -> (x, z, -y)` into POSITION with no node rotation. Witness: `examples/gltf-export-roundtrip/` and `examples/export-preset-axis/`. + +Snippet: `snippets/export_preset_unity.py`. + +## Godot (Z-up glTF, meters) + +```python +bpy.ops.export_scene.gltf( + filepath=path, + use_selection=True, + export_yup=False, + export_apply=True, + export_draco_mesh_compression_enable=draco, + export_animations=False, +) +``` + +`export_yup=False` writes Blender Z-up POSITION. This preset is the Z-up interop path. It is not the Unity kwargs; if both used `export_yup=True` the files would match and the axis contract would be untestable. + +Snippet: `snippets/export_preset_godot.py`. + +## Unreal (centimeters) + +glTF has no `global_scale`. Bake 100x (1 m -> 100 cm) onto the selected meshes, apply, then `export_yup=True`. That mutates the objects; copy first if the source must stay in meters. + +FBX keeps the meter mesh and scales on the way out: + +```python +bpy.ops.export_scene.fbx( + filepath=path, + use_selection=True, + axis_forward="-Z", + axis_up="Y", + global_scale=100.0, + apply_unit_scale=False, + use_mesh_modifiers=True, + bake_anim=False, +) +``` + +Do not pass `export_yup` to FBX. Do not pass `axis_forward` to glTF. + +Snippet: `snippets/export_preset_unreal.py`. + +## Common AI mistakes + +1. **`export_scene.gltf(..., axis_forward="-Z", axis_up="Y")`.** Those names are FBX. Rule `use-correct-axis-rna-per-exporter`. +2. **`export_scene.fbx(..., export_yup=True)`.** Same rule, other direction. +3. **Skipping `transform_apply`.** `export_apply` is modifiers, not object scale. `examples/unapplied-scale-gltf/`. +4. **Unity and Godot as the same kwargs.** They differ on `export_yup`. `examples/export-preset-axis/` asserts the re-imported orientations diverge. +5. **Unreal glTF without the 100x bake.** glTF has no `global_scale`. + +## Version correctness + +Probed on 4.5 LTS, 5.1, and 5.2: `export_yup` on glTF and `axis_forward` / `axis_up` / `global_scale` on FBX are present on all three. No version branch for the axis kwargs. Guard by requiring those names in operator RNA so a future rename fails loudly, as `examples/gltf-export-roundtrip/` does. + +`export_format` defaults differ by call site; pass the filepath suffix (`.glb` / `.gltf` / `.fbx`) and let the operator infer, or set `export_format` explicitly on glTF. + +## Related + +- Skill `ai-mesh-cleanup` +- Skill `depsgraph-and-evaluated-data` +- Rule `use-correct-axis-rna-per-exporter` +- Rule `no-unapplied-modifiers-on-export` +- Rule `validate-imported-mesh-scale` +- Snippet `snippets/export_preset_unity.py` +- Snippet `snippets/export_preset_godot.py` +- Snippet `snippets/export_preset_unreal.py` +- Snippet `snippets/gltf_draco_export.py` +- Example `export-preset-axis` +- Example `gltf-export-roundtrip` +- Example `unapplied-scale-gltf` + +## References + +- `bpy.ops.export_scene.gltf`: https://docs.blender.org/api/current/bpy.ops.export_scene.html#bpy.ops.export_scene.gltf +- `bpy.ops.export_scene.fbx`: https://docs.blender.org/api/current/bpy.ops.export_scene.html#bpy.ops.export_scene.fbx +- glTF 5.1: https://docs.blender.org/api/5.1/bpy.ops.export_scene.html#bpy.ops.export_scene.gltf +- FBX 5.1: https://docs.blender.org/api/5.1/bpy.ops.export_scene.html#bpy.ops.export_scene.fbx diff --git a/snippets/export_preset_godot.py b/snippets/export_preset_godot.py new file mode 100644 index 0000000..ae13f4c --- /dev/null +++ b/snippets/export_preset_godot.py @@ -0,0 +1,47 @@ +# Godot glTF preset: Z-up (Blender-native), meter scale, selected objects only. +# export_yup=False writes raw Z-up POSITION. glTF RNA has no axis_forward / +# axis_up. Draco is opt-in; see snippets/gltf_draco_export.py for the +# compression-only helper this does not duplicate. +# +# Assumption: scene units are meters (scale_length == 1.0). This preset is +# the Z-up interop path; it is intentionally not the Unity Y-up kwargs. +# +# Reference: +# https://docs.blender.org/api/current/bpy.ops.export_scene.html#bpy.ops.export_scene.gltf + +import tempfile + +import bpy + + +def apply_selected_mesh_transforms(): + for obj in list(bpy.context.selected_objects): + if obj.type != "MESH": + continue + with bpy.context.temp_override( + object=obj, active_object=obj, selected_objects=[obj] + ): + bpy.ops.object.transform_apply( + location=False, rotation=True, scale=True + ) + + +def export_preset_godot(filepath, selected_only=True, draco=False): + apply_selected_mesh_transforms() + bpy.ops.export_scene.gltf( + filepath=filepath, + use_selection=selected_only, + export_yup=False, + export_apply=True, + export_draco_mesh_compression_enable=draco, + export_animations=False, + ) + + +if __name__ == "__main__": + obj = bpy.context.active_object + if obj is not None and obj.type == "MESH": + obj.select_set(True) + path = tempfile.NamedTemporaryFile(suffix=".glb", delete=False).name + export_preset_godot(path) + print(f"wrote {path}") diff --git a/snippets/export_preset_unity.py b/snippets/export_preset_unity.py new file mode 100644 index 0000000..bc7756c --- /dev/null +++ b/snippets/export_preset_unity.py @@ -0,0 +1,47 @@ +# Unity glTF preset: Y-up, meter scale, selected objects only. +# Apply object rotation and scale before export so they do not land on the +# glTF node. Axis is export_yup=True. glTF RNA has no axis_forward / axis_up. +# Draco is opt-in; see snippets/gltf_draco_export.py for the compression-only +# helper this does not duplicate. +# +# Assumption: scene units are meters (scale_length == 1.0). +# +# Reference: +# https://docs.blender.org/api/current/bpy.ops.export_scene.html#bpy.ops.export_scene.gltf + +import tempfile + +import bpy + + +def apply_selected_mesh_transforms(): + for obj in list(bpy.context.selected_objects): + if obj.type != "MESH": + continue + with bpy.context.temp_override( + object=obj, active_object=obj, selected_objects=[obj] + ): + bpy.ops.object.transform_apply( + location=False, rotation=True, scale=True + ) + + +def export_preset_unity(filepath, selected_only=True, draco=False): + apply_selected_mesh_transforms() + bpy.ops.export_scene.gltf( + filepath=filepath, + use_selection=selected_only, + export_yup=True, + export_apply=True, + export_draco_mesh_compression_enable=draco, + export_animations=False, + ) + + +if __name__ == "__main__": + obj = bpy.context.active_object + if obj is not None and obj.type == "MESH": + obj.select_set(True) + path = tempfile.NamedTemporaryFile(suffix=".glb", delete=False).name + export_preset_unity(path) + print(f"wrote {path}") diff --git a/snippets/export_preset_unreal.py b/snippets/export_preset_unreal.py new file mode 100644 index 0000000..be0fc88 --- /dev/null +++ b/snippets/export_preset_unreal.py @@ -0,0 +1,67 @@ +# Unreal presets: centimeter scale. glTF has no global_scale and no +# axis_forward / axis_up; bake 100x then export_yup=True. FBX uses +# global_scale=100.0 plus axis_forward='-Z' and axis_up='Y'. That RNA +# split is the contract. glTF bake mutates selected mesh objects. +# Draco is glTF-only and opt-in; see snippets/gltf_draco_export.py. +# +# Assumption: scene units are meters before the 100x bake / FBX scale. +# +# Reference: +# https://docs.blender.org/api/current/bpy.ops.export_scene.html#bpy.ops.export_scene.gltf +# https://docs.blender.org/api/current/bpy.ops.export_scene.html#bpy.ops.export_scene.fbx + +import tempfile + +import bpy + + +def apply_selected_mesh_transforms(): + for obj in list(bpy.context.selected_objects): + if obj.type != "MESH": + continue + with bpy.context.temp_override( + object=obj, active_object=obj, selected_objects=[obj] + ): + bpy.ops.object.transform_apply( + location=False, rotation=True, scale=True + ) + + +def export_preset_unreal_gltf(filepath, selected_only=True, draco=False): + apply_selected_mesh_transforms() + for obj in list(bpy.context.selected_objects): + if obj.type != "MESH": + continue + obj.scale = (obj.scale[0] * 100.0, obj.scale[1] * 100.0, obj.scale[2] * 100.0) + apply_selected_mesh_transforms() + bpy.ops.export_scene.gltf( + filepath=filepath, + use_selection=selected_only, + export_yup=True, + export_apply=True, + export_draco_mesh_compression_enable=draco, + export_animations=False, + ) + + +def export_preset_unreal_fbx(filepath, selected_only=True): + apply_selected_mesh_transforms() + bpy.ops.export_scene.fbx( + filepath=filepath, + use_selection=selected_only, + axis_forward="-Z", + axis_up="Y", + global_scale=100.0, + apply_unit_scale=False, + use_mesh_modifiers=True, + bake_anim=False, + ) + + +if __name__ == "__main__": + obj = bpy.context.active_object + if obj is not None and obj.type == "MESH": + obj.select_set(True) + path = tempfile.NamedTemporaryFile(suffix=".fbx", delete=False).name + export_preset_unreal_fbx(path) + print(f"wrote {path}") From 4cdaa6910c8716372872bf2d4ec60dc60fcb973d Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Fri, 11 Sep 2026 19:45:32 -0400 Subject: [PATCH 2/3] feat: flag mixed glTF/FBX axis RNA in the import/export harness export_scene.gltf does not take axis_forward/axis_up; export_scene.fbx does not take export_yup. The check is per-call so a file that correctly uses both exporters still passes. Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Co-authored-by: Cursor --- rules/use-correct-axis-rna-per-exporter.mdc | 83 +++++++++++++++++++++ tests/check_import_export_rules.py | 47 +++++++++++- 2 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 rules/use-correct-axis-rna-per-exporter.mdc diff --git a/rules/use-correct-axis-rna-per-exporter.mdc b/rules/use-correct-axis-rna-per-exporter.mdc new file mode 100644 index 0000000..3082e3d --- /dev/null +++ b/rules/use-correct-axis-rna-per-exporter.mdc @@ -0,0 +1,83 @@ +--- +description: Flag export_scene.gltf calls that pass FBX axis_forward or axis_up, and export_scene.fbx calls that pass glTF export_yup. The two exporters do not share axis RNA. +alwaysApply: true +globs: + - "**/*.py" +standards-version: 1.10.0 +--- + +# Use correct axis RNA per exporter + +`bpy.ops.export_scene.gltf` exposes axis as `export_yup` (bool). +`bpy.ops.export_scene.fbx` exposes `axis_forward` and `axis_up` (axis +enums) plus `global_scale`. Mixing them is the usual engine-preset bug: +the glTF call raises or ignores FBX names, and the FBX call never sees +`export_yup`. + +Verified: https://docs.blender.org/api/current/bpy.ops.export_scene.html#bpy.ops.export_scene.gltf +and https://docs.blender.org/api/current/bpy.ops.export_scene.html#bpy.ops.export_scene.fbx + +## What this rule flags + +A `bpy.ops.export_scene.gltf(...)` call whose arguments include +`axis_forward` or `axis_up`. A `bpy.ops.export_scene.fbx(...)` call whose +arguments include `export_yup`. Whole-file scans are not enough: a file +that correctly calls both exporters (Unreal glTF plus Unreal FBX) must +still pass. + +```python +# WRONG: FBX axis names on the glTF operator +bpy.ops.export_scene.gltf( + filepath=path, + use_selection=True, + axis_forward="-Z", + axis_up="Y", +) +``` + +```python +# WRONG: glTF Y-up flag on the FBX operator +bpy.ops.export_scene.fbx( + filepath=path, + use_selection=True, + export_yup=True, +) +``` + +## The required pattern + +```python +# glTF: Unity Y-up +bpy.ops.export_scene.gltf( + filepath=path, + use_selection=True, + export_yup=True, + export_apply=True, +) + +# FBX: Unreal centimeters +bpy.ops.export_scene.fbx( + filepath=path, + use_selection=True, + axis_forward="-Z", + axis_up="Y", + global_scale=100.0, + use_mesh_modifiers=True, +) +``` + +## Why it matters + +Unity vs Godot vs Unreal is not one export with different comments. glTF +Y-up bakes `(x, y, z) -> (x, z, -y)` into POSITION. Z-up glTF writes the +Blender coords. FBX uses a different axis pair and can scale to +centimeters without mutating the mesh. Getting the RNA names wrong ships +the default axis and looks like "the engine importer is broken". + +## Related + +- Skill `engine-export-presets` +- Example `export-preset-axis` +- Example `gltf-export-roundtrip` +- Snippet `export_preset_unity.py` +- Snippet `export_preset_unreal.py` diff --git a/tests/check_import_export_rules.py b/tests/check_import_export_rules.py index 0f8382c..8beea6b 100644 --- a/tests/check_import_export_rules.py +++ b/tests/check_import_export_rules.py @@ -1,5 +1,5 @@ -"""Static checks for validate-imported-mesh-scale and -no-unapplied-modifiers-on-export. +"""Static checks for validate-imported-mesh-scale, +no-unapplied-modifiers-on-export, and use-correct-axis-rna-per-exporter. Scans snippets/ and templates/**/*.py. examples/ is excluded because several examples are intentional pathology witnesses (unapplied-scale-gltf). @@ -14,6 +14,7 @@ REQUIRED_RULES = ( "rules/validate-imported-mesh-scale.mdc", "rules/no-unapplied-modifiers-on-export.mdc", + "rules/use-correct-axis-rna-per-exporter.mdc", ) IMPORT_RE = re.compile(r"bpy\.ops\.import_scene\.(gltf|fbx)\s*\(") @@ -27,6 +28,11 @@ ) MODIFIER_NEW_RE = re.compile(r"modifiers\.new") MODIFIER_APPLY_RE = re.compile(r"modifier_apply") +GLTF_CALL_RE = re.compile(r"bpy\.ops\.export_scene\.gltf\s*\(") +FBX_CALL_RE = re.compile(r"bpy\.ops\.export_scene\.fbx\s*\(") +AXIS_FORWARD_RE = re.compile(r"\baxis_forward\b") +AXIS_UP_RE = re.compile(r"\baxis_up\b") +EXPORT_YUP_RE = re.compile(r"\bexport_yup\b") def scan_paths(extra): @@ -40,6 +46,31 @@ def scan_paths(extra): return paths +def _call_bodies(text, opener_re): + """Extract argument text of each matching call, paren-matched. + + Whole-file scans false-positive a file that correctly calls both + exporters (Unreal glTF plus Unreal FBX). Per-call bodies keep those + legal. + """ + bodies = [] + for match in opener_re.finditer(text): + i = match.end() + depth = 1 + start = i + while i < len(text) and depth: + char = text[i] + if char == "(": + depth += 1 + elif char == ")": + depth -= 1 + i += 1 + if depth != 0: + continue + bodies.append(text[start : i - 1]) + return bodies + + def check_text(rel, text): errors = [] if IMPORT_RE.search(text) and MESH_WORK_RE.search(text): @@ -55,6 +86,18 @@ def check_text(rel, text): f"{rel}: export with modifiers.new but no export_apply=True, " "evaluation_mode, or modifier_apply" ) + for body in _call_bodies(text, GLTF_CALL_RE): + if AXIS_FORWARD_RE.search(body) or AXIS_UP_RE.search(body): + errors.append( + f"{rel}: export_scene.gltf call passes axis_forward or " + "axis_up (FBX RNA; glTF uses export_yup)" + ) + for body in _call_bodies(text, FBX_CALL_RE): + if EXPORT_YUP_RE.search(body): + errors.append( + f"{rel}: export_scene.fbx call passes export_yup " + "(glTF RNA; FBX uses axis_forward / axis_up)" + ) return errors From 31a09b5ed23a9ebc86da20adc645eae0e7b89bc7 Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Fri, 11 Sep 2026 19:45:42 -0400 Subject: [PATCH 3/3] feat: witness Unity vs Godot glTF axis conversion on reimport The same beacon under export_yup True vs False produces different disk POSITION and different reimported orientation. Inventory, gallery, catalog, and the engine-export-presets roadmap row ship with the example. Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Co-authored-by: Cursor --- .cursor-plugin/plugin.json | 8 +- AGENTS.md | 10 +- CLAUDE.md | 20 +- README.md | 37 +- ROADMAP.md | 12 +- .../assets/export-preset-axis-hero.webp | Bin 0 -> 8272 bytes .../export-preset-axis-contact-sheet.webp | Bin 0 -> 35954 bytes docs/gallery/export-preset-axis/index.html | 838 ++++++++++++++++++ docs/gallery/index.html | 13 +- examples/export-preset-axis/README.md | 49 + .../export-preset-axis/export_preset_axis.py | 514 +++++++++++ examples/export-preset-axis/preview.webp | Bin 0 -> 7780 bytes examples/gallery.json | 11 + tests/smoke/catalog.json | 1 + 14 files changed, 1481 insertions(+), 32 deletions(-) create mode 100644 docs/gallery/assets/export-preset-axis-hero.webp create mode 100644 docs/gallery/contact-sheets/export-preset-axis-contact-sheet.webp create mode 100644 docs/gallery/export-preset-axis/index.html create mode 100644 examples/export-preset-axis/README.md create mode 100644 examples/export-preset-axis/export_preset_axis.py create mode 100644 examples/export-preset-axis/preview.webp diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 7e53567..dc04f9d 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -16,6 +16,7 @@ "skills": [ "skills/addon-scaffolding/SKILL.md", "skills/ai-mesh-cleanup/SKILL.md", + "skills/engine-export-presets/SKILL.md", "skills/operators/SKILL.md", "skills/ui-panels/SKILL.md", "skills/custom-properties/SKILL.md", @@ -37,7 +38,8 @@ "rules/prefer-temp-override-over-context-copy.mdc", "rules/use-foreach-set-for-bulk-data.mdc", "rules/validate-imported-mesh-scale.mdc", - "rules/no-unapplied-modifiers-on-export.mdc" + "rules/no-unapplied-modifiers-on-export.mdc", + "rules/use-correct-axis-rna-per-exporter.mdc" ], "snippets": [ "snippets/action-ensure-channelbag-for-slot.py", @@ -50,6 +52,9 @@ "snippets/decimate_to_budget.py", "snippets/depsgraph-evaluated-mesh.py", "snippets/driver-with-custom-function.py", + "snippets/export_preset_godot.py", + "snippets/export_preset_unity.py", + "snippets/export_preset_unreal.py", "snippets/foreach-get-vertices.py", "snippets/foreach-set-vertices.py", "snippets/gltf_draco_export.py", @@ -83,6 +88,7 @@ "examples/depsgraph-export", "examples/driver-wave", "examples/exit-pre-sidecar", + "examples/export-preset-axis", "examples/gltf-export-roundtrip", "examples/gltf-skin-roundtrip", "examples/gn-bundle-roundtrip", diff --git a/AGENTS.md b/AGENTS.md index 6394fbd..61e2776 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ a `.cursor-plugin/plugin.json` manifest so the ecosystem drift checker classifies it as a `cursor-plugin`. This is content the AI loads when the user asks Blender questions or works on Blender add-ons in Cursor or Claude Code. -The content base is 14 skills, 8 rules, 2 templates, 21 snippets, and 53 +The content base is 15 skills, 9 rules, 2 templates, 24 snippets, and 54 examples (counts are CI-enforced against README.md and the manifest). The full inventory tables and per-item purposes live in `CLAUDE.md`. Example anatomy and authoring rules: copy `examples/bmesh-gear/`; the render look is specified @@ -31,11 +31,11 @@ in `docs/VISUAL-STYLE.md`; the canonical run prompt is ``` Blender-Developer-Tools/ - skills//SKILL.md # 14 skill files - rules/.mdc # 8 rule files + skills//SKILL.md # 15 skill files + rules/.mdc # 9 rule files templates// # 2 starter templates - snippets/.py # 21 standalone Python snippets - examples// # 53 runnable smoke-gated examples (+ gallery.json) + snippets/.py # 24 standalone Python snippets + examples// # 54 runnable smoke-gated examples (+ gallery.json) examples/gallery_framing.py # shared Layer 1 framing measurement (render path only) scripts/build_gallery.py # generates docs/gallery/ (stdlib only) scripts/site/ # vendored landing-page build (build_site.py + template) diff --git a/CLAUDE.md b/CLAUDE.md index f7b1ff8..f4f4b24 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,23 +17,24 @@ The **Blender Developer Tools** repository is at **v0.54.0**. It packages skills ## Repository Architecture ``` -skills//SKILL.md - AI workflow definitions, 14 total -rules/.mdc - Anti-pattern rules, 8 total +skills//SKILL.md - AI workflow definitions, 15 total +rules/.mdc - Anti-pattern rules, 9 total templates// - Starter projects, 2 total -snippets/.py - Standalone code patterns, 21 total -examples// - Runnable smoke-gated examples, 53 total (+ gallery.json) +snippets/.py - Standalone code patterns, 24 total +examples// - Runnable smoke-gated examples, 54 total (+ gallery.json) scripts/build_gallery.py - Regenerates docs/gallery/ from gallery.json (stdlib only) scripts/site/ - Vendored landing-page build (Jinja2) docs/gallery/ - Committed generated gallery pages + hero renders VERSION - Source of truth for the repo version ``` -## Skills (14) +## Skills (15) | Skill | Purpose | | --- | --- | | addon-scaffolding | Extensions Platform manifest, file layout, register/unregister symmetry | | ai-mesh-cleanup | Ordered cleanup for imported generated meshes: units, transform apply, origin, normals, budget, collider | +| engine-export-presets | Unity Y-up, Godot Z-up, and Unreal centimeter glTF/FBX presets; glTF uses export_yup, FBX uses axis_forward/axis_up | | operators | `bpy.types.Operator` lifecycle, `bl_idname`, redo, defensive context handling | | ui-panels | `bpy.types.Panel` declarative `draw()`, layout primitives, conditional UI | | custom-properties | `bpy.props` annotations, PropertyGroup, PointerProperty, storage tradeoffs | @@ -47,7 +48,7 @@ VERSION - Source of truth for the repo version | bl-info-migration | Three-step migration from legacy `bl_info` to Extensions Platform, dual-format pattern | | vse-python | VSE timeline from Python: `.strips` vs `.sequences`, `new_effect` kwargs, 5.2 COLOR `width`/`height` bake | -## Rules (8) +## Rules (9) | Rule | Scope | What it flags | | --- | --- | --- | @@ -59,6 +60,7 @@ VERSION - Source of truth for the repo version | use-foreach-set-for-bulk-data | `*.py` | Python loops over `mesh.vertices` setting bulk attributes one at a time | | validate-imported-mesh-scale | `*.py` | glTF/FBX import then mesh work with no `transform_apply` and no unit-scale check | | no-unapplied-modifiers-on-export | `*.py` | Export with live modifiers when the export does not request evaluated geometry | +| use-correct-axis-rna-per-exporter | `*.py` | `export_scene.gltf` with FBX `axis_forward`/`axis_up`, or `export_scene.fbx` with glTF `export_yup` | ## Templates (2) @@ -78,7 +80,7 @@ VERSION - Source of truth for the repo version - glTF export via `bpy.ops.export_scene.gltf` - Explicit exit codes for CI integration -## Snippets (21) +## Snippets (24) Small standalone `.py` files at `snippets/.py`, each 5 to 50 lines. @@ -86,9 +88,9 @@ v0.1.0: canonical object creation and deletion, depsgraph evaluated mesh, bmesh v0.2.0: Principled BSDF material, driver-with-custom-function via `driver_namespace`, application handler registration, shader node group with cross-version `interface` API, `foreach_get` bulk vertex read, version-branch skeleton, and USD export with `evaluation_mode='RENDER'`. -AI asset pipeline track: `decimate_to_budget.py`, `convex_hull_collider.py`, `lod_chain.py` (helper duplicated, not imported), `gltf_draco_export.py`. +AI asset pipeline track: `decimate_to_budget.py`, `convex_hull_collider.py`, `lod_chain.py` (helper duplicated, not imported), `gltf_draco_export.py`, `export_preset_unity.py`, `export_preset_godot.py`, `export_preset_unreal.py`. -## Examples (53) +## Examples (54) Runnable scripts at `examples//`, each asserting a real API contract with deterministic checks (exit non-zero on failure) and optionally rendering a still via diff --git a/README.md b/README.md index c4126c0..3ed577c 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@

- 14 skills  •  8 rules  •  2 templates  •  21 snippets  •  53 examples + 15 skills  •  9 rules  •  2 templates  •  24 snippets  •  54 examples

@@ -36,16 +36,16 @@ ## Overview -This repository ships **14 skills, 8 rules, 2 templates, 21 snippets, and 53 examples** for Blender Python development targeting Blender 5.2 LTS (current stable) with Blender 4.5 LTS fallback support. Blender 5.1 is prior stable. +This repository ships **15 skills, 9 rules, 2 templates, 24 snippets, and 54 examples** for Blender Python development targeting Blender 5.2 LTS (current stable) with Blender 4.5 LTS fallback support. Blender 5.1 is prior stable. The content is consumed by AI coding agents (Cursor, Claude Code, any MCP-capable client) when working on Blender add-ons, geometry nodes scripts, batch pipelines, or animation tooling. There is no build step. Edit the markdown and Python files directly. | Layer | Role | | --- | --- | -| **Skills** | Guided workflows: scaffolding, operators, panels, properties, mesh and bmesh, headless batch, slotted actions, geometry nodes, procedural materials, depsgraph queries, drivers and handlers, `bl_info` migration, video sequencer, imported-mesh cleanup | -| **Rules** | Guardrails for the most common AI mistakes: ops-in-loops, bmesh leaks, legacy `bl_info` only, prop assignments, deprecated context-copy override, per-element loops over bulk mesh data, import without scale check, export without evaluated geometry | +| **Skills** | Guided workflows: scaffolding, operators, panels, properties, mesh and bmesh, headless batch, slotted actions, geometry nodes, procedural materials, depsgraph queries, drivers and handlers, `bl_info` migration, video sequencer, imported-mesh cleanup, engine export presets | +| **Rules** | Guardrails for the most common AI mistakes: ops-in-loops, bmesh leaks, legacy `bl_info` only, prop assignments, deprecated context-copy override, per-element loops over bulk mesh data, import without scale check, export without evaluated geometry, mixed glTF/FBX axis RNA | | **Templates** | A working Extensions Platform add-on starter and a headless batch script starter | -| **Snippets** | 21 small standalone Python files demonstrating canonical patterns | +| **Snippets** | 24 small standalone Python files demonstrating canonical patterns | ## Quick start @@ -627,7 +627,7 @@ portable path is `radius`.

-Game asset pipeline — 20 examples +Game asset pipeline — 21 examples @@ -646,6 +646,22 @@ loop), V-flipped UVs, and per-triangle material bindings — all against the depsgraph-evaluated mesh. The exporter/importer RNA signatures are probed byte-identical on 4.5.11 and 5.1.2 and guarded against future renames. + + + + + @@ -1008,15 +1024,15 @@ the duplicates, then glTF ships 24 tris / 48 positions / 8 unique. ## How content is organized ``` -skills//SKILL.md - 14 skill files, YAML frontmatter, one canonical pattern each -rules/.mdc - 8 rule files, anti-pattern + correction +skills//SKILL.md - 15 skill files, YAML frontmatter, one canonical pattern each +rules/.mdc - 9 rule files, anti-pattern + correction templates// - 2 template directories (extension-addon-template, headless-batch-script-template) -snippets/.py - 21 standalone Python snippets, 5 to 50 lines each +snippets/.py - 24 standalone Python snippets, 5 to 50 lines each ``` ## Using rules in Cursor -The `.mdc` files in `rules/` apply automatically when Cursor opens a Blender Python project, scoped by the `globs` in each rule's frontmatter. The eight rules are: +The `.mdc` files in `rules/` apply automatically when Cursor opens a Blender Python project, scoped by the `globs` in each rule's frontmatter. The nine rules are: - `prefer-data-over-ops-in-loops`: flags `bpy.ops.*` calls inside object iteration - `always-free-bmesh`: flags `bmesh.new()` without paired `bm.free()` in `try`/`finally` @@ -1026,6 +1042,7 @@ The `.mdc` files in `rules/` apply automatically when Cursor opens a Blender Pyt - `use-foreach-set-for-bulk-data`: flags Python loops over `mesh.vertices` setting `co`, normals, or other per-element bulk data - `validate-imported-mesh-scale`: flags glTF/FBX import then mesh work with no `transform_apply` and no unit-scale check - `no-unapplied-modifiers-on-export`: flags export of objects with live modifiers when the export does not request evaluated geometry +- `use-correct-axis-rna-per-exporter`: flags `export_scene.gltf` calls that pass FBX `axis_forward` / `axis_up`, and `export_scene.fbx` calls that pass glTF `export_yup` Symlink or clone this repo, then point Cursor at it as a skills/rules source. diff --git a/ROADMAP.md b/ROADMAP.md index 03fb9bd..d59d2de 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -18,8 +18,8 @@ derives the actual version from conventional-commit types. | 5.2 LTS targeting, GN modifier inputs | 12 | 6 | 2 | 17 | Shipped | | VSE COLOR strip intrinsic size (undocumented 5.2) | 13 | 6 | 2 | 17 | Shipped | | Modal operators, USD, mathutils | — | — | — | — | Upcoming | -| AI asset pipeline: post-generation cleanup | - | - | - | - | Upcoming | -| AI asset pipeline: engine export presets | - | - | - | - | Upcoming | +| AI asset pipeline: post-generation cleanup | 14 | 8 | 2 | 21 | Shipped (v0.54.0) | +| AI asset pipeline: engine export presets | 15 | 9 | 2 | 24 | Shipped | | AI asset pipeline: headless template | - | - | - | - | Upcoming | | AI asset pipeline: live-session bridge (spike) | - | - | - | - | Upcoming | | Stable | — | — | — | — | Upcoming | @@ -95,10 +95,10 @@ Audit pass on v0.1.0 content: standards-version markers bumped from `1.9.1` to ` Provider-agnostic GLB-in / engine-ready-out. This repo does not generate meshes. -- **Post-generation cleanup skills** (this phase starts the family; bake/UV/atlas follow on): import and unit-scale normalization, transform apply and origin, poly-budget decimate, LOD chain, collision mesh, high-to-low bake, UV transfer and atlas packing. Phase 1: `ai-mesh-cleanup`, four snippets, two rules. -- **Engine export presets.** Unity (Y-up), Godot, and Unreal (centimeter scale) glTF and FBX paths with Draco. One skill, one snippet set. -- **`ai-asset-pipeline-template/`.** Third template. Headless: GLB path in; LOD set, convex collider, engine-preset export; explicit CI exit codes. Pattern: `templates/headless-batch-script-template/`. Phase 2. -- **Live-session agent bridge.** Research spike, not a committed deliverable. MCP server or socket listener so an agent can execute against a running Blender instance instead of blind `--background` scripts. Built on `templates/extension-addon-template/`. Needs its own design pass. +- **Post-generation cleanup skills.** Import and unit-scale normalization, transform apply and origin, poly-budget decimate, LOD chain, collision mesh. Phase 1 shipped in v0.54.0 as `ai-mesh-cleanup`, four snippets, two rules. Bake/UV/atlas follow on. +- **Engine export presets.** **Delivered.** Unity (Y-up glTF), Godot (Z-up glTF, meters), Unreal (centimeter glTF bake and FBX `global_scale`). Skill `engine-export-presets`, three snippets, rule `use-correct-axis-rna-per-exporter`, witness `examples/export-preset-axis/`. Draco remains opt-in via `gltf_draco_export.py`. +- **`ai-asset-pipeline-template/`.** Third template. Headless: GLB path in; LOD set, convex collider, engine-preset export; explicit CI exit codes. Pattern: `templates/headless-batch-script-template/`. Phase 3. Unpinned. +- **Live-session agent bridge.** Research spike, not a committed deliverable. MCP server or socket listener so an agent can execute against a running Blender instance instead of blind `--background` scripts. Built on `templates/extension-addon-template/`. Needs its own design pass. Unpinned. ## Candidate pool (next content) diff --git a/docs/gallery/assets/export-preset-axis-hero.webp b/docs/gallery/assets/export-preset-axis-hero.webp new file mode 100644 index 0000000000000000000000000000000000000000..842f6f556e74a4e4aedc3a4d8055388d66c8f879 GIT binary patch literal 8272 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 literal 0 HcmV?d00001 diff --git a/docs/gallery/export-preset-axis/index.html b/docs/gallery/export-preset-axis/index.html new file mode 100644 index 0000000..ac5bd06 --- /dev/null +++ b/docs/gallery/export-preset-axis/index.html @@ -0,0 +1,838 @@ + + + + + + export-preset-axis — Examples — Blender Developer Tools + + + + + + + + + + + + + + + + + +

+
+

export-preset-axis

+

A radio beacon exported under Unity and Godot glTF presets and re-imported, proving the two files have different vertex orientation

+
+
+ +

Rendered headless by the example itself — click to zoom.

+
witnesses Unity export_yup=True round-trips standing (x,y,z); Godot export_yup=False reimports lying along -Y as (x,-z,y); --same-axis exits 9; exporter RNA is guarded so a future kwarg rename fails loudly
+
+
blender --background --python examples/export-preset-axis/export_preset_axis.py --
+ +
+
+

A runnable example that exports the same radio-beacon mesh under the Unity and Godot glTF presets from engine-export-presets and re-imports both files. The check is on coordinates, not a screenshot: Unity (export_yup=True) stands; Godot (export_yup=False) lies.

+

What it witnesses: named engine presets are not comments on the same kwargs. glTF axis RNA is export_yup, not FBX axis_forward / axis_up.

+
  • Disk POSITION follows the closed form. Unity bakes (x, y, z) -> (x, z, -y) with no node rotation. Godot writes raw Z-up (x, y, z). The check reads accessor min/max from the .gltf JSON.
  • Re-import proves the conversion. Blender's importer always treats the file as Y-up: blender = (gltf.x, -gltf.z, gltf.y). Unity restores the source. Godot permutes again, so the mast lies along -Y.
  • The two reimports differ. Unity z_span matches source height; Godot y_span matches that height. --same-axis exports both with export_yup=True; both stand and the differ check exits 9.
  • Exporter RNA is guarded. Every kwarg passed must still exist on bpy.ops.export_scene.gltf.
+

Neighbor of gltf-export-roundtrip (Y-up bake vs Z-up on disk for one file) and unapplied-scale-gltf (export_apply is modifiers, not object scale). This example names the Unity vs Godot presets and asserts the re-imported orientations diverge.

+

The still stages the two reimports side by side: standing Unity left, lying Godot right. If export_yup were the same on both, the pair would match.

+

Run

+
# Cheap correctness check (no render) - the CI check:
+blender --background --python export_preset_axis.py --
+
+# Falsifier: both presets Y-up. Must exit non-zero.
+blender --background --python export_preset_axis.py -- --same-axis
+
+# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
+blender --background --python export_preset_axis.py -- --output beacon.png
+blender --background --python export_preset_axis.py -- --output beacon.png --engine cycles
+

It exits non-zero on failure (RNA drift, source not Z-dominant, Unity disk not converted, Godot disk not Z-up, Unity not standing, Godot not lying, orientations equal). The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron).

+
+
+

Source

+
+ examples/export-preset-axis/export_preset_axis.py + View on GitHub → +
+
"""Unity vs Godot glTF presets: a runnable example.
+
+Same source mesh, two presets. Unity is Y-up (`export_yup=True`). Godot in this
+repo is Z-up glTF (`export_yup=False`). Re-importing each file through Blender's
+Y-up glTF importer proves the axis conversion actually happened: Unity stands
+(mast along +Z, original coords), Godot lies (mast along -Y). The check is on
+re-imported coordinates, not a screenshot.
+
+Closed form (Blender Z-up source `(x, y, z)`):
+
+* `export_yup=True` disk POSITION: `(x, z, -y)`
+* `export_yup=False` disk POSITION: `(x, y, z)`
+* Blender importer always treats the file as Y-up:
+  `blender = (gltf.x, -gltf.z, gltf.y)`
+  so Unity round-trips to `(x, y, z)` and Godot becomes `(x, -z, y)`.
+
+`--same-axis` exports both with `export_yup=True`. Both reimports stand, the
+"orientations differ" check exits 9. That is the falsifier.
+
+By default it runs only the correctness check (no render) - the CI smoke
+check. Pass --output to also render a still:
+
+    blender --background --python export_preset_axis.py --
+    blender --background --python export_preset_axis.py -- --output p.png
+    blender --background --python export_preset_axis.py -- --same-axis
+"""
+import argparse
+import json
+import math
+import os
+import sys
+import tempfile
+
+import bpy
+import bmesh
+from mathutils import Vector
+
+sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir))
+sys.dont_write_bytecode = True
+import gallery_framing
+
+EPS = 2e-4
+SPAN_GAP = 0.4
+
+# Full extents (not half-sizes). create_cube(size=1) verts are +/- 0.5.
+PARTS = (
+    ("base", (0.0, 0.0, 0.08), (0.90, 0.90, 0.16)),
+    ("step", (0.0, -0.51, 0.04), (0.28, 0.12, 0.08)),
+    ("pedestal", (0.0, 0.0, 0.26), (0.26, 0.26, 0.20)),
+    ("mast", (0.0, 0.0, 1.21), (0.09, 0.09, 1.70)),
+    ("yard", (0.31, 0.0, 1.85), (0.62, 0.07, 0.07)),
+    ("dish", (0.73, 0.0, 1.85), (0.22, 0.28, 0.28)),
+    ("cap", (0.0, 0.0, 2.11), (0.14, 0.14, 0.10)),
+)
+
+CAP_Z = 2.06
+TIP = Vector((0.07, 0.07, 2.16))
+
+UNITY_KWARGS = dict(
+    export_format="GLTF_SEPARATE",
+    use_selection=True,
+    export_yup=True,
+    export_apply=True,
+    export_texcoords=False,
+    export_normals=True,
+    export_materials="EXPORT",
+    export_animations=False,
+    export_image_format="NONE",
+)
+GODOT_KWARGS = dict(UNITY_KWARGS)
+GODOT_KWARGS["export_yup"] = False
+
+
+def eevee_engine_id():
+    return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT"
+
+
+def aabb_of(points):
+    xs = [p.x for p in points]
+    ys = [p.y for p in points]
+    zs = [p.z for p in points]
+    return (
+        (min(xs), max(xs)),
+        (min(ys), max(ys)),
+        (min(zs), max(zs)),
+    )
+
+
+def span(lohi):
+    return lohi[1] - lohi[0]
+
+
+def world_points(obj):
+    mw = obj.matrix_world
+    return [mw @ v.co.copy() for v in obj.data.vertices]
+
+
+def position_minmax(gltf_path):
+    g = json.load(open(gltf_path, encoding="utf-8"))
+    mins = []
+    maxs = []
+    for mesh in g["meshes"]:
+        for prim in mesh["primitives"]:
+            acc = g["accessors"][prim["attributes"]["POSITION"]]
+            mins.append(acc["min"])
+            maxs.append(acc["max"])
+    umin = tuple(min(m[i] for m in mins) for i in range(3))
+    umax = tuple(max(m[i] for m in maxs) for i in range(3))
+    node = g["nodes"][0]
+    return g, umin, umax, node
+
+
+def add_box(bm, center, size):
+    geom = bmesh.ops.create_cube(bm, size=1.0)
+    cx, cy, cz = center
+    sx, sy, sz = size
+    for vert in geom["verts"]:
+        vert.co.x = vert.co.x * sx + cx
+        vert.co.y = vert.co.y * sy + cy
+        vert.co.z = vert.co.z * sz + cz
+
+
+def build():
+    bpy.ops.wm.read_factory_settings(use_empty=True)
+    me = bpy.data.meshes.new("Beacon")
+    bm = bmesh.new()
+    try:
+        for _name, center, size in PARTS:
+            add_box(bm, center, size)
+        bm.to_mesh(me)
+    finally:
+        bm.free()
+    obj = bpy.data.objects.new("Beacon", me)
+    bpy.context.collection.objects.link(obj)
+    hull = principled("Hull", (0.22, 0.28, 0.18, 1.0), 0.35, 0.38)
+    glow = principled("Beacon", (0.02, 0.55, 0.48, 1.0), 0.0, 0.22)
+    emit = (0.05, 1.0, 0.75, 1.0)
+    bsdf = glow.node_tree.nodes["Principled BSDF"]
+    sock = bsdf.inputs.get("Emission Color") or bsdf.inputs["Emission"]
+    sock.default_value = emit
+    bsdf.inputs["Emission Strength"].default_value = 4.0
+    me.materials.append(hull)
+    me.materials.append(glow)
+    for poly in me.polygons:
+        poly.use_smooth = True
+        poly.material_index = 1 if poly.center.z > CAP_Z else 0
+    obj.select_set(True)
+    bpy.context.view_layer.objects.active = obj
+    return obj
+
+
+def principled(name, color, metallic, roughness):
+    mat = bpy.data.materials.new(name)
+    mat.use_nodes = True
+    bsdf = mat.node_tree.nodes["Principled BSDF"]
+    bsdf.inputs["Base Color"].default_value = color
+    bsdf.inputs["Metallic"].default_value = metallic
+    bsdf.inputs["Roughness"].default_value = roughness
+    return mat
+
+
+def apply_selected_mesh_transforms():
+    for obj in list(bpy.context.selected_objects):
+        if obj.type != "MESH":
+            continue
+        with bpy.context.temp_override(
+            object=obj, active_object=obj, selected_objects=[obj]
+        ):
+            bpy.ops.object.transform_apply(
+                location=False, rotation=True, scale=True
+            )
+
+
+def export_selected(path, kwargs):
+    apply_selected_mesh_transforms()
+    bpy.ops.export_scene.gltf(filepath=path, **kwargs)
+
+
+def import_gltf_meshes(path):
+    before = set(bpy.data.objects)
+    bpy.ops.import_scene.gltf(filepath=path)
+    added = [o for o in bpy.data.objects if o not in before and o.type == "MESH"]
+    return added or None
+
+
+def all_world_points(objs):
+    pts = []
+    for obj in objs:
+        pts.extend(world_points(obj))
+    return pts
+
+
+def node_has_rotation(node):
+    rot = node.get("rotation")
+    if not rot:
+        return False
+    return (
+        abs(rot[0]) > EPS
+        or abs(rot[1]) > EPS
+        or abs(rot[2]) > EPS
+        or abs(rot[3] - 1.0) > EPS
+    )
+
+
+def near(a, b, eps=EPS):
+    return abs(a - b) <= eps
+
+
+def check(src, same_axis):
+    exp_props = {
+        p.identifier for p in bpy.ops.export_scene.gltf.get_rna_type().properties
+    }
+    missing = [k for k in UNITY_KWARGS if k not in exp_props]
+    if missing:
+        print(f"ERROR: exporter RNA drifted, missing {missing}", file=sys.stderr)
+        return 2, None, None
+
+    pts = world_points(src)
+    sx, sy, sz = aabb_of(pts)
+    z_span, y_span, x_span = span(sz), span(sy), span(sx)
+    print(
+        f"source_aabb x={sx[0]:.4f}..{sx[1]:.4f} "
+        f"y={sy[0]:.4f}..{sy[1]:.4f} z={sz[0]:.4f}..{sz[1]:.4f}"
+    )
+    if not (z_span > y_span + SPAN_GAP and z_span > x_span + SPAN_GAP):
+        print(
+            f"ERROR: source mast is not Z-dominant z_span={z_span:.4f} "
+            f"y_span={y_span:.4f} x_span={x_span:.4f}",
+            file=sys.stderr,
+        )
+        return 3, None, None
+    tip_err = min((p - TIP).length for p in pts)
+    if tip_err > 1e-5:
+        print(f"ERROR: source tip drifted {tip_err:.3e} from {tuple(TIP)}", file=sys.stderr)
+        return 3, None, None
+
+    tmp = tempfile.mkdtemp(prefix="export_preset_axis_")
+    unity_path = os.path.join(tmp, "unity.gltf").replace("\\", "/")
+    godot_path = os.path.join(tmp, "godot.gltf").replace("\\", "/")
+    godot_kwargs = dict(GODOT_KWARGS)
+    if same_axis:
+        godot_kwargs["export_yup"] = True
+
+    src.select_set(True)
+    bpy.context.view_layer.objects.active = src
+    export_selected(unity_path, UNITY_KWARGS)
+    export_selected(godot_path, godot_kwargs)
+
+    _ug, u_min, u_max, u_node = position_minmax(unity_path)
+    _gg, g_min, g_max, g_node = position_minmax(godot_path)
+    print(f"unity_disk min={u_min} max={u_max} node_rot={u_node.get('rotation')}")
+    print(f"godot_disk min={g_min} max={g_max} node_rot={g_node.get('rotation')}")
+
+    # Unity disk Y is source Z; disk Z is -source Y.
+    if not (
+        near(u_min[1], sz[0])
+        and near(u_max[1], sz[1])
+        and near(u_min[2], -sy[1])
+        and near(u_max[2], -sy[0])
+    ):
+        print(
+            f"ERROR: Unity disk POSITION is not (x, z, -y) "
+            f"u_min={u_min} u_max={u_max} source_z={sz} source_y={sy}",
+            file=sys.stderr,
+        )
+        return 5, None, None
+    if node_has_rotation(u_node):
+        print(f"ERROR: Unity node has rotation {u_node.get('rotation')}", file=sys.stderr)
+        return 5, None, None
+
+    if not same_axis:
+        if not (near(g_min[2], sz[0]) and near(g_max[2], sz[1])):
+            print(
+                f"ERROR: Godot disk POSITION is not raw Z-up "
+                f"g_min={g_min} g_max={g_max} source_z={sz}",
+                file=sys.stderr,
+            )
+            return 6, None, None
+
+    unity_objs = import_gltf_meshes(unity_path)
+    godot_objs = import_gltf_meshes(godot_path)
+    if unity_objs is None or godot_objs is None:
+        print("ERROR: expected a mesh per glTF import", file=sys.stderr)
+        return 4, None, None
+
+    u_pts = all_world_points(unity_objs)
+    g_pts = all_world_points(godot_objs)
+    ux, uy, uz = aabb_of(u_pts)
+    gx, gy, gz = aabb_of(g_pts)
+    print(
+        f"unity_reimport x={ux[0]:.4f}..{ux[1]:.4f} "
+        f"y={uy[0]:.4f}..{uy[1]:.4f} z={uz[0]:.4f}..{uz[1]:.4f}"
+    )
+    print(
+        f"godot_reimport x={gx[0]:.4f}..{gx[1]:.4f} "
+        f"y={gy[0]:.4f}..{gy[1]:.4f} z={gz[0]:.4f}..{gz[1]:.4f}"
+    )
+
+    if not (
+        near(span(uz), z_span)
+        and near(span(uy), y_span)
+        and span(uz) > span(uy) + SPAN_GAP
+    ):
+        print(
+            f"ERROR: Unity reimport is not standing "
+            f"z_span={span(uz):.4f} y_span={span(uy):.4f} source_z={z_span:.4f}",
+            file=sys.stderr,
+        )
+        return 7, unity_objs, godot_objs
+
+    godot_lying = (
+        near(span(gy), z_span)
+        and near(span(gz), y_span)
+        and span(gy) > span(gz) + SPAN_GAP
+    )
+    orientations_differ = abs(span(uz) - span(gz)) > SPAN_GAP and abs(
+        span(uy) - span(gy)
+    ) > SPAN_GAP
+
+    if same_axis:
+        if orientations_differ:
+            print(
+                "ERROR: --same-axis did not collapse the axis difference",
+                file=sys.stderr,
+            )
+            return 11, unity_objs, godot_objs
+        print("ERROR: orientations did not differ", file=sys.stderr)
+        return 9, unity_objs, godot_objs
+
+    if not godot_lying:
+        print(
+            f"ERROR: Godot reimport is not lying along Y "
+            f"y_span={span(gy):.4f} z_span={span(gz):.4f} source_z={z_span:.4f}",
+            file=sys.stderr,
+        )
+        return 8, unity_objs, godot_objs
+
+    expected_godot_tip = Vector((TIP.x, -TIP.z, TIP.y))
+    godot_tip_err = min((p - expected_godot_tip).length for p in g_pts)
+    unity_tip_err = min((p - TIP).length for p in u_pts)
+    print(f"unity_tip_err={unity_tip_err:.3e} godot_tip_err={godot_tip_err:.3e}")
+    if unity_tip_err > 5e-4 or godot_tip_err > 5e-4:
+        print(
+            f"ERROR: reimported tip mismatch unity={unity_tip_err:.3e} "
+            f"godot={godot_tip_err:.3e} expected_godot={tuple(expected_godot_tip)}",
+            file=sys.stderr,
+        )
+        return 8, unity_objs, godot_objs
+
+    if not orientations_differ:
+        print(
+            f"ERROR: reimported orientations did not differ "
+            f"unity_z={span(uz):.4f} godot_z={span(gz):.4f}",
+            file=sys.stderr,
+        )
+        return 9, unity_objs, godot_objs
+
+    return 0, unity_objs, godot_objs
+
+
+def sit_on_floor(objs, x, y):
+    bpy.context.view_layer.update()
+    pts = all_world_points(objs)
+    min_x = min(p.x for p in pts)
+    max_x = max(p.x for p in pts)
+    min_y = min(p.y for p in pts)
+    max_y = max(p.y for p in pts)
+    min_z = min(p.z for p in pts)
+    dx = x - 0.5 * (min_x + max_x)
+    dy = y - 0.5 * (min_y + max_y)
+    dz = -min_z
+    for obj in objs:
+        obj.location.x += dx
+        obj.location.y += dy
+        obj.location.z += dz
+    bpy.context.view_layer.update()
+
+
+def light(scene, name, loc, energy, size, col, rot):
+    ld = bpy.data.lights.new(name, "AREA")
+    ld.energy = energy
+    ld.size = size
+    ld.color = col
+    ob = bpy.data.objects.new(name, ld)
+    ob.location = loc
+    ob.rotation_euler = tuple(math.radians(a) for a in rot)
+    scene.collection.objects.link(ob)
+
+
+def render_still(source, unity_objs, godot_objs, path, engine):
+    scene = bpy.context.scene
+    source.hide_render = True
+    source.hide_viewport = True
+    sit_on_floor(unity_objs, -2.15, 0.0)
+    sit_on_floor(godot_objs, 1.95, 0.0)
+
+    floor_me = bpy.data.meshes.new("Floor")
+    bm = bmesh.new()
+    try:
+        bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=30.0)
+        bm.to_mesh(floor_me)
+    finally:
+        bm.free()
+    floor_me.materials.append(principled("Studio", (0.03, 0.032, 0.037, 1.0), 0.0, 0.7))
+    floor = bpy.data.objects.new("Floor", floor_me)
+    scene.collection.objects.link(floor)
+    wall = bpy.data.objects.new("Wall", floor_me.copy())
+    wall.data.materials.clear()
+    wall.data.materials.append(principled("Wall", (0.03, 0.032, 0.037, 1.0), 0.0, 0.7))
+    wall.location = (0.0, 9.0, 0.0)
+    wall.rotation_euler = (math.pi / 2, 0.0, 0.0)
+    scene.collection.objects.link(wall)
+
+    world = bpy.data.worlds.new("World")
+    world.use_nodes = True
+    world.node_tree.nodes["Background"].inputs["Color"].default_value = (
+        0.02,
+        0.021,
+        0.025,
+        1.0,
+    )
+    scene.world = world
+
+    light(scene, "Key", (-4.0, -5.0, 6.0), 650.0, 5.0, (1.0, 0.96, 0.9), (46, 0, -35))
+    light(scene, "Fill", (5.0, -3.5, 3.0), 120.0, 9.0, (0.75, 0.85, 1.0), (62, 0, 50))
+    light(scene, "Wedge", (2.5, 5.5, 4.0), 380.0, 6.0, (1.0, 0.76, 0.5), (-68, 0, 190))
+
+    cam_data = bpy.data.cameras.new("Cam")
+    cam_data.lens = 50.0
+    cam = bpy.data.objects.new("Cam", cam_data)
+    cam.location = (3.12, -8.15, 2.45)
+    scene.collection.objects.link(cam)
+    aim = bpy.data.objects.new("Aim", None)
+    aim.location = (0.0, 0.0, 0.85)
+    scene.collection.objects.link(aim)
+    con = cam.constraints.new("TRACK_TO")
+    con.target = aim
+    con.track_axis = "TRACK_NEGATIVE_Z"
+    con.up_axis = "UP_Y"
+    scene.camera = cam
+
+    scene.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id()
+    if engine == "cycles":
+        scene.cycles.samples = 32
+    else:
+        try:
+            scene.eevee.taa_render_samples = 64
+        except AttributeError:
+            pass
+    scene.render.resolution_x = 1280
+    scene.render.resolution_y = 720
+    scene.render.image_settings.file_format = "PNG"
+    scene.render.filepath = path
+    scene.view_settings.view_transform = "Standard"
+
+    fcode = gallery_framing.check_framing(
+        scene,
+        cam,
+        hero=unity_objs + godot_objs,
+        elements=unity_objs + godot_objs,
+        stage=[floor, wall],
+    )
+    if fcode:
+        return fcode
+    bpy.ops.render.render(write_still=True)
+    if not (os.path.exists(path) and os.path.getsize(path) > 0):
+        print("ERROR: render produced no file", file=sys.stderr)
+        return 6
+    return 0
+
+
+def main():
+    argv = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else []
+    p = argparse.ArgumentParser()
+    p.add_argument("--output", default=None, help="optional: render a still PNG here")
+    p.add_argument(
+        "--engine",
+        default="eevee",
+        choices=("eevee", "cycles"),
+        help="render engine for --output",
+    )
+    p.add_argument(
+        "--same-axis",
+        action="store_true",
+        help="export both presets with export_yup=True (must fail)",
+    )
+    args = p.parse_args(argv)
+
+    src = build()
+    code, unity_objs, godot_objs = check(src, args.same_axis)
+    if code:
+        return code
+
+    if args.output:
+        rcode = render_still(
+            src, unity_objs, godot_objs, os.path.abspath(args.output), args.engine
+        )
+        if rcode:
+            return rcode
+        print(f"rendered still {args.output}")
+
+    print("export-preset-axis OK")
+    return 0
+
+
+if __name__ == "__main__":
+    try:
+        sys.exit(main())
+    except Exception as e:
+        import traceback
+
+        traceback.print_exc()
+        print(f"FATAL: {e}", file=sys.stderr)
+        sys.exit(1)
+
+
+
+ +
+
+ generated from examples/gallery.json + CC-BY-NC-ND-4.0 + exit 0 +
+
+ + + diff --git a/docs/gallery/index.html b/docs/gallery/index.html index 4ddcc55..ee83999 100644 --- a/docs/gallery/index.html +++ b/docs/gallery/index.html @@ -272,7 +272,7 @@

Examples Gallery

autocomplete="off" spellcheck="false" aria-label="Search examples" /> - 48 examples + 49 examples
@@ -598,6 +598,17 @@

gltf-export-roundtrip

View example
+
+ + export-preset-axis — A radio beacon exported under Unity and Godot glTF presets and re-imported, proving the two files have different vertex orientation + +
+

export-preset-axis

+

A radio beacon exported under Unity and Godot glTF presets and re-imported, proving the two files have different vertex orientation

+

witnesses Unity export_yup=True round-trips standing (x,y,z); Godot export_yup=False reimports lying along -Y as (x,-z,y); --same-axis exits 9; exporter RNA is guarded so a future kwarg rename fails loudly

+ View example +
+
lod-decimate-chain — A retro rocket at LOD0/1/2 via the Decimate modifier evaluated through the depsgraph. diff --git a/examples/export-preset-axis/README.md b/examples/export-preset-axis/README.md new file mode 100644 index 0000000..5ead816 --- /dev/null +++ b/examples/export-preset-axis/README.md @@ -0,0 +1,49 @@ +# Export Preset Axis + +A runnable example that exports the same radio-beacon mesh under the Unity +and Godot glTF presets from +[`engine-export-presets`](../../skills/engine-export-presets/SKILL.md) and +re-imports both files. The check is on coordinates, not a screenshot: Unity +(`export_yup=True`) stands; Godot (`export_yup=False`) lies. + +**What it witnesses:** named engine presets are not comments on the same +kwargs. glTF axis RNA is `export_yup`, not FBX `axis_forward` / `axis_up`. + +- **Disk POSITION follows the closed form.** Unity bakes + `(x, y, z) -> (x, z, -y)` with no node rotation. Godot writes raw Z-up + `(x, y, z)`. The check reads accessor min/max from the `.gltf` JSON. +- **Re-import proves the conversion.** Blender's importer always treats the + file as Y-up: `blender = (gltf.x, -gltf.z, gltf.y)`. Unity restores the + source. Godot permutes again, so the mast lies along `-Y`. +- **The two reimports differ.** Unity `z_span` matches source height; Godot + `y_span` matches that height. `--same-axis` exports both with + `export_yup=True`; both stand and the differ check exits 9. +- **Exporter RNA is guarded.** Every kwarg passed must still exist on + `bpy.ops.export_scene.gltf`. + +Neighbor of [`gltf-export-roundtrip`](../gltf-export-roundtrip/) (Y-up bake +vs Z-up on disk for one file) and [`unapplied-scale-gltf`](../unapplied-scale-gltf/) +(`export_apply` is modifiers, not object scale). This example names the +Unity vs Godot presets and asserts the re-imported orientations diverge. + +The still stages the two reimports side by side: standing Unity left, lying +Godot right. If `export_yup` were the same on both, the pair would match. + +## Run + +```bash +# Cheap correctness check (no render) - the CI check: +blender --background --python export_preset_axis.py -- + +# Falsifier: both presets Y-up. Must exit non-zero. +blender --background --python export_preset_axis.py -- --same-axis + +# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts): +blender --background --python export_preset_axis.py -- --output beacon.png +blender --background --python export_preset_axis.py -- --output beacon.png --engine cycles +``` + +It exits non-zero on failure (RNA drift, source not Z-dominant, Unity disk +not converted, Godot disk not Z-up, Unity not standing, Godot not lying, +orientations equal). The `blender-smoke` workflow runs the check on Blender +5.2 LTS and 4.5 LTS (5.1 on the weekly cron). diff --git a/examples/export-preset-axis/export_preset_axis.py b/examples/export-preset-axis/export_preset_axis.py new file mode 100644 index 0000000..94dd01f --- /dev/null +++ b/examples/export-preset-axis/export_preset_axis.py @@ -0,0 +1,514 @@ +"""Unity vs Godot glTF presets: a runnable example. + +Same source mesh, two presets. Unity is Y-up (`export_yup=True`). Godot in this +repo is Z-up glTF (`export_yup=False`). Re-importing each file through Blender's +Y-up glTF importer proves the axis conversion actually happened: Unity stands +(mast along +Z, original coords), Godot lies (mast along -Y). The check is on +re-imported coordinates, not a screenshot. + +Closed form (Blender Z-up source `(x, y, z)`): + +* `export_yup=True` disk POSITION: `(x, z, -y)` +* `export_yup=False` disk POSITION: `(x, y, z)` +* Blender importer always treats the file as Y-up: + `blender = (gltf.x, -gltf.z, gltf.y)` + so Unity round-trips to `(x, y, z)` and Godot becomes `(x, -z, y)`. + +`--same-axis` exports both with `export_yup=True`. Both reimports stand, the +"orientations differ" check exits 9. That is the falsifier. + +By default it runs only the correctness check (no render) - the CI smoke +check. Pass --output to also render a still: + + blender --background --python export_preset_axis.py -- + blender --background --python export_preset_axis.py -- --output p.png + blender --background --python export_preset_axis.py -- --same-axis +""" +import argparse +import json +import math +import os +import sys +import tempfile + +import bpy +import bmesh +from mathutils import Vector + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir)) +sys.dont_write_bytecode = True +import gallery_framing + +EPS = 2e-4 +SPAN_GAP = 0.4 + +# Full extents (not half-sizes). create_cube(size=1) verts are +/- 0.5. +PARTS = ( + ("base", (0.0, 0.0, 0.08), (0.90, 0.90, 0.16)), + ("step", (0.0, -0.51, 0.04), (0.28, 0.12, 0.08)), + ("pedestal", (0.0, 0.0, 0.26), (0.26, 0.26, 0.20)), + ("mast", (0.0, 0.0, 1.21), (0.09, 0.09, 1.70)), + ("yard", (0.31, 0.0, 1.85), (0.62, 0.07, 0.07)), + ("dish", (0.73, 0.0, 1.85), (0.22, 0.28, 0.28)), + ("cap", (0.0, 0.0, 2.11), (0.14, 0.14, 0.10)), +) + +CAP_Z = 2.06 +TIP = Vector((0.07, 0.07, 2.16)) + +UNITY_KWARGS = dict( + export_format="GLTF_SEPARATE", + use_selection=True, + export_yup=True, + export_apply=True, + export_texcoords=False, + export_normals=True, + export_materials="EXPORT", + export_animations=False, + export_image_format="NONE", +) +GODOT_KWARGS = dict(UNITY_KWARGS) +GODOT_KWARGS["export_yup"] = False + + +def eevee_engine_id(): + return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT" + + +def aabb_of(points): + xs = [p.x for p in points] + ys = [p.y for p in points] + zs = [p.z for p in points] + return ( + (min(xs), max(xs)), + (min(ys), max(ys)), + (min(zs), max(zs)), + ) + + +def span(lohi): + return lohi[1] - lohi[0] + + +def world_points(obj): + mw = obj.matrix_world + return [mw @ v.co.copy() for v in obj.data.vertices] + + +def position_minmax(gltf_path): + g = json.load(open(gltf_path, encoding="utf-8")) + mins = [] + maxs = [] + for mesh in g["meshes"]: + for prim in mesh["primitives"]: + acc = g["accessors"][prim["attributes"]["POSITION"]] + mins.append(acc["min"]) + maxs.append(acc["max"]) + umin = tuple(min(m[i] for m in mins) for i in range(3)) + umax = tuple(max(m[i] for m in maxs) for i in range(3)) + node = g["nodes"][0] + return g, umin, umax, node + + +def add_box(bm, center, size): + geom = bmesh.ops.create_cube(bm, size=1.0) + cx, cy, cz = center + sx, sy, sz = size + for vert in geom["verts"]: + vert.co.x = vert.co.x * sx + cx + vert.co.y = vert.co.y * sy + cy + vert.co.z = vert.co.z * sz + cz + + +def build(): + bpy.ops.wm.read_factory_settings(use_empty=True) + me = bpy.data.meshes.new("Beacon") + bm = bmesh.new() + try: + for _name, center, size in PARTS: + add_box(bm, center, size) + bm.to_mesh(me) + finally: + bm.free() + obj = bpy.data.objects.new("Beacon", me) + bpy.context.collection.objects.link(obj) + hull = principled("Hull", (0.22, 0.28, 0.18, 1.0), 0.35, 0.38) + glow = principled("Beacon", (0.02, 0.55, 0.48, 1.0), 0.0, 0.22) + emit = (0.05, 1.0, 0.75, 1.0) + bsdf = glow.node_tree.nodes["Principled BSDF"] + sock = bsdf.inputs.get("Emission Color") or bsdf.inputs["Emission"] + sock.default_value = emit + bsdf.inputs["Emission Strength"].default_value = 4.0 + me.materials.append(hull) + me.materials.append(glow) + for poly in me.polygons: + poly.use_smooth = True + poly.material_index = 1 if poly.center.z > CAP_Z else 0 + obj.select_set(True) + bpy.context.view_layer.objects.active = obj + return obj + + +def principled(name, color, metallic, roughness): + mat = bpy.data.materials.new(name) + mat.use_nodes = True + bsdf = mat.node_tree.nodes["Principled BSDF"] + bsdf.inputs["Base Color"].default_value = color + bsdf.inputs["Metallic"].default_value = metallic + bsdf.inputs["Roughness"].default_value = roughness + return mat + + +def apply_selected_mesh_transforms(): + for obj in list(bpy.context.selected_objects): + if obj.type != "MESH": + continue + with bpy.context.temp_override( + object=obj, active_object=obj, selected_objects=[obj] + ): + bpy.ops.object.transform_apply( + location=False, rotation=True, scale=True + ) + + +def export_selected(path, kwargs): + apply_selected_mesh_transforms() + bpy.ops.export_scene.gltf(filepath=path, **kwargs) + + +def import_gltf_meshes(path): + before = set(bpy.data.objects) + bpy.ops.import_scene.gltf(filepath=path) + added = [o for o in bpy.data.objects if o not in before and o.type == "MESH"] + return added or None + + +def all_world_points(objs): + pts = [] + for obj in objs: + pts.extend(world_points(obj)) + return pts + + +def node_has_rotation(node): + rot = node.get("rotation") + if not rot: + return False + return ( + abs(rot[0]) > EPS + or abs(rot[1]) > EPS + or abs(rot[2]) > EPS + or abs(rot[3] - 1.0) > EPS + ) + + +def near(a, b, eps=EPS): + return abs(a - b) <= eps + + +def check(src, same_axis): + exp_props = { + p.identifier for p in bpy.ops.export_scene.gltf.get_rna_type().properties + } + missing = [k for k in UNITY_KWARGS if k not in exp_props] + if missing: + print(f"ERROR: exporter RNA drifted, missing {missing}", file=sys.stderr) + return 2, None, None + + pts = world_points(src) + sx, sy, sz = aabb_of(pts) + z_span, y_span, x_span = span(sz), span(sy), span(sx) + print( + f"source_aabb x={sx[0]:.4f}..{sx[1]:.4f} " + f"y={sy[0]:.4f}..{sy[1]:.4f} z={sz[0]:.4f}..{sz[1]:.4f}" + ) + if not (z_span > y_span + SPAN_GAP and z_span > x_span + SPAN_GAP): + print( + f"ERROR: source mast is not Z-dominant z_span={z_span:.4f} " + f"y_span={y_span:.4f} x_span={x_span:.4f}", + file=sys.stderr, + ) + return 3, None, None + tip_err = min((p - TIP).length for p in pts) + if tip_err > 1e-5: + print(f"ERROR: source tip drifted {tip_err:.3e} from {tuple(TIP)}", file=sys.stderr) + return 3, None, None + + tmp = tempfile.mkdtemp(prefix="export_preset_axis_") + unity_path = os.path.join(tmp, "unity.gltf").replace("\\", "/") + godot_path = os.path.join(tmp, "godot.gltf").replace("\\", "/") + godot_kwargs = dict(GODOT_KWARGS) + if same_axis: + godot_kwargs["export_yup"] = True + + src.select_set(True) + bpy.context.view_layer.objects.active = src + export_selected(unity_path, UNITY_KWARGS) + export_selected(godot_path, godot_kwargs) + + _ug, u_min, u_max, u_node = position_minmax(unity_path) + _gg, g_min, g_max, g_node = position_minmax(godot_path) + print(f"unity_disk min={u_min} max={u_max} node_rot={u_node.get('rotation')}") + print(f"godot_disk min={g_min} max={g_max} node_rot={g_node.get('rotation')}") + + # Unity disk Y is source Z; disk Z is -source Y. + if not ( + near(u_min[1], sz[0]) + and near(u_max[1], sz[1]) + and near(u_min[2], -sy[1]) + and near(u_max[2], -sy[0]) + ): + print( + f"ERROR: Unity disk POSITION is not (x, z, -y) " + f"u_min={u_min} u_max={u_max} source_z={sz} source_y={sy}", + file=sys.stderr, + ) + return 5, None, None + if node_has_rotation(u_node): + print(f"ERROR: Unity node has rotation {u_node.get('rotation')}", file=sys.stderr) + return 5, None, None + + if not same_axis: + if not (near(g_min[2], sz[0]) and near(g_max[2], sz[1])): + print( + f"ERROR: Godot disk POSITION is not raw Z-up " + f"g_min={g_min} g_max={g_max} source_z={sz}", + file=sys.stderr, + ) + return 6, None, None + + unity_objs = import_gltf_meshes(unity_path) + godot_objs = import_gltf_meshes(godot_path) + if unity_objs is None or godot_objs is None: + print("ERROR: expected a mesh per glTF import", file=sys.stderr) + return 4, None, None + + u_pts = all_world_points(unity_objs) + g_pts = all_world_points(godot_objs) + ux, uy, uz = aabb_of(u_pts) + gx, gy, gz = aabb_of(g_pts) + print( + f"unity_reimport x={ux[0]:.4f}..{ux[1]:.4f} " + f"y={uy[0]:.4f}..{uy[1]:.4f} z={uz[0]:.4f}..{uz[1]:.4f}" + ) + print( + f"godot_reimport x={gx[0]:.4f}..{gx[1]:.4f} " + f"y={gy[0]:.4f}..{gy[1]:.4f} z={gz[0]:.4f}..{gz[1]:.4f}" + ) + + if not ( + near(span(uz), z_span) + and near(span(uy), y_span) + and span(uz) > span(uy) + SPAN_GAP + ): + print( + f"ERROR: Unity reimport is not standing " + f"z_span={span(uz):.4f} y_span={span(uy):.4f} source_z={z_span:.4f}", + file=sys.stderr, + ) + return 7, unity_objs, godot_objs + + godot_lying = ( + near(span(gy), z_span) + and near(span(gz), y_span) + and span(gy) > span(gz) + SPAN_GAP + ) + orientations_differ = abs(span(uz) - span(gz)) > SPAN_GAP and abs( + span(uy) - span(gy) + ) > SPAN_GAP + + if same_axis: + if orientations_differ: + print( + "ERROR: --same-axis did not collapse the axis difference", + file=sys.stderr, + ) + return 11, unity_objs, godot_objs + print("ERROR: orientations did not differ", file=sys.stderr) + return 9, unity_objs, godot_objs + + if not godot_lying: + print( + f"ERROR: Godot reimport is not lying along Y " + f"y_span={span(gy):.4f} z_span={span(gz):.4f} source_z={z_span:.4f}", + file=sys.stderr, + ) + return 8, unity_objs, godot_objs + + expected_godot_tip = Vector((TIP.x, -TIP.z, TIP.y)) + godot_tip_err = min((p - expected_godot_tip).length for p in g_pts) + unity_tip_err = min((p - TIP).length for p in u_pts) + print(f"unity_tip_err={unity_tip_err:.3e} godot_tip_err={godot_tip_err:.3e}") + if unity_tip_err > 5e-4 or godot_tip_err > 5e-4: + print( + f"ERROR: reimported tip mismatch unity={unity_tip_err:.3e} " + f"godot={godot_tip_err:.3e} expected_godot={tuple(expected_godot_tip)}", + file=sys.stderr, + ) + return 8, unity_objs, godot_objs + + if not orientations_differ: + print( + f"ERROR: reimported orientations did not differ " + f"unity_z={span(uz):.4f} godot_z={span(gz):.4f}", + file=sys.stderr, + ) + return 9, unity_objs, godot_objs + + return 0, unity_objs, godot_objs + + +def sit_on_floor(objs, x, y): + bpy.context.view_layer.update() + pts = all_world_points(objs) + min_x = min(p.x for p in pts) + max_x = max(p.x for p in pts) + min_y = min(p.y for p in pts) + max_y = max(p.y for p in pts) + min_z = min(p.z for p in pts) + dx = x - 0.5 * (min_x + max_x) + dy = y - 0.5 * (min_y + max_y) + dz = -min_z + for obj in objs: + obj.location.x += dx + obj.location.y += dy + obj.location.z += dz + bpy.context.view_layer.update() + + +def light(scene, name, loc, energy, size, col, rot): + ld = bpy.data.lights.new(name, "AREA") + ld.energy = energy + ld.size = size + ld.color = col + ob = bpy.data.objects.new(name, ld) + ob.location = loc + ob.rotation_euler = tuple(math.radians(a) for a in rot) + scene.collection.objects.link(ob) + + +def render_still(source, unity_objs, godot_objs, path, engine): + scene = bpy.context.scene + source.hide_render = True + source.hide_viewport = True + sit_on_floor(unity_objs, -2.15, 0.0) + sit_on_floor(godot_objs, 1.95, 0.0) + + floor_me = bpy.data.meshes.new("Floor") + bm = bmesh.new() + try: + bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=30.0) + bm.to_mesh(floor_me) + finally: + bm.free() + floor_me.materials.append(principled("Studio", (0.03, 0.032, 0.037, 1.0), 0.0, 0.7)) + floor = bpy.data.objects.new("Floor", floor_me) + scene.collection.objects.link(floor) + wall = bpy.data.objects.new("Wall", floor_me.copy()) + wall.data.materials.clear() + wall.data.materials.append(principled("Wall", (0.03, 0.032, 0.037, 1.0), 0.0, 0.7)) + wall.location = (0.0, 9.0, 0.0) + wall.rotation_euler = (math.pi / 2, 0.0, 0.0) + scene.collection.objects.link(wall) + + world = bpy.data.worlds.new("World") + world.use_nodes = True + world.node_tree.nodes["Background"].inputs["Color"].default_value = ( + 0.02, + 0.021, + 0.025, + 1.0, + ) + scene.world = world + + light(scene, "Key", (-4.0, -5.0, 6.0), 650.0, 5.0, (1.0, 0.96, 0.9), (46, 0, -35)) + light(scene, "Fill", (5.0, -3.5, 3.0), 120.0, 9.0, (0.75, 0.85, 1.0), (62, 0, 50)) + light(scene, "Wedge", (2.5, 5.5, 4.0), 380.0, 6.0, (1.0, 0.76, 0.5), (-68, 0, 190)) + + cam_data = bpy.data.cameras.new("Cam") + cam_data.lens = 50.0 + cam = bpy.data.objects.new("Cam", cam_data) + cam.location = (3.12, -8.15, 2.45) + scene.collection.objects.link(cam) + aim = bpy.data.objects.new("Aim", None) + aim.location = (0.0, 0.0, 0.85) + scene.collection.objects.link(aim) + con = cam.constraints.new("TRACK_TO") + con.target = aim + con.track_axis = "TRACK_NEGATIVE_Z" + con.up_axis = "UP_Y" + scene.camera = cam + + scene.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id() + if engine == "cycles": + scene.cycles.samples = 32 + else: + try: + scene.eevee.taa_render_samples = 64 + except AttributeError: + pass + scene.render.resolution_x = 1280 + scene.render.resolution_y = 720 + scene.render.image_settings.file_format = "PNG" + scene.render.filepath = path + scene.view_settings.view_transform = "Standard" + + fcode = gallery_framing.check_framing( + scene, + cam, + hero=unity_objs + godot_objs, + elements=unity_objs + godot_objs, + stage=[floor, wall], + ) + if fcode: + return fcode + bpy.ops.render.render(write_still=True) + if not (os.path.exists(path) and os.path.getsize(path) > 0): + print("ERROR: render produced no file", file=sys.stderr) + return 6 + return 0 + + +def main(): + argv = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else [] + p = argparse.ArgumentParser() + p.add_argument("--output", default=None, help="optional: render a still PNG here") + p.add_argument( + "--engine", + default="eevee", + choices=("eevee", "cycles"), + help="render engine for --output", + ) + p.add_argument( + "--same-axis", + action="store_true", + help="export both presets with export_yup=True (must fail)", + ) + args = p.parse_args(argv) + + src = build() + code, unity_objs, godot_objs = check(src, args.same_axis) + if code: + return code + + if args.output: + rcode = render_still( + src, unity_objs, godot_objs, os.path.abspath(args.output), args.engine + ) + if rcode: + return rcode + print(f"rendered still {args.output}") + + print("export-preset-axis OK") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception as e: + import traceback + + traceback.print_exc() + print(f"FATAL: {e}", file=sys.stderr) + sys.exit(1) diff --git a/examples/export-preset-axis/preview.webp b/examples/export-preset-axis/preview.webp new file mode 100644 index 0000000000000000000000000000000000000000..f38c681d3252076bca9b6ac3b6e9bdb9e619cb05 GIT binary patch literal 7780 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 literal 0 HcmV?d00001 diff --git a/examples/gallery.json b/examples/gallery.json index a567299..4481790 100644 --- a/examples/gallery.json +++ b/examples/gallery.json @@ -313,6 +313,17 @@ "uv" ] }, + { + "name": "export-preset-axis", + "dir": "examples/export-preset-axis", + "teaches": "A radio beacon exported under Unity and Godot glTF presets and re-imported, proving the two files have different vertex orientation", + "witnessesFix": "Unity export_yup=True round-trips standing (x,y,z); Godot export_yup=False reimports lying along -Y as (x,-z,y); --same-axis exits 9; exporter RNA is guarded so a future kwarg rename fails loudly", + "hero": "docs/gallery/assets/export-preset-axis-hero.webp", + "preview": "examples/export-preset-axis/preview.webp", + "tags": [ + "export" + ] + }, { "name": "lod-decimate-chain", "dir": "examples/lod-decimate-chain", diff --git a/tests/smoke/catalog.json b/tests/smoke/catalog.json index dd90dad..16054b0 100644 --- a/tests/smoke/catalog.json +++ b/tests/smoke/catalog.json @@ -35,6 +35,7 @@ "args": ["--check-pixels", "--engine", "cycles"] }, {"name": "gltf-export-roundtrip", "script": "examples/gltf-export-roundtrip/gltf_export_roundtrip.py"}, + {"name": "export-preset-axis", "script": "examples/export-preset-axis/export_preset_axis.py"}, {"name": "lod-decimate-chain", "script": "examples/lod-decimate-chain/lod_decimate_chain.py"}, {"name": "vertex-weight-limit", "script": "examples/vertex-weight-limit/vertex_weight_limit.py"}, {"name": "triangulate-tangents", "script": "examples/triangulate-tangents/triangulate_tangents.py"},
+Export preset axis: a radio beacon exported under Unity and Godot glTF presets and re-imported side by side on a dark studio floor - Unity standing with a glowing cap, Godot lying on its base - proving the two files have different vertex orientation + + +### [export-preset-axis](examples/export-preset-axis/) + +The same beacon mesh under the Unity (`export_yup=True`) and Godot +(`export_yup=False`) glTF presets. Re-importing each file proves the axis +conversion: Unity stands, Godot lies along `-Y`. `--same-axis` exports both +Y-up and the differ check exits 9. Neighbor of +[`gltf-export-roundtrip`](examples/gltf-export-roundtrip/). +