From b73294b2c87397883a161795cb20ed1733346281 Mon Sep 17 00:00:00 2001 From: fOuttaMyPaint Date: Sun, 19 Jul 2026 15:47:47 -0400 Subject: [PATCH] feat: add gltf-export-roundtrip example witnessing glTF interchange contracts The game-engine interchange path is where AI-generated Blender code drifts silently: the +Y-up convention, modifier application at export, UV V-flips. This example pins them: a supply crate exported GLTF_SEPARATE and re-imported, checked against the depsgraph-evaluated mesh (positions, normals, UVs, per-triangle material bindings), with the on-disk JSON/buffer parsed directly for the axis-conversion and V-flip witnesses. Probed during authoring on 4.5.11 and 5.1.2: exporter/importer RNA is byte-identical, so the example guards the kwarg set explicitly instead of papering over a divergence that does not exist. Mesh.calc_normals() being removed on 5.x surfaced as a real authoring hazard and is pinned in code. Signed-off-by: fOuttaMyPaint --- .cursor-plugin/plugin.json | 1 + .github/workflows/blender-smoke.yml | 13 + README.md | 30 +- ROADMAP.md | 1 + .../assets/gltf-export-roundtrip-hero.webp | Bin 0 -> 12720 bytes .../gltf-export-roundtrip-contact-sheet.webp | Bin 0 -> 38032 bytes docs/gallery/gltf-export-roundtrip/index.html | 849 ++++++++++++++++++ docs/gallery/index.html | 11 + examples/gallery.json | 12 + examples/gltf-export-roundtrip/README.md | 64 ++ .../gltf_export_roundtrip.py | 603 +++++++++++++ examples/gltf-export-roundtrip/preview.webp | Bin 0 -> 11362 bytes 12 files changed, 1582 insertions(+), 2 deletions(-) create mode 100644 docs/gallery/assets/gltf-export-roundtrip-hero.webp create mode 100644 docs/gallery/contact-sheets/gltf-export-roundtrip-contact-sheet.webp create mode 100644 docs/gallery/gltf-export-roundtrip/index.html create mode 100644 examples/gltf-export-roundtrip/README.md create mode 100644 examples/gltf-export-roundtrip/gltf_export_roundtrip.py create mode 100644 examples/gltf-export-roundtrip/preview.webp diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 6361074..3ea0013 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -67,6 +67,7 @@ "examples/damped-track-aim", "examples/depsgraph-export", "examples/driver-wave", + "examples/gltf-export-roundtrip", "examples/gn-instance-grid", "examples/gn-sdf-remesh", "examples/grease-pencil-rosette", diff --git a/.github/workflows/blender-smoke.yml b/.github/workflows/blender-smoke.yml index b202638..5499cea 100644 --- a/.github/workflows/blender-smoke.yml +++ b/.github/workflows/blender-smoke.yml @@ -369,3 +369,16 @@ jobs: xvfb-run -a "$BLENDER" --background \ --python examples/vse-cut-list/vse_cut_list.py -- \ --check-pixels --engine cycles + + - name: Shipped example - glTF export round-trip (+Y-up, applied modifiers, UVs) + run: | + set -euo pipefail + # Check only (no render): a 35-part supply crate exported GLTF_SEPARATE + # and re-imported; asserts the exporter/importer kwarg set still exists + # (RNA guard), the on-disk +Y-up conversion ((x,y,z)->(x,z,-y) baked + # into POSITION bounds, no node rotation), disk vertex count == + # evaluated loop count (export_apply), V-flipped TEXCOORD_0, and the + # full round-trip of positions/normals/UVs/per-triangle material + # bindings within float tolerances. Exits non-zero on failure. + xvfb-run -a "$BLENDER" --background \ + --python examples/gltf-export-roundtrip/gltf_export_roundtrip.py -- diff --git a/README.md b/README.md index aff9516..9e2322f 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@

- 12 skills  •  6 rules  •  2 templates  •  17 snippets  •  23 examples + 12 skills  •  6 rules  •  2 templates  •  17 snippets  •  24 examples

@@ -36,7 +36,7 @@ ## Overview -This repository ships **12 skills, 6 rules, 2 templates, 17 snippets, and 22 runnable examples** for Blender Python development targeting Blender 5.1 (current stable) with Blender 4.5 LTS fallback support. +This repository ships **12 skills, 6 rules, 2 templates, 17 snippets, and 24 runnable examples** for Blender Python development targeting Blender 5.1 (current stable) with Blender 4.5 LTS fallback support. 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. @@ -465,6 +465,32 @@ round-trips through the raw `POINT` buffer. +

