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 0000000..842f6f5 Binary files /dev/null and b/docs/gallery/assets/export-preset-axis-hero.webp differ diff --git a/docs/gallery/contact-sheets/export-preset-axis-contact-sheet.webp b/docs/gallery/contact-sheets/export-preset-axis-contact-sheet.webp new file mode 100644 index 0000000..2e10ee3 Binary files /dev/null and b/docs/gallery/contact-sheets/export-preset-axis-contact-sheet.webp differ 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 0000000..f38c681 Binary files /dev/null and b/examples/export-preset-axis/preview.webp differ 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/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/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}") 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 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/). +