@@ -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.
+
+
+
+
+
+
+
+
+### [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/).
+
@@ -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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Skip to content
+
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 = 1if poly.center.z > CAP_Z else0
+ 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 notin before and o.type == "MESH"]
+ return added orNone
+
+
+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")
+ ifnot rot:
+ returnFalse
+ 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 notin exp_props]
+ if missing:
+ print(f"ERROR: exporter RNA drifted, missing {missing}", file=sys.stderr)
+ return2, 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}"
+ )
+ ifnot (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,
+ )
+ return3, 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)
+ return3, 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.
+ ifnot (
+ 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,
+ )
+ return5, None, None
+ if node_has_rotation(u_node):
+ print(f"ERROR: Unity node has rotation {u_node.get('rotation')}", file=sys.stderr)
+ return5, None, None
+
+ ifnot same_axis:
+ ifnot (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,
+ )
+ return6, None, None
+
+ unity_objs = import_gltf_meshes(unity_path)
+ godot_objs = import_gltf_meshes(godot_path)
+ if unity_objs isNoneor godot_objs isNone:
+ print("ERROR: expected a mesh per glTF import", file=sys.stderr)
+ return4, 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}"
+ )
+
+ ifnot (
+ 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,
+ )
+ return7, 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,
+ )
+ return11, unity_objs, godot_objs
+ print("ERROR: orientations did not differ", file=sys.stderr)
+ return9, unity_objs, godot_objs
+
+ ifnot 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,
+ )
+ return8, 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-4or 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,
+ )
+ return8, unity_objs, godot_objs
+
+ ifnot orientations_differ:
+ print(
+ f"ERROR: reimported orientations did not differ "
+ f"unity_z={span(uz):.4f} godot_z={span(gz):.4f}",
+ file=sys.stderr,
+ )
+ return9, unity_objs, godot_objs
+
+ return0, 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)
+ ifnot (os.path.exists(path) and os.path.getsize(path) > 0):
+ print("ERROR: render produced no file", file=sys.stderr)
+ return6
+ return0
+
+
+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")
+ return0
+
+
+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)
+