+Game asset pipeline — 1 example + + + + + + +
+glTF export round-trip: two identical olive-drab sci-fi supply crates on a dark studio floor - authored beside re-imported - with teal status strips, riveted panels, and corner armor, proving the export/import round-trip preserves the asset + + +### [gltf-export-roundtrip](examples/gltf-export-roundtrip/) + +A game-prop supply crate round-tripped through `bpy.ops.export_scene.gltf` and +the importer — the check reads the `.gltf` JSON and `.bin` buffer directly. +Witnesses the +Y-up convention baked into vertex data with no node rotation, +`export_apply` shipping the evaluated mesh (one disk vertex per evaluated +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. + +
+ +
+ ## How content is organized ``` diff --git a/ROADMAP.md b/ROADMAP.md index b76e808..635437a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -103,6 +103,7 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo - Attribute domain witness: writing a POINT-domain color attribute and reading it as if it were CORNER (or vice versa) silently shears colors across shared verts — companion to `color-attribute-wheel` - Light-linking collection witness: object light linking / receiver collections so a key illuminates only the hero (common AI miss when staging multi-light studios) - ~~VSE sequences-to-strips witness~~ **SHIPPED** as `examples/vse-cut-list/` — `.sequences` removed on 5.x (4.5 bridges to `.strips`), `new_effect` end kwarg `frame_end=` (4.5) vs `length=` (5.x), `frame_final_*` deprecated in favor of `left_handle`/`right_handle`/`duration`, TRANSFORM effect type removed, GAMMA_CROSS clamps to the source overlap, effect strips consume inputs only when stacked above them, same-scene scene strips render transparent; save/reload round-trip + tiny-render pixel witness +- ~~glTF export round-trip witness~~ **SHIPPED** as `examples/gltf-export-roundtrip/` — `export_yup` bakes `(x,y,z)→(x,z,−y)` into vertex data with no node rotation (probed identical on 4.5.11 and 5.1.2), `export_apply` ships the evaluated mesh (one disk vertex per evaluated loop), TEXCOORD_0 is V-flipped on disk, per-triangle material bindings survive; exporter/importer RNA signatures byte-identical between 4.5.11 and 5.1.2 (guarded against future renames); `Mesh.calc_normals()` removal on 5.x surfaced during authoring - GAMMA_CROSS blend-curve witness: the cross blend is not the naive linear mix (mid-cross measured (0.341, 0.349, 0.463) from crimson (0.85, 0.10, 0.22) and teal (0.06, 0.75, 0.80)) — derive and assert the gamma-space closed form per frame; follow-up to `vse-cut-list` - Falsy `bpy_prop_collection` trap snippet: an empty collection is falsy, so `editor.strips or editor.sequences` silently falls through to the legacy accessor on an empty timeline — always branch on `hasattr`; likely generalizes across the API (found authoring `vse-cut-list`) diff --git a/docs/gallery/assets/gltf-export-roundtrip-hero.webp b/docs/gallery/assets/gltf-export-roundtrip-hero.webp new file mode 100644 index 0000000000000000000000000000000000000000..e1502f0a6e6d698e9a25d6a0d102be297b9c11b8 GIT binary patch literal 12720 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 literal 0 HcmV?d00001 diff --git a/docs/gallery/gltf-export-roundtrip/index.html b/docs/gallery/gltf-export-roundtrip/index.html new file mode 100644 index 0000000..08bbdd0 --- /dev/null +++ b/docs/gallery/gltf-export-roundtrip/index.html @@ -0,0 +1,849 @@ + + + + + + gltf-export-roundtrip — Examples — Blender Developer Tools + + + + + + + + + + + + + + + + + + +
+

gltf-export-roundtrip

+

A sci-fi supply crate exported to glTF and re-imported, verifying the round-trip against the depsgraph-evaluated mesh within float tolerances. Positions, loop normals, box-mapped UVs, and per-triangle material bindings must all survive; the on-disk JSON proves the +Y-up conversion and the V-flipped UV layout.

+
+
+ +

Rendered headless by the example itself — click to zoom.

+
witnesses export_yup bakes (x, y, z) -> (x, z, -y) into the vertex data with no node rotation (probed identical on 4.5.11 and 5.1.2); export_apply ships the evaluated mesh, not the base cage; exporter/importer RNA is guarded so a future kwarg rename fails loudly.
+
+
blender --background --python examples/gltf-export-roundtrip/gltf_export_roundtrip.py --
+ +
+
+

A runnable example that builds a sci-fi supply crate — 35 beveled box shells with three material slots and box-mapped UVs — exports it with bpy.ops.export_scene.gltf, parses the file on disk, re-imports it, and verifies the whole round-trip against the depsgraph-evaluated mesh, following depsgraph-and-evaluated-data and headless-batch-scripting.

+

What it witnesses: the interchange contracts AI-generated export code most often gets silently wrong.

+

1. The +Y-up convention is baked into vertex data. glTF is +Y-up, Blender is +Z-up, and export_yup=True (the default) writes (x, y, z) -> (x, z, -y) directly into the POSITION buffer — the node carries no rotation or scale. The check parses the .gltf JSON and asserts the accessor bounds equal the axis-converted evaluated bounding box, and that the node transform is absent. Exporting with export_yup=False ships raw Z-up data every engine displays lying on its back. 2. export_apply=True ships the evaluated mesh, not the base cage. The crate's bevel modifier lives only in the depsgraph; with flat shading and UV seams the exporter splits exactly one vertex per evaluated loop (7,560), so the on-disk POSITION count is an exact witness. export_apply=False silently writes the 624-vertex cage. 3. The round-trip is faithful. Re-imported positions (bit-exact here), loop normals (≤2e-4), box-mapped UVs (≤3e-5), and per-triangle material bindings all match the evaluated mesh. UVs are V-flipped on disk (glTF texture origin is top-left) and flipped back on import — both flips are proven by reading the .bin buffer directly.

+

