From 66a4188590f7a7621d4b430ba880203c0a27bf60 Mon Sep 17 00:00:00 2001 From: fOuttaMyPaint Date: Sun, 19 Jul 2026 23:17:09 -0400 Subject: [PATCH] feat: add gltf-skin-roundtrip example witnessing the skinned export contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The geometry round-trip left the skinning side of the format uncovered: skeleton, weights, and deformation must all survive it. A seven-bone scorpion rig exported with export_skins and re-imported pins all three — joint list and unit weight sums on disk, bit-exact weights and rest matrices on re-import, identical deformation — plus the parenting hazard (unparented skinned meshes let the exporter bind an armature by name) and the sorted-multiset trap (the exporter welds duplicate loops; compare by rest-key). The skins pipeline is byte-identical on 4.5.11 and 5.1.2. Signed-off-by: fOuttaMyPaint --- .cursor-plugin/plugin.json | 1 + .github/workflows/blender-smoke.yml | 11 + README.md | 23 +- ROADMAP.md | 2 +- .../assets/gltf-skin-roundtrip-hero.webp | Bin 0 -> 14972 bytes .../gltf-skin-roundtrip-contact-sheet.webp | Bin 0 -> 39010 bytes docs/gallery/gltf-skin-roundtrip/index.html | 840 ++++++++++++++++++ docs/gallery/index.html | 11 + examples/gallery.json | 12 + examples/gltf-skin-roundtrip/README.md | 63 ++ .../gltf_skin_roundtrip.py | 593 +++++++++++++ examples/gltf-skin-roundtrip/preview.webp | Bin 0 -> 13248 bytes 12 files changed, 1552 insertions(+), 4 deletions(-) create mode 100644 docs/gallery/assets/gltf-skin-roundtrip-hero.webp create mode 100644 docs/gallery/contact-sheets/gltf-skin-roundtrip-contact-sheet.webp create mode 100644 docs/gallery/gltf-skin-roundtrip/index.html create mode 100644 examples/gltf-skin-roundtrip/README.md create mode 100644 examples/gltf-skin-roundtrip/gltf_skin_roundtrip.py create mode 100644 examples/gltf-skin-roundtrip/preview.webp diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 1f7ba08..1f79e9a 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -68,6 +68,7 @@ "examples/depsgraph-export", "examples/driver-wave", "examples/gltf-export-roundtrip", + "examples/gltf-skin-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 13ad5ca..8ab1309 100644 --- a/.github/workflows/blender-smoke.yml +++ b/.github/workflows/blender-smoke.yml @@ -416,3 +416,14 @@ jobs: # surviving calc_tangents. Exits non-zero on failure. xvfb-run -a "$BLENDER" --background \ --python examples/triangulate-tangents/triangulate_tangents.py -- + + - name: Shipped example - glTF skin round-trip (skins, weights, deform) + run: | + set -euo pipefail + # Check only (no render): a 7-bone mech scorpion exported with + # export_skins and re-imported; asserts skins[0] joints, JOINTS_0/ + # WEIGHTS_0 unit sums, bone/parent/rest-matrix round-trip, bit-exact + # weights via position-key lookup, and identical deformation of the + # re-imported rig. Exits non-zero on failure. + xvfb-run -a "$BLENDER" --background \ + --python examples/gltf-skin-roundtrip/gltf_skin_roundtrip.py -- diff --git a/README.md b/README.md index 9fcfdda..54ef199 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@

- 12 skills  •  6 rules  •  2 templates  •  17 snippets  •  27 examples + 12 skills  •  6 rules  •  2 templates  •  17 snippets  •  28 examples

@@ -36,7 +36,7 @@ ## Overview -This repository ships **12 skills, 6 rules, 2 templates, 17 snippets, and 27 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 28 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. @@ -472,7 +472,7 @@ round-trips through the raw `POINT` buffer.

