diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json
index 20db2db..fae63bc 100644
--- a/.cursor-plugin/plugin.json
+++ b/.cursor-plugin/plugin.json
@@ -64,6 +64,7 @@
"examples/attribute-domain-shear",
"examples/bmesh-gear",
"examples/car-mirror-symmetry",
+ "examples/coincident-vert-weld",
"examples/collision-hull-proxy",
"examples/color-attribute-wheel",
"examples/compositor-glare",
@@ -90,6 +91,7 @@
"examples/lod-decimate-chain",
"examples/mesh-hygiene-audit",
"examples/modular-kit-snap",
+ "examples/ngon-triangulate",
"examples/parent-inverse-orrery",
"examples/png-exr-alpha",
"examples/prop-origin-transform",
@@ -103,6 +105,7 @@
"examples/text-version-stamp",
"examples/triangulate-tangents",
"examples/turntable",
+ "examples/unapplied-scale-gltf",
"examples/usd-export-evaluation-mode",
"examples/uv-layer-grid",
"examples/vertex-color-ao",
diff --git a/AGENTS.md b/AGENTS.md
index 102b049..0baf40c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -20,7 +20,7 @@ a `.cursor-plugin/plugin.json` manifest so the ecosystem drift checker
classifies it as a `cursor-plugin`. This is content the AI loads when the user
asks Blender questions or works on Blender add-ons in Cursor or Claude Code.
-The content base is 13 skills, 6 rules, 2 templates, 17 snippets, and 50
+The content base is 13 skills, 6 rules, 2 templates, 17 snippets, and 53
examples (counts are CI-enforced against README.md and the manifest). The full
inventory tables and per-item purposes live in `CLAUDE.md`. Example anatomy
and authoring rules: copy `examples/bmesh-gear/`; the render look is specified
@@ -35,7 +35,7 @@ Blender-Developer-Tools/
rules/
- 13 skills • 6 rules • 2 templates • 17 snippets • 50 examples + 13 skills • 6 rules • 2 templates • 17 snippets • 53 examples
@@ -627,7 +627,7 @@ portable path is `radius`.
Game asset pipeline — 9 examples
+Game asset pipeline — 20 examples
diff --git a/ROADMAP.md b/ROADMAP.md
index 20bbd1b..a378a3a 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -96,8 +96,11 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo
- ~~Repeat Zone / For Each Element pairing witness~~ **SHIPPED** as `examples/gn-zone-iterate/` — `pair_with_output` is load-bearing (unpaired evaluates 0 verts); Repeat `8×(1+N)` with X-centers at `k×STEP`; For Each `8×P` with Z-centers at `i×STEP`; For Each main Geometry is a passthrough (6 verts); count-only is insufficient (`--no-offset` still 32 verts, one X-center)
- ~~Geometry Nodes bundle round-trip~~ **SHIPPED** as `examples/gn-bundle-roundtrip/` — 5.x RNA `NodeCombineBundle` (`GeometryNodeCombineBundle` undefined on 5.2); closed form 8/6 + x `[0.5, 2.5]` + `bundle_mark=0.314159`; 4.5 SKIP via catalog `min_version` 5.0 (experimental flag off evaluates empty; `--force-run` hits 5.x RNA undefined)
- Tighten inverted smoke canaries: assert the expected `[FAIL]` marker text (`skipped on ... should run` / `missing post-exit sidecar`) alongside wrapper exit 1, so a canary that dies for the wrong reason does not satisfy the gate
-- Falsification flags on shipped examples (Phases 6–8 `--skip-delete` / `--unpair-*` / `--bypass`, Phase 9 `--silent-handler` / `--wrong-text`) were proven local-Windows only — chat and `.scratch`, not CI history. Decide later whether a cron should exercise them or whether flags in the scripts are sufficient record.
- ~~exit_pre sidecar witness~~ **SHIPPED** as `examples/exit-pre-sidecar/` — 5.1+ `handlers.exit_pre` writes `$BDT_SMOKE_SIDECAR`; harness `--expect-sidecar` + `sidecar_contains=exit_pre-ok` after process exit; 4.5 SKIP `min_version` 5.1; `--force-run` is AttributeError; `--silent-handler` / `--no-handler` missing sidecar; `--wrong-text` / `--write-in-main` / `--atexit-instead` wrong contents
+- ~~N-gon / unapplied-scale / coincident-vert pathology witnesses~~ **SHIPPED** as `examples/ngon-triangulate/`, `examples/unapplied-scale-gltf/`, `examples/coincident-vert-weld/` — two-stage pre-assert then handling; no gallery stills (defects not thumbnail-legible); `--no-dissolve` / `--identity` / `--no-duplicate` red on detection; `--skip-triangulate` / `--bake` / `--weld` red on handling
+- Overlapping / mirrored UV islands as a *shipped* pathology (not lightmap `--falsify`): SAT hits matching a constructed overlap, then glTF TEXCOORD survival. Deferred — `lightmap-uv-channel` already owns the zero-overlap gate.
+- Vendor CC0 asset fixtures (Kenney, Quaternius, ambientCG only — no aggregators) as `.glb` not `.blend`, with per-fixture provenance records mirroring the Free-Game-Dev-Assets frontmatter schema. Permitted only where the assertion is an invariant over the fixture rather than a measurement of it. Deferred pending a decision on repo weight and CI fetch policy.
+- Falsification flags on shipped examples (Phases 6–8 `--skip-delete` / `--unpair-*` / `--bypass`, Phase 9 `--silent-handler` / `--wrong-text`, Phase 10 `--no-dissolve` / `--identity` / `--no-duplicate`) were proven local-Windows only — chat and `.scratch`, not CI history. Decide later whether a cron should exercise them or whether flags in the scripts are sufficient record.
- ~~Fleet Pages facelift + examples support~~ **RESOLVED differently (2026-07-03)**: the meta-repo migration was dropped — the fleet template only scaffolds new tools, and each tool's site evolves independently after that. This repo vendored the site build into `scripts/site/`, redesigned landing + gallery as the Blender-viewport system (see `docs/gallery/DESIGN_NOTES.md`), added the examples grid, nav link, and full hero stats locally. `scripts/build_gallery.py` and `examples/gallery.json` are now permanent, not a prototype awaiting lift-and-shift.
- `modal-operators` skill -- `invoke` returning `RUNNING_MODAL`, the `modal()` event handler, modal cancellation patterns
- `usd-pipelines` skill -- USD export options, `evaluation_mode`, instancing, the USD vs glTF tradeoffs
diff --git a/docs/technical-audit.md b/docs/technical-audit.md
index d6247aa..ef893ab 100644
--- a/docs/technical-audit.md
+++ b/docs/technical-audit.md
@@ -72,6 +72,21 @@ on `bundle_items` are load-bearing. Vert count alone is not a witness.
- Taught: `skills/geometry-nodes-python/SKILL.md`
- Witness: `examples/gn-bundle-roundtrip/`
+### 2026-09-09 — glTF `export_apply` does not bake object scale
+
+RNA: "Apply modifiers (excluding Armatures) to mesh objects". Unapplied
+object scale is written to the glTF node, Y-up permuted
+`(sx, sy, sz) → (sx, sz, sy)`. POSITION stays the local mesh. Baking
+scale in Blender (`mesh.transform` + identity `obj.scale`) is what
+removes `node.scale` and puts world extents in POSITION. Same on
+4.5 LTS and 5.2 LTS; not a version split. `gltf-export-roundtrip`
+asserts no node scale because that crate is identity. Not listed in
+the python_api notes as a behavior change because the RNA name did
+not move.
+
+- Taught: `skills/depsgraph-and-evaluated-data/SKILL.md`
+- Witness: `examples/unapplied-scale-gltf/`
+
### Contrast — NodesModifier dict assignment (5.2, was in the notes)
`mod[identifier] = value` raises `TypeError` on 5.2 (`id properties not
diff --git a/examples/coincident-vert-weld/README.md b/examples/coincident-vert-weld/README.md
new file mode 100644
index 0000000..7a90cd7
--- /dev/null
+++ b/examples/coincident-vert-weld/README.md
@@ -0,0 +1,35 @@
+# Coincident vert weld
+
+Synthesizes two cubes occupying the same space as one mesh, then
+asserts the duplicates exist before asserting they cross glTF export.
+Inverse of [`degenerate-bevel-weld`](../degenerate-bevel-weld/)
+(coincidences from bevel pinch) and
+[`mesh-hygiene-audit`](../mesh-hygiene-audit/) (manifold on a clean
+solid). Neighbor of [`gltf-export-roundtrip`](../gltf-export-roundtrip/)
+(kit-bash face-plane welds on export).
+
+**Why this pathology:** coincident shells are still manifold (every
+edge borders 2 faces). Hygiene can pass. The engine trap is extra
+triangles on disk.
+
+**Pre-assertion (pathology exists):** **16** verts, **8** unique
+positions, **12** faces, **24** edges, valence all 2. `--no-duplicate`
+exits 3.
+
+**Handling (second axis):** glTF ships **48** loop-split positions,
+**24** tris, **8** unique. Vert count 16 is not enough; unique=8 is
+the construction axis; 24 tris vs 12 after `remove_doubles` is the
+export axis. `--weld` after the pre-assert exits 4.
+
+`remove_doubles` collapses to one cube (8/12/6), still manifold — not
+a 4-face-per-edge mesh. Same on 4.5 LTS and 5.2 LTS.
+
+No gallery still. Two coincident cubes look like one cube.
+
+## Run
+
+```bash
+blender --background --python coincident_vert_weld.py --
+blender --background --python coincident_vert_weld.py -- --no-duplicate
+blender --background --python coincident_vert_weld.py -- --weld
+```
diff --git a/examples/coincident-vert-weld/coincident_vert_weld.py b/examples/coincident-vert-weld/coincident_vert_weld.py
new file mode 100644
index 0000000..58c5eec
--- /dev/null
+++ b/examples/coincident-vert-weld/coincident_vert_weld.py
@@ -0,0 +1,200 @@
+"""Coincident duplicate verts — a runnable example.
+
+Two 2 m cubes occupying the same space, joined as one mesh: 16 verts,
+8 unique positions, still manifold. Inverse of ``degenerate-bevel-weld``
+(coincidences from bevel pinch) and ``mesh-hygiene-audit`` (manifold on
+a clean solid). The authored duplicates cross the glTF boundary:
+24 triangles, 48 loop-split positions, 8 unique.
+
+``bmesh.ops.remove_doubles`` collapses to one cube (8/12/6, valence 2)
+— not non-manifold. The export-crossing is the handling contract.
+
+Vert count 16 alone is not enough (any 16-vert mesh). Unique positions
+= 8 is the second axis. glTF tri count 24 vs 12 after weld is the
+export axis.
+
+No gallery still. Two coincident cubes look like one cube.
+
+ blender --background --python coincident_vert_weld.py --
+"""
+import argparse
+import json
+import os
+import struct
+import sys
+import tempfile
+
+import bpy
+import bmesh
+
+sys.dont_write_bytecode = True
+
+VERTS = 16
+UNIQUE = 8
+FACES = 12
+EDGES = 24
+GLTF_POS = 48
+GLTF_TRIS = 24
+ND = 6
+
+EXPORT_KWARGS = dict(
+ export_format="GLTF_SEPARATE",
+ export_apply=True,
+ export_yup=True,
+ export_texcoords=False,
+ export_normals=True,
+ export_materials="NONE",
+ export_animations=False,
+ export_image_format="NONE",
+)
+
+
+def read_gltf(path):
+ g = json.load(open(path, encoding="utf-8"))
+ 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
+
+
+def unique_count(me):
+ return len({
+ (round(v.co.x, ND), round(v.co.y, ND), round(v.co.z, ND))
+ for v in me.vertices
+ })
+
+
+def edge_valence(me):
+ counts = {}
+ for e in me.edges:
+ n = len(e.link_faces) if hasattr(e, "link_faces") else None
+ if n is None:
+ break
+ counts[n] = counts.get(n, 0) + 1
+ if counts:
+ return counts
+ # MeshEdge has no link_faces; rebuild via bmesh.
+ bm = bmesh.new()
+ try:
+ bm.from_mesh(me)
+ for e in bm.edges:
+ n = len(e.link_faces)
+ counts[n] = counts.get(n, 0) + 1
+ finally:
+ bm.free()
+ return counts
+
+
+def weld_mesh(me):
+ bm = bmesh.new()
+ try:
+ bm.from_mesh(me)
+ bmesh.ops.remove_doubles(bm, verts=list(bm.verts), dist=1e-4)
+ bm.to_mesh(me)
+ finally:
+ bm.free()
+ me.update()
+
+
+def build(duplicate=True):
+ bpy.ops.wm.read_factory_settings(use_empty=True)
+ me = bpy.data.meshes.new("CoincidentCubes")
+ bm = bmesh.new()
+ try:
+ bmesh.ops.create_cube(bm, size=2.0)
+ if duplicate:
+ bmesh.ops.create_cube(bm, size=2.0)
+ bm.to_mesh(me)
+ finally:
+ bm.free()
+ ob = bpy.data.objects.new("CoincidentCubes", me)
+ bpy.context.scene.collection.objects.link(ob)
+ return ob
+
+
+def check(ob, weld):
+ 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 props]
+ if missing:
+ print(f"ERROR: exporter RNA drifted, missing {missing}", file=sys.stderr)
+ return 2
+ me = ob.data
+ uniq = unique_count(me)
+ valence = edge_valence(me)
+ if (
+ len(me.vertices) != VERTS
+ or uniq != UNIQUE
+ or len(me.polygons) != FACES
+ or len(me.edges) != EDGES
+ or valence != {2: EDGES}
+ ):
+ print(
+ f"ERROR: pathology missing: V={len(me.vertices)} unique={uniq} "
+ f"F={len(me.polygons)} E={len(me.edges)} valence={valence}",
+ file=sys.stderr,
+ )
+ return 3
+ if weld:
+ weld_mesh(me)
+ tmp = tempfile.mkdtemp(prefix="coincident_")
+ path = os.path.join(tmp, "dup.gltf").replace("\\", "/")
+ bpy.ops.export_scene.gltf(filepath=path, **EXPORT_KWARGS)
+ g, acc = read_gltf(path)
+ prim = g["meshes"][0]["primitives"][0]
+ pos = acc(prim["attributes"]["POSITION"], 3)
+ pos_u = {(round(p[0], 5), round(p[1], 5), round(p[2], 5)) for p in pos}
+ nidx = g["accessors"][prim["indices"]]["count"]
+ tris = nidx // 3
+ if len(pos) != GLTF_POS or tris != GLTF_TRIS or len(pos_u) != UNIQUE:
+ print(
+ f"ERROR: export handling failed pos={len(pos)} unique={len(pos_u)} "
+ f"tris={tris}",
+ file=sys.stderr,
+ )
+ return 4
+ print(
+ f"V={VERTS} unique={UNIQUE} F={FACES} valence=2×{EDGES} "
+ f"gltf_pos={len(pos)} gltf_tris={tris} gltf_unique={len(pos_u)}"
+ )
+ return 0
+
+
+def main():
+ argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
+ p = argparse.ArgumentParser()
+ p.add_argument(
+ "--no-duplicate",
+ action="store_true",
+ help="falsification: one cube; unique == V",
+ )
+ p.add_argument(
+ "--weld",
+ action="store_true",
+ help="falsification: remove_doubles after pre-assert; glTF tris 12",
+ )
+ args = p.parse_args(argv)
+ ob = build(duplicate=not args.no_duplicate)
+ code = check(ob, weld=args.weld)
+ if code:
+ return code
+ print("coincident-vert-weld OK")
+ return 0
+
+
+if __name__ == "__main__":
+ try:
+ sys.exit(main())
+ except Exception as exc:
+ print(f"ERROR: {type(exc).__name__}: {exc}", file=sys.stderr)
+ sys.exit(1)
diff --git a/examples/ngon-triangulate/README.md b/examples/ngon-triangulate/README.md
new file mode 100644
index 0000000..50acdb2
--- /dev/null
+++ b/examples/ngon-triangulate/README.md
@@ -0,0 +1,31 @@
+# N-gon triangulate
+
+Synthesizes one hexagon by dissolving a cube edge, then asserts the
+hygiene / tangent contracts on that defective mesh. Inverse of
+[`mesh-hygiene-audit`](../mesh-hygiene-audit/) (clean mesh, no ngons)
+and neighbor of [`triangulate-tangents`](../triangulate-tangents/)
+(`calc_tangents` aborts on any n-gon).
+
+**Why this pathology:** the audit's n-gon gate is a snapshot on clean
+geometry. AI-generated meshes often leave dissolved n-gons; glTF
+silently triangulates, so an export-only check is vacuous (12 tris
+either way).
+
+**Pre-assertion (pathology exists):** exactly **1** face with **6**
+loops, **5** faces total. `--no-dissolve` exits 3.
+
+**Handling (second axis):** `Mesh.calc_tangents` aborts with
+`tris/quads` until `bmesh.ops.triangulate` on that face; afterward
+**4** tris + **4** quads, **28** loops, tangents succeed. Count-only
+glTF tris = 12 for a cube *or* this mesh. `--skip-triangulate` exits 4.
+
+No gallery still. A hexagon on a cube does not read at thumbnail
+without fake annotation.
+
+## Run
+
+```bash
+blender --background --python ngon_triangulate.py --
+blender --background --python ngon_triangulate.py -- --no-dissolve
+blender --background --python ngon_triangulate.py -- --skip-triangulate
+```
diff --git a/examples/ngon-triangulate/ngon_triangulate.py b/examples/ngon-triangulate/ngon_triangulate.py
new file mode 100644
index 0000000..394d7ba
--- /dev/null
+++ b/examples/ngon-triangulate/ngon_triangulate.py
@@ -0,0 +1,180 @@
+"""N-gon topology pathology — a runnable example.
+
+Dissolves one cube edge into a hexagon, then proves the n-gon exists
+before asserting how Blender handles it. Hygiene's "no ngons" gate
+(`mesh-hygiene-audit`) is the inverse: that example audits a clean
+mesh. This one synthesizes the defect.
+
+Closed form (cube size 2, dissolve the +X+Y edge):
+
+* 5 faces, 1 n-gon, that face has 6 loops
+* ``Mesh.calc_tangents`` aborts until triangulated
+ (same abort ``triangulate-tangents`` documents)
+* triangulate the n-gon → 4 tris + 4 quads, 28 loops, tangents succeed
+
+glTF tri count is 12 either way (hexagon+quads or a cube) — not a
+witness. Loop count of the n-gon is the second axis.
+
+No gallery still. A hexagon on a cube does not read at thumbnail
+without fake annotation (same call ``mesh-hygiene-audit`` made).
+
+ blender --background --python ngon_triangulate.py --
+"""
+import argparse
+import sys
+
+import bpy
+import bmesh
+
+sys.dont_write_bytecode = True
+
+NGON_COUNT = 1
+NGON_LOOPS = 6
+FACES_BEFORE = 5
+TRIS_AFTER = 4
+QUADS_AFTER = 4
+LOOPS_AFTER = 28
+TANGENT_ABORT = "tris/quads"
+
+
+def ngons(me):
+ return [p for p in me.polygons if len(p.vertices) > 4]
+
+
+def build(dissolve=True):
+ bpy.ops.wm.read_factory_settings(use_empty=True)
+ me = bpy.data.meshes.new("NgonCube")
+ bm = bmesh.new()
+ try:
+ bmesh.ops.create_cube(bm, size=2.0)
+ if dissolve:
+ bm.edges.ensure_lookup_table()
+ edge = None
+ for e in bm.edges:
+ mid = (e.verts[0].co + e.verts[1].co) / 2.0
+ if abs(mid.x - 1.0) < 1e-8 and abs(mid.y - 1.0) < 1e-8:
+ edge = e
+ break
+ if edge is None:
+ raise RuntimeError("dissolve edge +X+Y not found")
+ bmesh.ops.dissolve_edges(bm, edges=[edge])
+ bm.to_mesh(me)
+ finally:
+ bm.free()
+ ob = bpy.data.objects.new("NgonCube", me)
+ bpy.context.scene.collection.objects.link(ob)
+ if not me.uv_layers:
+ me.uv_layers.new(name="UVMap")
+ return ob
+
+
+def triangulate_ngons(me):
+ bm = bmesh.new()
+ try:
+ bm.from_mesh(me)
+ faces = [f for f in bm.faces if len(f.verts) > 4]
+ if faces:
+ bmesh.ops.triangulate(bm, faces=faces)
+ bm.to_mesh(me)
+ finally:
+ bm.free()
+ me.update()
+
+
+def tangents_aborted(me):
+ try:
+ me.calc_tangents()
+ except RuntimeError as exc:
+ return TANGENT_ABORT in str(exc)
+ return False
+
+
+def check(ob, skip_triangulate):
+ me = ob.data
+ found = ngons(me)
+ if len(found) != NGON_COUNT:
+ print(
+ f"ERROR: pathology missing: ngon count {len(found)} != {NGON_COUNT}",
+ file=sys.stderr,
+ )
+ return 3
+ loops = len(found[0].vertices)
+ if loops != NGON_LOOPS:
+ print(
+ f"ERROR: pathology missing: n-gon loops {loops} != {NGON_LOOPS}",
+ file=sys.stderr,
+ )
+ return 3
+ if len(me.polygons) != FACES_BEFORE:
+ print(
+ f"ERROR: pathology missing: faces {len(me.polygons)} != {FACES_BEFORE}",
+ file=sys.stderr,
+ )
+ return 3
+ if not tangents_aborted(me):
+ print(
+ "ERROR: calc_tangents did not abort on the constructed n-gon",
+ file=sys.stderr,
+ )
+ return 4
+ if skip_triangulate:
+ print(
+ "ERROR: skip-triangulate left the n-gon; handling unrepaired",
+ file=sys.stderr,
+ )
+ return 4
+ triangulate_ngons(me)
+ leftover = ngons(me)
+ tris = sum(1 for p in me.polygons if len(p.vertices) == 3)
+ quads = sum(1 for p in me.polygons if len(p.vertices) == 4)
+ if leftover or tris != TRIS_AFTER or quads != QUADS_AFTER:
+ print(
+ f"ERROR: triangulate handling failed ngons={len(leftover)} "
+ f"tris={tris} quads={quads}",
+ file=sys.stderr,
+ )
+ return 4
+ if len(me.loops) != LOOPS_AFTER:
+ print(
+ f"ERROR: loop count {len(me.loops)} != {LOOPS_AFTER}",
+ file=sys.stderr,
+ )
+ return 4
+ if tangents_aborted(me):
+ print("ERROR: calc_tangents still aborting after triangulate", file=sys.stderr)
+ return 4
+ print(
+ f"ngons={NGON_COUNT} loops={NGON_LOOPS} faces_before={FACES_BEFORE} "
+ f"tris={tris} quads={quads} loops_after={len(me.loops)} tangents=ok"
+ )
+ return 0
+
+
+def main():
+ argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
+ p = argparse.ArgumentParser()
+ p.add_argument(
+ "--no-dissolve",
+ action="store_true",
+ help="falsification: skip the dissolve so the n-gon is absent",
+ )
+ p.add_argument(
+ "--skip-triangulate",
+ action="store_true",
+ help="falsification: leave the n-gon; tangents stay aborted",
+ )
+ args = p.parse_args(argv)
+ ob = build(dissolve=not args.no_dissolve)
+ code = check(ob, skip_triangulate=args.skip_triangulate)
+ if code:
+ return code
+ print("ngon-triangulate OK")
+ return 0
+
+
+if __name__ == "__main__":
+ try:
+ sys.exit(main())
+ except Exception as exc:
+ print(f"ERROR: {type(exc).__name__}: {exc}", file=sys.stderr)
+ sys.exit(1)
diff --git a/examples/unapplied-scale-gltf/README.md b/examples/unapplied-scale-gltf/README.md
new file mode 100644
index 0000000..2d5c988
--- /dev/null
+++ b/examples/unapplied-scale-gltf/README.md
@@ -0,0 +1,33 @@
+# Unapplied scale glTF
+
+Synthesizes unapplied non-uniform object scale on a unit cube, then
+asserts the glTF exporter contract. Inverse of
+[`gltf-export-roundtrip`](../gltf-export-roundtrip/) (identity scale,
+node has no scale) and neighbor of
+[`prop-origin-transform`](../prop-origin-transform/) (bake to `(1,1,1)`
+in Blender).
+
+**Why this pathology:** `export_apply` RNA is "Apply modifiers … to mesh
+objects". AI code treats it as "apply object transforms". Unapplied
+scale lands on the glTF node, Y-up permuted; POSITION stays local.
+
+**Pre-assertion (pathology exists):** `obj.scale == (2, 1, 0.5)`
+(non-uniform) **and** local verts at ±1 on every axis. `--identity`
+exits 3.
+
+**Handling (second axis):** with `export_apply=True`, node.scale is
+`(2, 0.5, 1)` (`(sx, sz, sy)` from `(x,y,z)→(x,z,−y)`) and POSITION
+bbox stays ±1. Vert count is 8 either way. `--bake` after the
+pre-assert exits 4 (node.scale missing, POSITION x ±2).
+
+Same on 4.5 LTS and 5.2 LTS. Not a version split.
+
+No gallery still. A stretched box looks like modeled non-uniform size.
+
+## Run
+
+```bash
+blender --background --python unapplied_scale_gltf.py --
+blender --background --python unapplied_scale_gltf.py -- --identity
+blender --background --python unapplied_scale_gltf.py -- --bake
+```
diff --git a/examples/unapplied-scale-gltf/unapplied_scale_gltf.py b/examples/unapplied-scale-gltf/unapplied_scale_gltf.py
new file mode 100644
index 0000000..6261234
--- /dev/null
+++ b/examples/unapplied-scale-gltf/unapplied_scale_gltf.py
@@ -0,0 +1,184 @@
+"""Unapplied non-uniform scale → glTF — a runnable example.
+
+A 2 m cube (local verts at ±1) with object scale (2, 1, 0.5). Proves
+the scale is unapplied *and* non-uniform before asserting the exporter
+contract. ``export_apply`` applies **modifiers**, not object scale
+(RNA description). Neighbor of ``gltf-export-roundtrip`` (identity
+scale, node has no scale) and ``prop-origin-transform`` (data-API bake
+to (1,1,1) in Blender, not on disk).
+
+Closed form:
+
+* ``obj.scale == (2, 1, 0.5)`` and local bbox ±1 on every axis
+* glTF node.scale is Y-up permuted ``(sx, sz, sy) == (2, 0.5, 1)``
+* POSITION accessor stays the local cube (±1)
+
+Vert count is 8 with or without the scale. Node scale + local POSITION
+are the second axis.
+
+No gallery still. A stretched box is indistinguishable from modeled
+non-uniform dimensions; the defect is unapplied vs baked.
+
+ blender --background --python unapplied_scale_gltf.py --
+"""
+import argparse
+import json
+import os
+import struct
+import sys
+import tempfile
+
+import bpy
+import bmesh
+
+sys.dont_write_bytecode = True
+
+SCALE = (2.0, 1.0, 0.5)
+YUP_NODE_SCALE = (2.0, 0.5, 1.0)
+LOCAL = 1.0
+EPS = 1e-5
+
+EXPORT_KWARGS = dict(
+ export_format="GLTF_SEPARATE",
+ export_apply=True,
+ export_yup=True,
+ export_texcoords=False,
+ export_normals=False,
+ export_materials="NONE",
+ export_animations=False,
+ export_image_format="NONE",
+)
+
+
+def read_gltf(path):
+ g = json.load(open(path, encoding="utf-8"))
+ 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
+
+
+def local_extent(me):
+ xs = [v.co.x for v in me.vertices]
+ ys = [v.co.y for v in me.vertices]
+ zs = [v.co.z for v in me.vertices]
+ return (
+ (min(xs), max(xs)),
+ (min(ys), max(ys)),
+ (min(zs), max(zs)),
+ )
+
+
+def extents_unit(ext):
+ return all(
+ abs(lo + LOCAL) < EPS and abs(hi - LOCAL) < EPS for lo, hi in ext
+ )
+
+
+def bake_scale(ob):
+ me = ob.data
+ mw = ob.matrix_world.copy()
+ me.transform(mw.to_3x3().to_4x4())
+ me.update()
+ ob.scale = (1.0, 1.0, 1.0)
+ bpy.context.view_layer.update()
+
+
+def build(identity=False):
+ bpy.ops.wm.read_factory_settings(use_empty=True)
+ me = bpy.data.meshes.new("UnitCube")
+ bm = bmesh.new()
+ try:
+ bmesh.ops.create_cube(bm, size=2.0)
+ bm.to_mesh(me)
+ finally:
+ bm.free()
+ ob = bpy.data.objects.new("Scaled", me)
+ bpy.context.scene.collection.objects.link(ob)
+ if not identity:
+ ob.scale = SCALE
+ bpy.context.view_layer.update()
+ return ob
+
+
+def check(ob, bake):
+ 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 props]
+ if missing:
+ print(f"ERROR: exporter RNA drifted, missing {missing}", file=sys.stderr)
+ return 2
+ me = ob.data
+ got = tuple(float(c) for c in ob.scale)
+ non_uniform = len({round(c, 6) for c in got}) > 1
+ ext = local_extent(me)
+ if got != SCALE or not non_uniform or not extents_unit(ext):
+ print(
+ f"ERROR: pathology missing: scale={got} non_uniform={non_uniform} "
+ f"local={ext}",
+ file=sys.stderr,
+ )
+ return 3
+ if bake:
+ bake_scale(ob)
+ tmp = tempfile.mkdtemp(prefix="unapplied_scale_")
+ path = os.path.join(tmp, "scaled.gltf").replace("\\", "/")
+ bpy.ops.export_scene.gltf(filepath=path, **EXPORT_KWARGS)
+ g, acc = read_gltf(path)
+ node = g["nodes"][0]
+ pos = acc(g["meshes"][0]["primitives"][0]["attributes"]["POSITION"], 3)
+ xs, ys, zs = zip(*pos)
+ node_scale = tuple(node["scale"]) if node.get("scale") is not None else None
+ pos_ext = ((min(xs), max(xs)), (min(ys), max(ys)), (min(zs), max(zs)))
+ if node_scale != YUP_NODE_SCALE or not extents_unit(pos_ext) or len(pos) != 8:
+ print(
+ f"ERROR: export handling failed node.scale={node_scale} "
+ f"pos_ext={pos_ext} n={len(pos)}",
+ file=sys.stderr,
+ )
+ return 4
+ print(
+ f"scale={got} yup_node_scale={node_scale} local=±{LOCAL} "
+ f"pos_n={len(pos)} export_apply=modifiers-only"
+ )
+ return 0
+
+
+def main():
+ argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
+ p = argparse.ArgumentParser()
+ p.add_argument(
+ "--identity",
+ action="store_true",
+ help="falsification: leave scale (1,1,1); pathology absent",
+ )
+ p.add_argument(
+ "--bake",
+ action="store_true",
+ help="falsification: bake scale after the pre-assert; node.scale gone",
+ )
+ args = p.parse_args(argv)
+ ob = build(identity=args.identity)
+ code = check(ob, bake=args.bake)
+ if code:
+ return code
+ print("unapplied-scale-gltf OK")
+ return 0
+
+
+if __name__ == "__main__":
+ try:
+ sys.exit(main())
+ except Exception as exc:
+ print(f"ERROR: {type(exc).__name__}: {exc}", file=sys.stderr)
+ sys.exit(1)
diff --git a/skills/depsgraph-and-evaluated-data/SKILL.md b/skills/depsgraph-and-evaluated-data/SKILL.md
index 293dd22..1065e3f 100644
--- a/skills/depsgraph-and-evaluated-data/SKILL.md
+++ b/skills/depsgraph-and-evaluated-data/SKILL.md
@@ -179,6 +179,7 @@ When you build your own exporter on top of `evaluated_depsgraph_get()`, the deps
- **Using the raw object's `matrix_world` after evaluating**. `obj.matrix_world` and `obj_eval.matrix_world` may differ (parent constraints evaluate during depsgraph). Use `obj_eval.matrix_world` for world-space positions.
- **Calling `to_mesh()` inside a tight loop without clearing**. Each iteration leaks a temp mesh. Even with the right intent, this exhausts memory fast.
- **USD `evaluation_mode` without `export_subdivision='TESSELLATE'`**. Default `BEST_MATCH` writes the cage plus `subdivisionScheme = catmullClark`, so RENDER and VIEWPORT files match and the mode looks like a no-op.
+- **`export_apply=True` as "apply object transforms".** RNA is "Apply modifiers (excluding Armatures) to mesh objects". Unapplied non-uniform object scale lands on the glTF node, Y-up permuted `(sx, sz, sy)`; POSITION stays local. Witness: `examples/unapplied-scale-gltf/`.
## Version correctness
@@ -193,6 +194,7 @@ In 5.0 the underlying Animation 2025 work changed how armature evaluation intera
- Snippet `depsgraph-evaluated-mesh.py` for the minimal copy-paste pattern.
- Snippet `usd-export-evaluation-mode.py` for the exporter parameter.
- Example `usd-export-evaluation-mode` for the TESSELLATE closed form versus the BEST_MATCH cage.
+- Example `unapplied-scale-gltf` for object scale vs `export_apply` (modifiers only).
## References
diff --git a/skills/mesh-editing-and-bmesh/SKILL.md b/skills/mesh-editing-and-bmesh/SKILL.md
index 1080cef..27e5092 100644
--- a/skills/mesh-editing-and-bmesh/SKILL.md
+++ b/skills/mesh-editing-and-bmesh/SKILL.md
@@ -266,6 +266,10 @@ After mutating selection, call `bm.select_flush_mode()` if you've changed indivi
7. **Wrong dtype in `foreach_set`** (`float64` instead of `float32`). Silently writes garbage on some platforms.
+8. **Trusting glTF as an n-gon witness.** The exporter always triangulates. A cube and a hexagon-from-dissolved-edge both ship 12 tris. Hygiene (`len(poly.vertices) <= 4`) and `Mesh.calc_tangents` (aborts on n-gons) must run on the Blender mesh. Witness: `examples/ngon-triangulate/`.
+
+9. **Treating coincident duplicate shells as non-manifold.** Two cubes occupying the same space are still valence-2. They ship as extra glTF triangles until `bmesh.ops.remove_doubles`. Witness: `examples/coincident-vert-weld/`.
+
## Related
- `headless-batch-scripting` for using these patterns in CLI scripts
@@ -273,6 +277,8 @@ After mutating selection, call `bm.select_flush_mode()` if you've changed indivi
- Rule `prefer-data-over-ops-in-loops`
- Rule `always-free-bmesh`
- Snippet `canonical-object-creation.py`, `canonical-object-deletion.py`, `bmesh-load-edit-free.py`, `depsgraph-evaluated-mesh.py`, `foreach-set-vertices.py`
+- Example `ngon-triangulate` for synthesized n-gons vs `calc_tangents` / triangulate
+- Example `coincident-vert-weld` for authored duplicate shells vs glTF
## References
diff --git a/tests/smoke/catalog.json b/tests/smoke/catalog.json
index a11d421..dd90dad 100644
--- a/tests/smoke/catalog.json
+++ b/tests/smoke/catalog.json
@@ -64,5 +64,8 @@
"min_version": "5.1",
"expect_sidecar": "$OUT/exit-pre-sidecar.sidecar",
"sidecar_contains": "exit_pre-ok"
- }
+ },
+ {"name": "ngon-triangulate", "script": "examples/ngon-triangulate/ngon_triangulate.py"},
+ {"name": "unapplied-scale-gltf", "script": "examples/unapplied-scale-gltf/unapplied_scale_gltf.py"},
+ {"name": "coincident-vert-weld", "script": "examples/coincident-vert-weld/coincident_vert_weld.py"}
]
@@ -954,6 +954,51 @@ while **`BYTE_COLOR` is sRGB-encoded 8-bit** — 0.735 reads back
Companion to [`color-attribute-wheel`](examples/color-attribute-wheel/) and
[`attribute-domain-shear`](examples/attribute-domain-shear/).
+
+
+
+
+
+check-only, no gallery still — a hexagon on a cube is not thumbnail-legible
+
+
+
+### [ngon-triangulate](examples/ngon-triangulate/)
+
+Synthesizes one 6-loop hexagon by dissolving a cube edge. Pre-asserts the
+n-gon exists (1 face / 6 loops / 5 faces), then `calc_tangents` aborts until
+`bmesh.ops.triangulate` yields 4 tris + 4 quads / 28 loops. glTF tri count
+is 12 either way. Inverse of [`mesh-hygiene-audit`](examples/mesh-hygiene-audit/).
+
+
+
+
+
+check-only, no gallery still — unapplied scale looks like modeled non-uniform size
+
+
+
+### [unapplied-scale-gltf](examples/unapplied-scale-gltf/)
+
+Unit cube with object scale `(2, 1, 0.5)`. Pre-asserts unapplied non-uniform
+scale and local ±1 verts, then glTF `export_apply=True` still writes Y-up
+node.scale `(2, 0.5, 1)` with local POSITION. `export_apply` is modifiers
+only. Inverse of [`gltf-export-roundtrip`](examples/gltf-export-roundtrip/).
+
+
+
+
+check-only, no gallery still — coincident cubes look like one cube
+
+
+
+### [coincident-vert-weld](examples/coincident-vert-weld/)
+
+Two cubes in one mesh: 16 verts / 8 unique / still manifold. Pre-asserts
+the duplicates, then glTF ships 24 tris / 48 positions / 8 unique.
+`remove_doubles` collapses to one cube. Inverse of
+[`degenerate-bevel-weld`](examples/degenerate-bevel-weld/).
+