Version witness (probed on Blender 4.5.11 LTS and 5.1.2): the operator signatures are byte-identical — 109 exporter properties, 20 importer properties, same defaults — and the exported JSON differs only in asset.generator ("Khronos glTF Blender I/O v4.5.51" vs "v5.1.20"). The example therefore runs identical kwargs on both versions and guards forward drift explicitly: every kwarg it passes must still exist in the operator's RNA, so a future rename fails loudly instead of drifting silently. One genuine 5.x removal surfaced during authoring: Mesh.calc_normals() is gone (loop normals auto-compute on read) — calling it is itself a cross-version hazard, noted in the code.

+

Two more authoring hazards are pinned in comments: exact face-plane coincidences between kit-bashed shells weld loops on export (the count check catches it), and read_factory_settings mid-check frees the original mesh — touching a freed RNA raises ReferenceError, so counts are captured before the wipe.

+

The render stages the authored crate beside the actual re-imported one — same bevels, same materials carried through the file itself. If the axis conversion broke, the right twin would lie on its side; if the modifier contract broke, its silhouette would lose the rounded edges.

+

Run

+
# Cheap correctness check (no render) — the CI check:
+blender --background --python gltf_export_roundtrip.py --
+
+# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
+blender --background --python gltf_export_roundtrip.py -- --output crate.png
+blender --background --python gltf_export_roundtrip.py -- --output crate.png --engine cycles
+

It exits non-zero on failure (RNA kwarg drift, cage drift, missing on-disk conversion, vertex-split drift, or any round-trip excursion beyond tolerance). The blender-smoke workflow runs the check on Blender 4.5 LTS and 5.1.

+
+
+

Source