-Game asset pipeline — 4 examples +Game asset pipeline — 5 examples @@ -542,6 +542,23 @@ edge/UV-delta formula on smooth fields. Documents the planar-on-cylinder UV degeneracy (tangent collapses onto the normal) and the stale layer-handle hazard that silently corrupts measurements on 4.5. + + + + +
+glTF skin round-trip: two identical mech scorpions on a dark studio floor - orange armor, black segmented tails curled over their backs with glowing stinger tips, teal eyes - proving skeleton, weights, and deformation survive the export + + +### [gltf-skin-roundtrip](examples/gltf-skin-roundtrip/) + +The skinning counterpart to `gltf-export-roundtrip`: a seven-bone rigged +scorpion exported with `export_skins` and re-imported, asserting the joint +list, JOINTS_0/WEIGHTS_0 unit sums, bone/parent/rest-matrix round-trip, +bit-exact weights, and identical deformation of the re-imported rig — plus +the parenting hazard: unparented skinned meshes let the exporter bind an +armature by name. +
diff --git a/ROADMAP.md b/ROADMAP.md index d8f2ec2..ffa1c63 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -108,7 +108,7 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo - ~~Vertex weight limit witness~~ **SHIPPED** as `examples/vertex-weight-limit/` — the 4-influence engine cap enforced via the data API (`v.groups` + `VertexGroup.remove` + renormalize); unit sums (measured 3e-8), pose preserved (4.9e-3), exact LBS from the mesh's own deform layer (2.7e-7), Root mount pinned - ~~Triangulate + tangent-space witness~~ **SHIPPED** as `examples/triangulate-tangents/` — `calc_tangents` aborts on any ngon (back cap must be an explicit fan); mikktspace matches the edge/UV-delta formula within welding tolerance on smooth fields (2.3e-6 measured); planar UVs on a cylindrical wall collapse tangents onto normals (dot 0.998); `MeshUVLoopLayer` handle dangles across `calc_tangents()` on 4.5 (471 phantom flips, silent exit 0) while the mikktspace math is byte-identical on 4.5.11 and 5.1.2 - UV-handle lifetime snippet: re-fetch attribute/UV layers by name after any CustomData-reallocating call (`calc_tangents`, `VertexGroup.add`, modifier edits) — held handles dangle silently on 4.5, survive by luck on 5.1 (found authoring `triangulate-tangents`) -- glTF skinned-mesh export witness: follow-up to `gltf-export-roundtrip` + `vertex-weight-limit` — export a rigged mesh (weights + joints) to glTF and round-trip the skin, asserting influence counts and joint bindings survive the format +- glTF skinned-mesh export witness: follow-up to `gltf-export-roundtrip` + `vertex-weight-limit` — **SHIPPED** as `examples/gltf-skin-roundtrip/` — `skins[0].joints` names every bone, JOINTS_0/WEIGHTS_0 with unit sums (3e-8), weights bit-exact, rest matrices to 2.4e-07, deformation to 4.8e-07; exporter welds duplicate loops (sorted-multiset comparisons mispair — compare by rest-key); unparented skinned meshes make the exporter bind an armature by name - Degenerate-bevel weld hazard (snippet or rule): bevel width ≥ half a box dimension creates zero-area faces whose loops weld on glTF export (found authoring `gltf-export-roundtrip`, where the count check caught a 36-vertex weld) - 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-skin-roundtrip-hero.webp b/docs/gallery/assets/gltf-skin-roundtrip-hero.webp new file mode 100644 index 0000000000000000000000000000000000000000..f03c55d5ba00a7090055293d7b23296035629669 GIT binary patch literal 14972 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 e(Tia7YlyOj@)$q>000F=Gq}tH1iJ#& + + + + + gltf-skin-roundtrip — Examples — Blender Developer Tools + + + + + + + + + + + + + + + + + + +
+

gltf-skin-roundtrip

+

A rigged mech scorpion exported to glTF with skins and re-imported, verifying the skinning contract the geometry round-trip left uncovered. Skeleton, weights, and deformation must all survive the format.

+
+
+ +

Rendered headless by the example itself — click to zoom.

+
witnesses skins[0].joints names every bone; JOINTS_0/WEIGHTS_0 per primitive with unit sums; weights bit-exact on re-import; deformation matches within 4.8e-07. The mesh must be parented to the armature or the exporter binds by name.
+
+
blender --background --python examples/gltf-skin-roundtrip/gltf_skin_roundtrip.py --
+ +
+
+

A runnable example that rigs a mech scorpion — seven-bone chain from pedestal root to stinger, blend rings at every joint seam — exports it with bpy.ops.export_scene.gltf (export_skins=True), parses the file, re-imports it, and verifies the whole skinning contract against the authored rig, following depsgraph-and-evaluated-data.

+

Pipeline arc: modeling/LOD in lod-decimate-chain, weighting in vertex-weight-limit, export in gltf-export-roundtrip — this is the skinning counterpart to the crate's geometry round-trip. Tangent frames for the normal maps are in triangulate-tangents.

+

What it witnesses: the skinned-mesh export contract the geometry round-trip left uncovered.

+

