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/.mdc # 6 rule files templates// # 2 starter templates snippets/.py # 17 standalone Python snippets - examples// # 50 runnable smoke-gated examples (+ gallery.json) + examples// # 53 runnable smoke-gated examples (+ gallery.json) examples/gallery_framing.py # shared Layer 1 framing measurement (render path only) scripts/build_gallery.py # generates docs/gallery/ (stdlib only) scripts/site/ # vendored landing-page build (build_site.py + template) diff --git a/CLAUDE.md b/CLAUDE.md index 26fbe7b..0df8cf9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,7 @@ skills//SKILL.md - AI workflow definitions, 13 total rules/.mdc - Anti-pattern rules, 6 total templates// - Starter projects, 2 total snippets/.py - Standalone code patterns, 17 total -examples// - Runnable smoke-gated examples, 50 total (+ gallery.json) +examples// - Runnable smoke-gated examples, 53 total (+ gallery.json) scripts/build_gallery.py - Regenerates docs/gallery/ from gallery.json (stdlib only) scripts/site/ - Vendored landing-page build (Jinja2) docs/gallery/ - Committed generated gallery pages + hero renders @@ -83,7 +83,7 @@ v0.1.0: canonical object creation and deletion, depsgraph evaluated mesh, bmesh v0.2.0: Principled BSDF material, driver-with-custom-function via `driver_namespace`, application handler registration, shader node group with cross-version `interface` API, `foreach_get` bulk vertex read, version-branch skeleton, and USD export with `evaluation_mode='RENDER'`. -## Examples (50) +## Examples (53) Runnable scripts at `examples//`, each asserting a real API contract with deterministic checks (exit non-zero on failure) and optionally rendering a still via diff --git a/README.md b/README.md index 33be696..70cec78 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@

- 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 @@ -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/). +
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"} ]