+
+ examples/gltf-export-roundtrip/gltf_export_roundtrip.py + View on GitHub → +
+
"""A sci-fi supply crate round-tripped through glTF export/import — a runnable example.
+
+Witnesses the contracts of the game-asset interchange path that AI-generated
+code most often gets silently wrong:
+
+1. The +Y-up convention. glTF is +Y-up, Blender is +Z-up, and
+   ``export_yup=True`` (the default) bakes the conversion into the vertex
+   data itself — (x, y, z) -> (x, z, -y) on disk — with no node rotation.
+   The check parses the exported .gltf JSON and asserts the POSITION accessor
+   bounds equal the axis-converted evaluated bounding box, and that the node
+   carries neither rotation nor scale. Exporting with ``export_yup=False``
+   writes raw Z-up data that every engine will display lying on its back.
+2. Modifiers ship evaluated geometry. ``export_apply=True`` applies the
+   crate's bevel modifier: the re-imported mesh matches the
+   depsgraph-evaluated mesh, not the base cage. With ``export_apply=False``
+   the file silently contains the low-poly cage.
+3. The round-trip is faithful. Positions, loop normals, UVs, and material
+   bindings of the re-imported mesh match the evaluated mesh within float
+   tolerances. UVs are V-flipped on disk (glTF texture origin is top-left)
+   and flipped back on import — the check proves both flips, reading the
+   .bin buffer directly.
+
+Version witness (probed during authoring on Blender 4.5.11 LTS and 5.1.2):
+the operator signatures are byte-identical — 109 exporter properties, 20
+importer properties, same defaults — and the exported JSON differs only in
+``asset.generator`` ("Khronos glTF Blender I/O v4.5.51" vs "v5.1.20"). The
+example therefore runs identical kwargs on both versions and guards forward
+drift explicitly: every kwarg it passes must still exist in the operator's
+RNA (a rename fails the check loudly instead of drifting silently).
+
+By default it runs only the correctness check (no render) — the CI smoke
+check. Pass --output to also render a still:
+
+    blender --background --python gltf_export_roundtrip.py --                 # check only
+    blender --background --python gltf_export_roundtrip.py -- --output c.png  # + render
+"""
+import bpy, bmesh, sys, os, math, json, struct, shutil, tempfile, argparse
+import mathutils
+
+# ---------------------------------------------------------------------------
+# Crate specification. Every part is an axis-aligned box, so every base-cage
+# vertex is a closed form: center +/- size/2. The parts interpenetrate on
+# purpose — kit-bashed shells are how real game props are assembled — and the
+# bevel modifier rounds each shell independently.
+# ---------------------------------------------------------------------------
+PAINT, TRIM, GLOW = 0, 1, 2
+BEVEL_WIDTH = 0.03
+BEVEL_SEGMENTS = 2
+
+# (name, center, size, material) — 35 shells. Geometry rules that keep the
+# exporter's seam-splitting exact: no two shells share a face plane (exact
+# float coincidences weld loops on export), interpenetration is fine, and the
+# bevel width stays under half of every box dimension.
+PARTS = [
+    ("body",        (0.0, 0.0, 0.73),       (2.00, 1.40, 1.10), PAINT),
+    ("lid",         (0.0, 0.0, 1.42),       (2.14, 1.54, 0.26), PAINT),
+    ("lid_plate",   (0.0, 0.0, 1.57),       (0.90, 0.50, 0.08), TRIM),
+    ("panel_front", (0.0, -0.725, 0.75),    (1.50, 0.08, 0.70), PAINT),
+    ("panel_back",  (0.0, 0.725, 0.75),     (1.50, 0.08, 0.70), PAINT),
+    ("panel_left",  (-1.025, 0.0, 0.75),    (0.08, 1.00, 0.70), PAINT),
+    ("panel_right", (1.025, 0.0, 0.75),     (0.08, 1.00, 0.70), PAINT),
+    ("strip",       (0.0, -0.78, 1.05),     (1.60, 0.10, 0.12), GLOW),
+]
+PARTS += [("foot", (sx * 0.85, sy * 0.55, 0.09), (0.30, 0.30, 0.24), TRIM)
+          for sx in (-1, 1) for sy in (-1, 1)]
+PARTS += [("cornerA", (sx * 1.045, sy * 0.63, 0.73), (0.08, 0.24, 1.16), TRIM)
+          for sx in (-1, 1) for sy in (-1, 1)]
+PARTS += [("cornerB", (sx * 0.92, sy * 0.70, 0.73), (0.24, 0.08, 1.16), TRIM)
+          for sx in (-1, 1) for sy in (-1, 1)]
+PARTS += [("handle_post", (sx * 1.06, sy * 0.25, 1.00), (0.10, 0.08, 0.30), TRIM)
+          for sx in (-1, 1) for sy in (-1, 1)]
+PARTS += [("handle_bar", (sx * 1.06, 0.0, 1.13), (0.10, 0.60, 0.08), TRIM)
+          for sx in (-1, 1)]
+PARTS += [("latch", (sx * 0.55, -0.74, 1.38), (0.10, 0.08, 0.14), TRIM)
+          for sx in (-1, 1)]
+# rivets frame the front panel's visible band below the glow strip
+PARTS += [("rivet", (sx * 0.66, -0.78, sz), (0.08, 0.08, 0.08), TRIM)
+          for sx in (-1, 1) for sz in (0.47, 0.90)]
+# three status pips on the lid plate share the strip's emissive language
+PARTS += [("pip", (sx * 0.18, 0.0, 1.60), (0.08, 0.08, 0.08), GLOW)
+          for sx in (-1, 0, 1)]
+
+UV_SCALE = 0.4          # box-map scale; authored UVs are a closed form of position
+EXPORT_KWARGS = dict(
+    export_format='GLTF_SEPARATE',   # parseable .gltf JSON + .bin buffer
+    export_apply=True,               # contract 2: ship the evaluated (beveled) mesh
+    export_yup=True,                 # contract 1: bake the +Y-up conversion
+    export_texcoords=True,
+    export_normals=True,
+    export_materials='EXPORT',
+    export_animations=False,
+    export_image_format='NONE',      # geometry contract only; no textures to pack
+)
+
+POS_TOL = 2e-5          # float32 on disk vs float32 in Blender, plus axis math
+NRM_TOL = 2e-4
+UV_TOL = 3e-5
+UNIT_TOL = 1e-3         # |length(n) - 1| for re-imported loop normals
+KEY_DIGITS = 4          # position-key grid for attribute lookups
+
+
+def box_corners(center, size):
+    cx, cy, cz = center
+    hx, hy, hz = (s / 2 for s in size)
+    return [(cx + sx * hx, cy + sy * hy, cz + sz * hz)
+            for sx in (-1, 1) for sy in (-1, 1) for sz in (-1, 1)]
+
+
+def boxmap_uv(co, normal):
+    """Closed-form UV for a loop: project onto the face's dominant axis plane."""
+    ax = max(range(3), key=lambda i: abs(normal[i]))
+    if ax == 2:
+        return (0.5 + UV_SCALE * co.x, 0.5 + UV_SCALE * co.y)
+    if ax == 0:
+        return (0.5 + UV_SCALE * co.z, 0.5 + UV_SCALE * co.y)
+    return (0.5 + UV_SCALE * co.x, 0.5 + UV_SCALE * co.z)
+
+
+def build_crate():
+    """The supply crate: 35 beveled box shells, 3 material slots, box-mapped UVs."""
+    me = bpy.data.meshes.new("SupplyCrate")
+    bm = bmesh.new()
+    try:
+        for _name, center, size, mat in PARTS:
+            n0 = len(bm.faces)
+            m = (mathutils.Matrix.Translation(center)
+                 @ mathutils.Matrix.Diagonal((*size, 1.0)))
+            bmesh.ops.create_cube(bm, size=1.0, matrix=m)
+            for f in bm.faces[n0:]:
+                f.material_index = mat
+        bm.to_mesh(me)
+    finally:
+        bm.free()  # the ownership contract, as always
+    for poly in me.polygons:
+        poly.use_smooth = False  # crisp low-poly facets; normals are unambiguous
+    uv = me.uv_layers.new(name="UVMap")
+    for poly in me.polygons:
+        for li in poly.loop_indices:
+            loop = me.loops[li]
+            uv.data[li].uv = boxmap_uv(me.vertices[loop.vertex_index].co, poly.normal)
+    obj = bpy.data.objects.new("SupplyCrate", me)
+    bpy.context.collection.objects.link(obj)
+    mod = obj.modifiers.new("EdgeRounding", 'BEVEL')
+    mod.width = BEVEL_WIDTH
+    mod.segments = BEVEL_SEGMENTS
+    return obj
+
+
+def make_materials():
+    """Authored PBR set: olive drab paint, gunmetal trim, teal status glow."""
+    def pbr(name, base, metallic, roughness, emission=None, strength=0.0):
+        mat = bpy.data.materials.new(name)
+        mat.use_nodes = True
+        b = mat.node_tree.nodes["Principled BSDF"]
+        b.inputs["Base Color"].default_value = (*base, 1.0)
+        b.inputs["Metallic"].default_value = metallic
+        b.inputs["Roughness"].default_value = roughness
+        if emission is not None:
+            sock = b.inputs.get("Emission Color") or b.inputs["Emission"]
+            sock.default_value = (*emission, 1.0)
+            b.inputs["Emission Strength"].default_value = strength
+        return mat
+    return [
+        pbr("Paint", (0.16, 0.18, 0.12), 0.15, 0.50),
+        pbr("Trim", (0.12, 0.13, 0.15), 0.90, 0.35),
+        pbr("Glow", (0.02, 0.20, 0.25), 0.0, 0.40,
+            emission=(0.10, 0.75, 0.85), strength=3.0),
+    ]
+
+
+# ---------------------------------------------------------------------------
+# Evaluated-mesh capture: the independent reference the file must carry.
+# ---------------------------------------------------------------------------
+def capture_evaluated(obj):
+    """Snapshot the depsgraph-evaluated mesh, then release it (lifetime contract)."""
+    deps = bpy.context.evaluated_depsgraph_get()
+    ob_eval = obj.evaluated_get(deps)
+    me = ob_eval.to_mesh()
+    try:
+        # loop normals auto-compute on read; Mesh.calc_normals() was removed in
+        # 5.0 (deprecated 4.x) — calling it is itself a cross-version hazard
+        me.calc_loop_triangles()
+        uv = me.uv_layers.active.data
+        return {
+            "verts": [tuple(v.co) for v in me.vertices],
+            "loops": [tuple(me.vertices[l.vertex_index].co) for l in me.loops],
+            "normals": [tuple(l.normal) for l in me.loops],
+            "uvs": [tuple(uv[i].uv) for i in range(len(me.loops))],
+            "tris": [(t.material_index, tuple(t.center))
+                     for t in me.loop_triangles],
+        }
+    finally:
+        ob_eval.to_mesh_clear()  # the reference dies here, by contract
+
+
+def key_of(p, digits=KEY_DIGITS):
+    return tuple(round(c, digits) for c in p)
+
+
+def set_map(positions, values):
+    """position key -> set of attribute values (split loops share positions)."""
+    m = {}
+    for p, v in zip(positions, values):
+        m.setdefault(key_of(p), set()).add(tuple(round(c, 6) for c in v))
+    return m
+
+
+def candidates(m, p):
+    """Union of the set at p's key and its 26 neighbor keys.
+
+    Values that differ by less than the key grid can straddle a rounding
+    boundary; probing the neighboring cells makes the lookup straddle-safe.
+    """
+    step = 10 ** -KEY_DIGITS
+    k0 = key_of(p)
+    out = set()
+    for dx in (-step, 0.0, step):
+        for dy in (-step, 0.0, step):
+            for dz in (-step, 0.0, step):
+                out |= m.get((k0[0] + dx, k0[1] + dy, k0[2] + dz), set())
+    return out
+
+
+# ---------------------------------------------------------------------------
+# glTF JSON + buffer reading (GLTF_SEPARATE layout).
+# ---------------------------------------------------------------------------
+def read_gltf(path):
+    g = json.load(open(path))
+    blob = open(os.path.join(os.path.dirname(path),
+                             g["buffers"][0]["uri"]), "rb").read()
+
+    def accessor_floats(idx, ncomp):
+        acc = g["accessors"][idx]
+        bv = g["bufferViews"][acc["bufferView"]]
+        off = bv.get("byteOffset", 0) + acc.get("byteOffset", 0)
+        stride = bv.get("byteStride", 4 * ncomp)
+        return [struct.unpack_from(f"<{ncomp}f", blob, off + stride * i)
+                for i in range(acc["count"])]
+
+    return g, accessor_floats
+
+
+# ---------------------------------------------------------------------------
+# The check. Distinct exit codes per contract; measured maxima printed on success.
+# ---------------------------------------------------------------------------
+def check(crate):
+    # contract 0 (version guard): every kwarg we rely on still exists.
+    exp_props = {p.identifier for p in bpy.ops.export_scene.gltf.get_rna_type().properties}
+    imp_props = {p.identifier for p in bpy.ops.import_scene.gltf.get_rna_type().properties}
+    missing = [k for k in EXPORT_KWARGS if k not in exp_props]
+    if missing or "filepath" not in exp_props or "filepath" not in imp_props:
+        print(f"ERROR: exporter/importer RNA drifted, missing kwargs {missing}"
+              "the API contract this example pins has changed", file=sys.stderr)
+        return 3
+
+    me = crate.data
+    # base cage is exactly the authored closed form: 35 boxes x 8 corners
+    expect = sorted(key_of(c) for _n, ce, s, _m in PARTS for c in box_corners(ce, s))
+    got = sorted(key_of(v.co) for v in me.vertices)
+    if got != expect:
+        print(f"ERROR: base cage drifted from its closed form "
+              f"({len(got)} verts vs {len(expect)} expected)", file=sys.stderr)
+        return 4
+
+    # authored UVs are the box-map closed form of position + face normal
+    uv0 = me.uv_layers["UVMap"].data
+    uv_err = max((uv0[li].uv - mathutils.Vector(boxmap_uv(
+        me.vertices[me.loops[li].vertex_index].co, p.normal))).length
+        for p in me.polygons for li in p.loop_indices)
+    if uv_err > 1e-6:
+        print(f"ERROR: authored UV drift {uv_err:.3e} from box-map closed form",
+              file=sys.stderr)
+        return 5
+
+    snap = capture_evaluated(crate)
+    if not (len(snap["verts"]) > len(me.vertices) and len(snap["loops"]) > len(me.loops)):
+        print("ERROR: bevel modifier produced no evaluated geometry", file=sys.stderr)
+        return 6
+    # the export/import cycle below wipes the file — read_factory_settings
+    # frees this Mesh; capture the count now (touching a freed RNA raises
+    # ReferenceError, the classic dangling-reference hazard)
+    base_verts = len(me.vertices)
+
+    tmp = tempfile.mkdtemp(prefix="gltf_roundtrip_")
+    try:
+        path = os.path.join(tmp, "crate.gltf").replace("\\", "/")
+        bpy.ops.export_scene.gltf(filepath=path, **EXPORT_KWARGS)
+
+        # contract 1 (on disk): +Y-up is baked into vertex data, no node transform
+        g, acc_floats = read_gltf(path)
+        prims = g["meshes"][0]["primitives"]
+        node = g["nodes"][0]
+        if len(g["nodes"]) != 1 or len(g["meshes"]) != 1:
+            print("ERROR: expected exactly one node and one mesh on disk", file=sys.stderr)
+            return 7
+        if "Khronos" not in g["asset"].get("generator", ""):
+            print("ERROR: unexpected exporter generator string", file=sys.stderr)
+            return 7
+        if node.get("rotation") is not None or node.get("scale") is not None:
+            print(f"ERROR: yup conversion leaked into the node transform: {node}",
+                  file=sys.stderr)
+            return 7
+        if len(prims) != 3 or {p.get("material") for p in prims} != {0, 1, 2}:
+            print(f"ERROR: expected 3 primitives bound to materials 0..2, got "
+                  f"{[p.get('material') for p in prims]}", file=sys.stderr)
+            return 8
+        # contract 2 (on disk): the file carries the evaluated mesh, not the
+        # base cage — flat shading + UV seams split exactly one vertex per
+        # evaluated loop, so the on-disk POSITION count is the loop count
+        disk_verts = sum(g["accessors"][p["attributes"]["POSITION"]]["count"]
+                         for p in prims)
+        if disk_verts != len(snap["loops"]):
+            print(f"ERROR: on-disk POSITION count {disk_verts} != evaluated loop "
+                  f"count {len(snap['loops'])} — the file's vertex split drifted "
+                  "from the evaluated mesh (export_apply / export_normals contract)",
+                  file=sys.stderr)
+            return 10
+
+        xs = [v[0] for v in snap["verts"]]
+        ys = [v[1] for v in snap["verts"]]
+        zs = [v[2] for v in snap["verts"]]
+        # Blender (x, y, z) -> glTF (x, z, -y): the +Y-up conversion, in closed form
+        want_min = (min(xs), min(zs), -max(ys))
+        want_max = (max(xs), max(zs), -min(ys))
+        got_min = [1e30] * 3
+        got_max = [-1e30] * 3
+        for p in prims:
+            a = g["accessors"][p["attributes"]["POSITION"]]
+            got_min = [min(got_min[i], a["min"][i]) for i in range(3)]
+            got_max = [max(got_max[i], a["max"][i]) for i in range(3)]
+        bbox_err = max(abs(got_min[i] - want_min[i]) for i in range(3))
+        bbox_err = max(bbox_err, max(abs(got_max[i] - want_max[i]) for i in range(3)))
+        if bbox_err > POS_TOL:
+            print(f"ERROR: on-disk POSITION bounds deviate {bbox_err:.3e} from the "
+                  "axis-converted evaluated bbox — the +Y-up convention drifted",
+                  file=sys.stderr)
+            return 9
+
+        # contract 3a (on disk): TEXCOORD_0 is V-flipped vs Blender UV space
+        prim0 = prims[0]["attributes"]
+        disk_pos = acc_floats(prim0["POSITION"], 3)
+        disk_uv = acc_floats(prim0["TEXCOORD_0"], 2)
+        uv_set = set_map(snap["loops"], snap["uvs"])
+        flip_err = 0.0
+        for pd, (u, v) in zip(disk_pos[:32], disk_uv[:32]):
+            pb = (pd[0], -pd[2], pd[1])  # glTF (x, z, -y) -> Blender (x, y, z)
+            best = min((max(abs(u - e[0]), abs((1.0 - v) - e[1]))
+                        for e in candidates(uv_set, pb)), default=1e30)
+            flip_err = max(flip_err, best)
+        if flip_err > UV_TOL:
+            print(f"ERROR: on-disk UVs deviate {flip_err:.3e} from the V-flipped "
+                  "authored layout (glTF texture origin is top-left)", file=sys.stderr)
+            return 11
+
+        # wipe the file, then re-import: names must survive exactly
+        bpy.ops.wm.read_factory_settings(use_empty=True)
+        bpy.ops.import_scene.gltf(filepath=path)
+    finally:
+        shutil.rmtree(tmp, ignore_errors=True)
+
+    meshes = [o for o in bpy.data.objects if o.type == 'MESH']
+    if len(meshes) != 1:
+        print(f"ERROR: re-import produced {len(meshes)} mesh objects, expected 1",
+              file=sys.stderr)
+        return 12
+    ri = meshes[0]
+    ident_err = max(abs(ri.matrix_world[i][j] - (1.0 if i == j else 0.0))
+                    for i in range(4) for j in range(4))
+    if ident_err > 1e-6:
+        print(f"ERROR: re-imported object carries a transform (err {ident_err:.3e})",
+              file=sys.stderr)
+        return 13
+    names = sorted(m.name for m in ri.data.materials)
+    if names != ["Glow", "Paint", "Trim"]:
+        print(f"ERROR: material names drifted on re-import: {names}", file=sys.stderr)
+        return 14
+    if len(ri.data.vertices) != len(snap["loops"]):
+        print(f"ERROR: re-import has {len(ri.data.vertices)} verts, expected "
+              f"{len(snap['loops'])} (one per evaluated loop: flat normals + "
+              "UV seams split every loop)", file=sys.stderr)
+        return 15
+
+    # contract 3b: positions round-trip (sorted point sets; the importer
+    # re-applies the inverse axis conversion)
+    want = sorted(snap["loops"])
+    got = sorted(tuple(v.co) for v in ri.data.vertices)
+    pos_err = max(max(abs(a[k] - b[k]) for k in range(3))
+                  for a, b in zip(got, want))
+    if pos_err > POS_TOL:
+        print(f"ERROR: round-trip position drift {pos_err:.3e} (tol {POS_TOL})",
+              file=sys.stderr)
+        return 16
+
+    # contract 3c: loop normals round-trip (unit length, member of the
+    # evaluated normal set at the same position)
+    normal_set = set_map(snap["loops"], snap["normals"])
+    rme = ri.data
+    unit_err = max(abs(l.normal.length - 1.0) for l in rme.loops)
+    nrm_err = 0.0
+    for l in rme.loops:
+        p = tuple(rme.vertices[l.vertex_index].co)
+        best = min((max(abs(l.normal[k] - e[k]) for k in range(3))
+                    for e in candidates(normal_set, p)), default=1e30)
+        nrm_err = max(nrm_err, best)
+    if unit_err > UNIT_TOL or nrm_err > NRM_TOL:
+        print(f"ERROR: round-trip normal drift {nrm_err:.3e} (tol {NRM_TOL}), "
+              f"unit err {unit_err:.3e}", file=sys.stderr)
+        return 17
+
+    # contract 3d: UVs round-trip (the double V-flip returns the authored values)
+    ruv = rme.uv_layers.active.data
+    rt_uv_err = 0.0
+    for l in rme.loops:
+        p = tuple(rme.vertices[l.vertex_index].co)
+        best = min((max(abs(ruv[l.index].uv[k] - e[k]) for k in range(2))
+                    for e in candidates(uv_set, p)), default=1e30)
+        rt_uv_err = max(rt_uv_err, best)
+    if rt_uv_err > UV_TOL:
+        print(f"ERROR: round-trip UV drift {rt_uv_err:.3e} (tol {UV_TOL})",
+              file=sys.stderr)
+        return 18
+
+    # contract 3e: per-triangle material bindings round-trip (positions of the
+    # triangle centers carry the binding; straddle-safe neighbor lookup)
+    if len(rme.polygons) != len(snap["tris"]):
+        print(f"ERROR: re-import has {len(rme.polygons)} triangles, expected "
+              f"{len(snap['tris'])}", file=sys.stderr)
+        return 19
+    tri_map = {}
+    for mat, ctr in snap["tris"]:
+        tri_map.setdefault(key_of(ctr), []).append((mat, ctr))
+    bind_err = 0.0
+    for p in rme.polygons:
+        best = 1e30
+        for dx in (-1e-4, 0.0, 1e-4):
+            for dy in (-1e-4, 0.0, 1e-4):
+                for dz in (-1e-4, 0.0, 1e-4):
+                    k = (round(p.center.x + dx, KEY_DIGITS),
+                         round(p.center.y + dy, KEY_DIGITS),
+                         round(p.center.z + dz, KEY_DIGITS))
+                    for mat, ctr in tri_map.get(k, []):
+                        if mat == p.material_index:
+                            best = min(best, max(abs(p.center[i] - ctr[i])
+                                                 for i in range(3)))
+        bind_err = max(bind_err, best)
+    if bind_err > 4e-5:
+        print(f"ERROR: per-triangle material bindings drifted on round-trip "
+              f"(center err {bind_err:.3e})", file=sys.stderr)
+        return 20
+
+    print(f"verts={base_verts} eval_verts={len(snap['verts'])} "
+          f"eval_loops={len(snap['loops'])} disk_verts={disk_verts} tris={len(snap['tris'])}")
+    print(f"uv_closed_form_err={uv_err:.2e} bbox_err={bbox_err:.2e} "
+          f"flip_err={flip_err:.2e} ident_err={ident_err:.2e} "
+          f"bind_err={bind_err:.2e}")
+    print(f"roundtrip pos_err={pos_err:.2e} (tol {POS_TOL}) "
+          f"nrm_err={nrm_err:.2e} (tol {NRM_TOL}) "
+          f"uv_err={rt_uv_err:.2e} (tol {UV_TOL}) unit_err={unit_err:.2e}")
+    return 0
+
+
+def eevee_engine_id():
+    return 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT'
+
+
+def render_still(authored, roundtrip, path, engine):
+    scene = bpy.context.scene
+
+    # the authored twin keeps its materials; the re-imported twin renders with
+    # whatever the file carried back — the same look through the format itself
+    authored.location.x = -1.35
+    roundtrip.location.x = 1.35
+
+    pm = bpy.data.materials.new("PlaqueMetal")
+    pm.use_nodes = True
+    pb = pm.node_tree.nodes["Principled BSDF"]
+    pb.inputs["Base Color"].default_value = (0.16, 0.17, 0.19, 1.0)
+    pb.inputs["Metallic"].default_value = 0.9
+    pb.inputs["Roughness"].default_value = 0.3
+
+    def plaque(text, x):
+        cu = bpy.data.curves.new("Plaque", 'FONT')
+        cu.body = text
+        cu.align_x = 'CENTER'
+        cu.size = 0.24
+        cu.extrude = 0.008
+        ob = bpy.data.objects.new("Plaque", cu)
+        ob.location = (x, -1.55, 0.01)
+        ob.data.materials.append(pm)
+        scene.collection.objects.link(ob)
+
+    plaque("AUTHORED", -1.35)
+    plaque("ROUND-TRIP", 1.35)
+
+    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()
+    fmat = bpy.data.materials.new("Studio")
+    fmat.use_nodes = True
+    fb = fmat.node_tree.nodes["Principled BSDF"]
+    fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0)
+    fb.inputs["Roughness"].default_value = 0.7
+    floor_me.materials.append(fmat)
+    floor = bpy.data.objects.new("Floor", floor_me)
+    scene.collection.objects.link(floor)
+    wall = bpy.data.objects.new("Wall", floor_me.copy())
+    wall.location = (0.0, 9.0, 0.0)
+    wall.rotation_euler = (math.radians(90), 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
+
+    def light(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)
+
+    # shaped warm key, faint cool fill, cool rim, warm wedge on the back wall
+    # (docs/VISUAL-STYLE.md)
+    light("Key", (-4.0, -5.0, 6.0), 560.0, 4.5, (1.0, 0.96, 0.9), (48, 0, -38))
+    light("Fill", (5.0, -4.0, 3.0), 100.0, 9.0, (0.75, 0.85, 1.0), (62, 0, 50))
+    light("Rim", (0.5, 4.5, 5.0), 360.0, 4.0, (0.6, 0.78, 1.0), (-55, 0, 175))
+    light("Wedge", (2.5, 3.5, 4.2), 460.0, 5.5, (1.0, 0.76, 0.5), (-72, 0, 195))
+
+    cam_data = bpy.data.cameras.new("Cam")
+    cam_data.lens = 53.0
+    cam = bpy.data.objects.new("Cam", cam_data)
+    cam.location = (0.0, -8.6, 2.7)
+    scene.collection.objects.link(cam)
+    target = bpy.data.objects.new("Aim", None)
+    target.location = (0.0, 0.0, 0.75)
+    scene.collection.objects.link(target)
+    con = cam.constraints.new('TRACK_TO')
+    con.target = target
+    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
+    # AgX would wash the olive drab and teal glow toward pastel
+    # (docs/VISUAL-STYLE.md)
+    scene.view_settings.view_transform = 'Standard'
+    bpy.ops.render.render(write_still=True)
+    return os.path.exists(path) and os.path.getsize(path) > 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 (cycles for GPU-less hosts)")
+    args = p.parse_args(argv)
+
+    bpy.ops.wm.read_factory_settings(use_empty=True)
+    crate = build_crate()
+    for m in make_materials():
+        crate.data.materials.append(m)
+    code = check(crate)
+    if code:
+        return code
+
+    if args.output:
+        # the re-imported crate is still in the file (check wiped and
+        # re-imported); build an authored twin beside it
+        roundtrip = [o for o in bpy.data.objects if o.type == 'MESH'][0]
+        authored = build_crate()
+        for m in make_materials():
+            authored.data.materials.append(m)
+        if not render_still(authored, roundtrip, os.path.abspath(args.output),
+                            args.engine):
+            print("ERROR: render produced no file", file=sys.stderr)
+            return 21
+        print(f"rendered still {args.output}")
+
+    print("gltf-export-roundtrip 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 43efeb4..c753c1c 100644 --- a/docs/gallery/index.html +++ b/docs/gallery/index.html @@ -458,6 +458,17 @@

vse-cut-list

View example +
+ + gltf-export-roundtrip — A sci-fi supply crate exported to glTF and re-imported, verifying the round-trip against the depsgraph-evaluated mesh within float tolerances. + +
+

gltf-export-roundtrip

+

A sci-fi supply crate exported to glTF and re-imported, verifying the round-trip against the depsgraph-evaluated mesh within float tolerances. Positions, loop normals, box-mapped UVs, and per-triangle material bindings must all survive; the on-disk JSON proves the +Y-up conversion and the V-flipped UV layout.

+

witnesses export_yup bakes (x, y, z) -> (x, z, -y) into the vertex data with no node rotation (probed identical on 4.5.11 and 5.1.2); export_apply ships the evaluated mesh, not the base cage; exporter/importer RNA is guarded so a future kwarg rename fails loudly.

+ View example +
+