1. The skeleton survives. skins[0].joints names every bone; the re-imported armature carries the same 7 bones, the same parent chain, and rest matrices within 2.4e-07 — the +Y-up conversion applies to bone nodes exactly as it does to meshes (their translations convert, no rotation is written). 2. The weights survive. Every primitive carries JOINTS_0/WEIGHTS_0; per-vertex weights on disk sum to 1 (err 3.0e-08); the re-imported vertex groups match the authored groups bit-exactly (w_err 0.0), compared as straddle-safe position keys, the same protocol as the crate example. 3. The deformation survives. Posed identically, the re-imported rig's evaluated mesh matches the original's within 4.8e-07 — linear blend skinning through the file format. The comparison is by rest-position key, never sorted multisets: the exporter welds duplicate loops (32 here), so cardinalities differ and a naive sorted zip mispairs vertices (a phantom 2.29 "deviation" measured and fixed during authoring). 4. The mesh must be parented to the armature. The exporter warns "Armature must be the parent of skinned mesh" and picks an armature by name otherwise — with two rigs in the file it can bind the wrong one.

+

What each check catches on failure: exporting with export_skins=False (exit 5 — no skin on disk), stripping the weights (exit 4 — no vertex groups), and posing the re-imported rig differently (exit 19 — deformation deviates 0.90).

+

Version witness: the skins pipeline is stable between Blender 4.5 LTS and 5.1 — the exporter/importer RNA is byte-identical (probed with gltf-export-roundtrip), and every measured value matches to the digit on 4.5.11 and 5.1.2.

+

The render stages the authored scorpion beside the actual re-imported one — same curl, same glowing stinger — proof the skin rode the format through.

+

Run

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

It exits non-zero on failure (missing skin, joint drift, weight-sum drift, skeleton drift, weight excursion, or deformation excursion). The blender-smoke workflow runs the check on Blender 4.5 LTS and 5.1.

+
+
+

Source

+
+ examples/gltf-skin-roundtrip/gltf_skin_roundtrip.py + View on GitHub → +
+
"""A rigged mech scorpion round-tripped through glTF skins — a runnable example.
+
+Witnesses the skinned-mesh export contract that gltf-export-roundtrip left
+uncovered: bones, hierarchy, weights, and deformation must all survive the
+format. (Pipeline arc: modeling/LOD in lod-decimate-chain, weighting in
+vertex-weight-limit, export in gltf-export-roundtrip; the tangent frames
+those maps need are in triangulate-tangents.)
+
+1. The skeleton round-trips. ``skins[0].joints`` names every bone in order,
+   and the re-imported armature carries the same bones, parent chain, and
+   rest matrices within tolerance — the +Y-up conversion applies to bone
+   nodes the same way it applies to meshes.
+2. The weights round-trip. JOINTS_0/WEIGHTS_0 exist per primitive, per-vertex
+   weights on disk sum to 1, and the re-imported vertex groups match the
+   authored groups weight-for-weight within float tolerances (compared as
+   sorted point sets, the same protocol as gltf-export-roundtrip).
+3. The deformation round-trips. Posed identically, the re-imported rig's
+   evaluated mesh matches the original's — linear blend skinning through
+   the file format.
+4. The mesh must be parented to the armature. The exporter warns "Armature
+   must be the parent of skinned mesh" and picks an armature by name
+   otherwise — with more than one rig in the file it can bind the wrong one.
+
+The skins pipeline is stable between Blender 4.5 LTS and 5.1 (exporter RNA
+is byte-identical, verified on both) — the example runs identically on both.
+
+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_skin_roundtrip.py --                 # check only
+    blender --background --python gltf_skin_roundtrip.py -- --output s.png  # + render
+"""
+import bpy, bmesh, sys, os, math, json, struct, shutil, tempfile, argparse
+import mathutils
+
+GUNMETAL, ORANGE, RUBBER, GLOW = 0, 1, 2, 3
+BONES = ("Root", "Thorax", "Abdomen", "Tail_1", "Tail_2", "Tail_3", "Stinger")
+SPANS = {"Root": (0.0, 0.35), "Thorax": (0.35, 0.85), "Abdomen": (0.85, 1.25),
+         "Tail_1": (1.25, 1.60), "Tail_2": (1.60, 1.90), "Tail_3": (1.90, 2.15),
+         "Stinger": (2.15, 2.30)}
+# tail curl: progressive pitch per joint; the pose is the deformation witness
+POSE_DEG = {"Tail_1": -28.0, "Tail_2": -34.0, "Tail_3": -40.0, "Stinger": 25.0}
+BLEND = 0.06                    # joint blend half-width (two bones per seam)
+POS_TOL = 2e-5                  # float32 on disk vs float32 in Blender
+NRM_TOL = 2e-4
+W_TOL = 3e-4                    # weight round-trip tolerance
+SUM_TOL = 3e-4                  # disk weight-sum tolerance
+REST_TOL = 1e-5                 # rest-matrix round-trip tolerance
+LBS_TOL = 5e-4
+KEY_DIGITS = 4
+
+EXPORT_KWARGS = dict(
+    export_format='GLTF_SEPARATE',
+    export_skins=True,
+    export_yup=True,
+    export_animations=False,
+    export_image_format='NONE',
+)
+
+
+def box_part(bm, size, loc, rot, mat, part_of, bone):
+    """One box shell (rot in degrees XYZ); tags `bone` for its verts."""
+    n0f, n0v = len(bm.faces), len(bm.verts)
+    m = (mathutils.Matrix.Translation(loc)
+         @ mathutils.Euler(tuple(math.radians(a) for a in rot)).to_matrix().to_4x4()
+         @ mathutils.Matrix.Diagonal((*size, 1.0)))
+    bmesh.ops.create_cube(bm, size=1.0, matrix=m)
+    for f in bm.faces[n0f:]:
+        f.material_index = mat
+    part_of.extend([bone] * (len(bm.verts) - n0v))
+
+
+def lathe_part(bm, rings, mat, part_of, bone, sides=16):
+    """Revolve (z, r) rings; tags `bone` for its verts."""
+    n0f, n0v = len(bm.faces), len(bm.verts)
+    out = []
+    for z, r in rings:
+        out.append([bm.verts.new((r * math.cos(2 * math.pi * s / sides),
+                                  r * math.sin(2 * math.pi * s / sides), z))
+                    for s in range(sides)])
+    for k in range(len(out) - 1):
+        for s in range(sides):
+            f = bm.faces.new((out[k][s], out[k][(s + 1) % sides],
+                              out[k + 1][(s + 1) % sides], out[k + 1][s]))
+            f.material_index = mat
+    part_of.extend([bone] * (len(bm.verts) - n0v))
+
+
+def build_scorpion():
+    """The mech scorpion: base plate, six rigid legs, thorax with claws,
+    abdomen, three tail segments, and a stinger — every part closed form,
+    every vertex tagged to its bone (or a two-bone blend ring at a seam)."""
+    me = bpy.data.meshes.new("Scorpion")
+    part_of = []
+    bm = bmesh.new()
+    try:
+        # base plate (Root)
+        box_part(bm, (0.9, 0.7, 0.10), (0.0, 0.0, 0.05), (0, 0, 0),
+                 GUNMETAL, part_of, "Root")
+        # thorax shell + head with eyes (Thorax)
+        box_part(bm, (0.70, 0.55, 0.42), (0.0, -0.05, 0.60), (0, 0, 0),
+                 ORANGE, part_of, "Thorax")
+        box_part(bm, (0.40, 0.30, 0.22), (0.0, -0.38, 0.66), (8, 0, 0),
+                 ORANGE, part_of, "Thorax")
+        for sx in (-1, 1):  # glowing eyes
+            box_part(bm, (0.06, 0.03, 0.06), (sx * 0.10, -0.545, 0.70),
+                     (0, 0, 0), GLOW, part_of, "Thorax")
+        # six legs in two rows, angled down (rigid to Thorax)
+        for sx in (-1, 1):
+            for k, z in ((0, 0.72), (1, 0.60), (2, 0.48)):
+                box_part(bm, (0.34, 0.09, 0.07), (sx * 0.50, -0.02 + k * 0.16, z),
+                         (0, 0, sx * 18), GUNMETAL, part_of, "Thorax")
+                box_part(bm, (0.30, 0.06, 0.05), (sx * 0.76, -0.02 + k * 0.16, z - 0.13),
+                         (0, 0, sx * 40), GUNMETAL, part_of, "Thorax")
+        # claws: shoulder + forearm + pincer pair (rigid to Thorax)
+        for sx in (-1, 1):
+            box_part(bm, (0.18, 0.40, 0.18), (sx * 0.26, -0.58, 0.60),
+                     (-15, 0, 0), GUNMETAL, part_of, "Thorax")
+            box_part(bm, (0.16, 0.34, 0.16), (sx * 0.26, -0.85, 0.52),
+                     (20, 0, 0), GUNMETAL, part_of, "Thorax")
+            for dy in (-1, 1):
+                box_part(bm, (0.07, 0.22, 0.12),
+                         (sx * 0.26, -1.02, 0.52 + dy * 0.08), (0, 0, 0),
+                         GUNMETAL, part_of, "Thorax")
+        # abdomen: two tapering plates (Abdomen)
+        box_part(bm, (0.56, 0.46, 0.30), (0.0, 0.12, 1.00), (4, 0, 0),
+                 ORANGE, part_of, "Abdomen")
+        box_part(bm, (0.44, 0.36, 0.26), (0.0, 0.16, 1.24), (6, 0, 0),
+                 ORANGE, part_of, "Abdomen")
+        # tail segments, lathed and tapering (their own bones)
+        lathe_part(bm, [(1.25, 0.16), (1.42, 0.15), (1.60, 0.13)],
+                   GUNMETAL, part_of, "Tail_1")
+        lathe_part(bm, [(1.60, 0.13), (1.75, 0.115), (1.90, 0.10)],
+                   GUNMETAL, part_of, "Tail_2")
+        lathe_part(bm, [(1.90, 0.10), (2.02, 0.085), (2.15, 0.07)],
+                   GUNMETAL, part_of, "Tail_3")
+        # stinger: cone + barb with the glow tip (Stinger)
+        lathe_part(bm, [(2.15, 0.07), (2.26, 0.045), (2.34, 0.012)],
+                   GUNMETAL, part_of, "Stinger")
+        lathe_part(bm, [(2.30, 0.030), (2.36, 0.030)], GLOW, part_of, "Stinger")
+        bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
+        bm.to_mesh(me)
+    finally:
+        bm.free()  # the ownership contract, as always
+    for poly in me.polygons:
+        poly.use_smooth = False  # flat facets: one disk vertex per loop
+    obj = bpy.data.objects.new("Scorpion", me)
+    bpy.context.collection.objects.link(obj)
+    return obj, part_of
+
+
+def bone_of(z, bone):
+    """Two-bone smooth blend across each joint seam; hard weight elsewhere."""
+    order = list(SPANS)
+    i = order.index(bone)
+    if i == 0:
+        return None
+    prev = order[i - 1]
+    seam = SPANS[bone][0]
+    if abs(z - seam) <= BLEND:
+        return prev
+    return None
+
+
+def assign_weights(obj, part_of):
+    """Hard weights per part plus 50/50 blend rings at each bone seam."""
+    groups = {b: obj.vertex_groups.new(name=b) for b in BONES}
+    for idx, v in enumerate(obj.data.vertices):
+        bone = part_of[idx]
+        prev = bone_of(v.co.z, bone)
+        if prev is not None:
+            t = (v.co.z - (SPANS[bone][0] - BLEND)) / (2 * BLEND)
+            groups[prev].add([idx], 1.0 - t, 'REPLACE')
+            groups[bone].add([idx], t, 'REPLACE')
+        else:
+            groups[bone].add([idx], 1.0, 'REPLACE')
+    return groups
+
+
+def build_rig(obj):
+    arm_data = bpy.data.armatures.new("ScorpRig")
+    arm = bpy.data.objects.new("ScorpRig", arm_data)
+    bpy.context.collection.objects.link(arm)
+    bpy.context.view_layer.objects.active = arm
+    bpy.ops.object.mode_set(mode='EDIT')
+    prev = None
+    for name in BONES:
+        eb = arm_data.edit_bones.new(name)
+        z0, z1 = SPANS[name]
+        eb.head = (0.0, 0.0, z0)
+        eb.tail = (0.0, 0.0, z1)
+        if prev is not None:
+            eb.parent = prev
+            eb.use_connect = True
+        prev = eb
+    bpy.ops.object.mode_set(mode='OBJECT')
+    # the exporter warns and picks by name otherwise — with two rigs in the
+    # file it can bind the WRONG one; parent explicitly
+    obj.parent = arm
+    mod = obj.modifiers.new("Armature", 'ARMATURE')
+    mod.object = arm
+    mod.use_vertex_groups = True
+    mod.use_bone_envelopes = False
+    for name, deg in POSE_DEG.items():
+        pb = arm.pose.bones[name]
+        pb.rotation_mode = 'XYZ'
+        pb.rotation_euler.x = math.radians(deg)
+    return arm
+
+
+def eval_positions(obj):
+    deps = bpy.context.evaluated_depsgraph_get()
+    ob_eval = obj.evaluated_get(deps)
+    me = ob_eval.to_mesh()
+    try:
+        return [tuple(v.co) for v in me.vertices]
+    finally:
+        ob_eval.to_mesh_clear()
+
+
+def key_of(p):
+    return tuple(round(c, KEY_DIGITS) for c in p)
+
+
+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"])]
+
+    def accessor_uints(idx, ncomp, ctype):
+        acc = g["accessors"][idx]
+        bv = g["bufferViews"][acc["bufferView"]]
+        off = bv.get("byteOffset", 0) + acc.get("byteOffset", 0)
+        stride = bv.get("byteStride", {5121: 1, 5123: 2, 5125: 4}[ctype] * ncomp)
+        fmt = {5121: "B", 5123: "H", 5125: "I"}[ctype]
+        return [struct.unpack_from(f"<{ncomp}{fmt}", blob, off + stride * i)
+                for i in range(acc["count"])]
+
+    return g, accessor_floats, accessor_uints
+
+
+def check(obj, arm, part_of):
+    me = obj.data
+    exp_props = {p.identifier for p in bpy.ops.export_scene.gltf.get_rna_type().properties}
+    missing = [k for k in EXPORT_KWARGS if k not in exp_props]
+    if missing:
+        print(f"ERROR: exporter RNA drifted, missing {missing}", file=sys.stderr)
+        return 3
+
+    base_verts = len(me.vertices)
+    ngroups = len(obj.vertex_groups)
+    if ngroups != len(BONES):
+        print(f"ERROR: {ngroups} vertex groups != {len(BONES)} bones", file=sys.stderr)
+        return 4
+
+    deps = bpy.context.evaluated_depsgraph_get()
+    ob_eval = obj.evaluated_get(deps)
+    me_eval = ob_eval.to_mesh()
+    try:
+        eval_loops = len(me_eval.loops)
+        pose_orig = [tuple(v.co) for v in me_eval.vertices]
+    finally:
+        ob_eval.to_mesh_clear()
+
+    rest_orig = [tuple(v.co) for v in me.vertices]
+    # snapshot everything the export/import wipe would free: the original
+    # datablocks DIE at read_factory_settings (the crate's freed-mesh hazard)
+    bone_info = {name: (arm.data.bones[name].parent.name
+                        if arm.data.bones[name].parent else None,
+                        tuple(tuple(arm.data.bones[name].matrix_local[r][c]
+                                    for c in range(4)) for r in range(4)))
+                 for name in BONES}
+
+    def weight_map(o):
+        names = [g.name for g in o.vertex_groups]
+        return {key_of(v.co): tuple(sorted(
+            (names[g.group], round(g.weight, 5)) for g in v.groups))
+            for v in o.data.vertices}
+
+    want_w = weight_map(obj)
+
+    tmp = tempfile.mkdtemp(prefix="gltf_skin_")
+    try:
+        path = os.path.join(tmp, "scorp.gltf").replace("\\", "/")
+        bpy.ops.export_scene.gltf(filepath=path, **EXPORT_KWARGS)
+
+        # contract 1 (on disk): the skin carries every bone, joints named
+        g, acc_f, acc_u = read_gltf(path)
+        skins = g.get("skins", [])
+        if len(skins) != 1:
+            print(f"ERROR: {len(skins)} skins on disk, expected 1", file=sys.stderr)
+            return 5
+        joints = skins[0]["joints"]
+        node_names = [g["nodes"][j].get("name", "") for j in joints]
+        if len(joints) != len(BONES) or sorted(node_names) != sorted(BONES):
+            print(f"ERROR: skin joints {node_names} != bones {sorted(BONES)}",
+                  file=sys.stderr)
+            return 6
+        prim = g["meshes"][0]["primitives"]
+        if not all("JOINTS_0" in p["attributes"] and "WEIGHTS_0" in p["attributes"]
+                   for p in prim):
+            print("ERROR: primitives lack JOINTS_0/WEIGHTS_0", file=sys.stderr)
+            return 7
+        # contract 2a (on disk): per-vertex weights sum to 1
+        disk_verts = 0
+        sum_err = 0.0
+        for p in prim:
+            a = p["attributes"]
+            w = acc_f(a["WEIGHTS_0"], 4)
+            j = acc_u(a["JOINTS_0"], 4, g["accessors"][a["JOINTS_0"]]["componentType"])
+            disk_verts += len(w)
+            for ws, js in zip(w, j):
+                total = sum(x for x, ji in zip(ws, js) if ji < len(BONES))
+                sum_err = max(sum_err, abs(total - 1.0) if total > 1e-9 else 0.0)
+        if sum_err > SUM_TOL:
+            print(f"ERROR: disk weight sums deviate {sum_err:.3e} from 1.0",
+                  file=sys.stderr)
+            return 8
+        if disk_verts > eval_loops:
+            print(f"ERROR: disk verts {disk_verts} exceed evaluated loops "
+                  f"{eval_loops} — the exporter invented vertices",
+                  file=sys.stderr)
+            return 9
+
+        bpy.ops.wm.read_factory_settings(use_empty=True)
+        bpy.ops.import_scene.gltf(filepath=path)
+    finally:
+        shutil.rmtree(tmp, ignore_errors=True)
+
+    # contract 1b: the skeleton round-trips
+    arms = [o for o in bpy.data.objects if o.type == 'ARMATURE']
+    if len(arms) != 1:
+        print(f"ERROR: {len(arms)} armatures after import, expected 1",
+              file=sys.stderr)
+        return 10
+    ri_arm = arms[0]
+    if len(ri_arm.data.bones) != len(BONES):
+        print(f"ERROR: {len(ri_arm.data.bones)} bones != {len(BONES)}",
+              file=sys.stderr)
+        return 11
+    rest_err = 0.0
+    for name in BONES:
+        b1 = ri_arm.data.bones.get(name)
+        if b1 is None:
+            print(f"ERROR: bone {name} lost in round-trip", file=sys.stderr)
+            return 12
+        p0, m0 = bone_info[name]
+        p1 = b1.parent.name if b1.parent else None
+        if p0 != p1:
+            print(f"ERROR: {name} parent {p1} != {p0}", file=sys.stderr)
+            return 13
+        rest_err = max(rest_err,
+                       max(abs(m0[r][c] - b1.matrix_local[r][c])
+                           for r in range(4) for c in range(4)))
+    if rest_err > REST_TOL:
+        print(f"ERROR: rest matrices deviate {rest_err:.3e} on round-trip",
+              file=sys.stderr)
+        return 14
+
+    # contract 2b: the weights round-trip (sorted point sets, crate protocol)
+    ri = [o for o in bpy.data.objects
+          if o.type == 'MESH' and o.vertex_groups and o.find_armature()]
+    if len(ri) != 1:
+        print(f"ERROR: {len(ri)} skinned meshes after import, expected 1",
+              file=sys.stderr)
+        return 15
+    ri = ri[0]
+    rme = ri.data
+    if len(rme.vertices) != disk_verts:
+        print(f"ERROR: re-import has {len(rme.vertices)} verts, expected the "
+              f"{disk_verts} on disk (the import must be 1:1 with the file)",
+              file=sys.stderr)
+        return 16
+    if sorted(g.name for g in ri.vertex_groups) != sorted(BONES):
+        print("ERROR: vertex group names drifted", file=sys.stderr)
+        return 17
+
+    # per-vertex weight vectors, compared at sorted positions
+    names_ri = [g.name for g in ri.vertex_groups]
+    got_w = {key_of(v.co): tuple(sorted(
+        (names_ri[g.group], round(g.weight, 5)) for g in v.groups))
+        for v in rme.vertices}
+    w_err = 0.0
+    step = 10 ** -KEY_DIGITS
+    for p, want in want_w.items():
+        got = None
+        for dx in (-step, 0.0, step):
+            for dy in (-step, 0.0, step):
+                for dz in (-step, 0.0, step):
+                    k = (p[0] + dx, p[1] + dy, p[2] + dz)
+                    if k in got_w:
+                        got = got_w[k]
+                        break
+        if got != want:
+            w_err = max(w_err, 1.0 if got is None else
+                        max(abs(dict(want).get(n, 0.0) - dict(got).get(n, 0.0))
+                            for n in set(dict(want)) | set(dict(got))))
+    if w_err > W_TOL:
+        print(f"ERROR: weight round-trip deviates {w_err:.3e} (tol {W_TOL})",
+              file=sys.stderr)
+        return 18
+
+    # contract 3: the deformation round-trips (pose the re-imported rig the
+    # same way, compare posed positions BY REST-KEY — sorted multisets
+    # misalign because the exporter welds duplicate loops)
+    for name, deg in POSE_DEG.items():
+        pb = ri_arm.pose.bones[name]
+        pb.rotation_mode = 'XYZ'
+        pb.rotation_euler.x = math.radians(deg)
+    bpy.context.view_layer.update()
+    pose_ri = eval_positions(ri)
+    orig_map = {}
+    for r, p in zip(rest_orig, pose_orig):
+        orig_map.setdefault(key_of(r), []).append(p)
+    step = 10 ** -KEY_DIGITS
+    pos_err = 0.0
+    for j, rv in enumerate(rme.vertices):
+        k0 = key_of(rv.co)
+        cands = []
+        for dx in (-step, 0.0, step):
+            for dy in (-step, 0.0, step):
+                for dz in (-step, 0.0, step):
+                    cands += orig_map.get((k0[0] + dx, k0[1] + dy, k0[2] + dz), [])
+        best = min((max(abs(pose_ri[j][c] - p[c]) for c in range(3))
+                    for p in cands), default=1e30)
+        pos_err = max(pos_err, best)
+    if pos_err > POS_TOL:
+        print(f"ERROR: deformation round-trip deviates {pos_err:.3e} "
+              f"(tol {POS_TOL})", file=sys.stderr)
+        return 19
+
+    print(f"bones={len(BONES)} verts={base_verts} eval_loops={eval_loops} "
+          f"disk_verts={disk_verts} (welded {eval_loops - disk_verts}) "
+          f"sum_err={sum_err:.2e}")
+    print(f"rest_err={rest_err:.2e} w_err={w_err:.2e} (tol {W_TOL}) "
+          f"pos_err={pos_err:.2e} (tol {POS_TOL})")
+    return 0
+
+
+def eevee_engine_id():
+    return 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT'
+
+
+def make_materials():
+    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("Gunmetal", (0.11, 0.12, 0.14), 0.9, 0.32),
+        pbr("HazardOrange", (0.80, 0.28, 0.07), 0.10, 0.45),
+        pbr("FlexRubber", (0.05, 0.05, 0.06), 0.0, 0.85),
+        pbr("TealGlow", (0.03, 0.22, 0.26), 0.0, 0.35,
+            emission=(0.10, 0.70, 0.78), strength=2.5),
+    ]
+
+
+def render_still(obj, path, engine):
+    scene = bpy.context.scene
+
+    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), 340.0, 4.0, (0.6, 0.78, 1.0), (-55, 0, 175))
+    light("Wedge", (2.5, 3.5, 4.2), 460.0, 6.0, (1.0, 0.76, 0.5), (-72, 0, 195))
+
+    cam_data = bpy.data.cameras.new("Cam")
+    cam_data.lens = 52.0
+    cam = bpy.data.objects.new("Cam", cam_data)
+    cam.location = (1.5, -6.6, 2.7)
+    scene.collection.objects.link(cam)
+    target = bpy.data.objects.new("Aim", None)
+    target.location = (0.0, 0.0, 0.8)
+    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 hazard orange and teal glow (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)
+    obj, part_of = build_scorpion()
+    for m in make_materials():
+        obj.data.materials.append(m)
+    assign_weights(obj, part_of)
+    arm = build_rig(obj)
+    bpy.context.view_layer.update()
+    code = check(obj, arm, part_of)
+    if code:
+        return code
+
+    if args.output:
+        # the re-imported rig is still in the file after the check; rebuild
+        # the authored pair beside it for the still
+        roundtrip_mesh = [o for o in bpy.data.objects
+                          if o.type == 'MESH' and o.vertex_groups][0]
+        roundtrip_arm = [o for o in bpy.data.objects if o.type == 'ARMATURE'][0]
+        authored, part_of2 = build_scorpion()
+        for m in make_materials():
+            authored.data.materials.append(m)
+        assign_weights(authored, part_of2)
+        authored_arm = build_rig(authored)
+        # move the ARMATURES; the skinned meshes follow as children
+        authored_arm.location.x = -1.1
+        roundtrip_arm.location.x = 1.1
+        if not render_still(authored, os.path.abspath(args.output), args.engine):
+            print("ERROR: render produced no file", file=sys.stderr)
+            return 20
+        print(f"rendered still {args.output}")
+
+    print("gltf-skin-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 1193f72..3e36cfb 100644 --- a/docs/gallery/index.html +++ b/docs/gallery/index.html @@ -502,6 +502,17 @@

triangulate-tangents

View example +
+ + gltf-skin-roundtrip — A rigged mech scorpion exported to glTF with skins and re-imported, verifying the skinning contract the geometry round-trip left uncovered. + +
+

gltf-skin-roundtrip

+

A rigged mech scorpion exported to glTF with skins and re-imported, verifying the skinning contract the geometry round-trip left uncovered. Skeleton, weights, and deformation must all survive the format.

+

witnesses skins[0].joints names every bone; JOINTS_0/WEIGHTS_0 per primitive with unit sums; weights bit-exact on re-import; deformation matches within 4.8e-07. The mesh must be parented to the armature or the exporter binds by name.

+ View example +
+