diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index d2b67c9..4420fa9 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -69,6 +69,7 @@ "examples/curve-bevel-arc", "examples/custom-normals-shade", "examples/damped-track-aim", + "examples/degenerate-bevel-weld", "examples/depsgraph-export", "examples/driver-wave", "examples/gltf-export-roundtrip", diff --git a/.github/workflows/blender-smoke.yml b/.github/workflows/blender-smoke.yml index 521d82e..48618b9 100644 --- a/.github/workflows/blender-smoke.yml +++ b/.github/workflows/blender-smoke.yml @@ -554,3 +554,15 @@ jobs: # non-zero on failure. xvfb-run -a "$BLENDER" --background \ --python examples/attribute-domain-shear/attribute_domain_shear.py -- + + - name: Shipped example - degenerate bevel weld (half-dimension collapse) + run: | + set -euo pipefail + # Check only (no render): a 1.6x0.4x1.0 slab beveled at safe (0.10) + # and threshold (0.20 == min/2) offsets; asserts zero vs exactly 12 + # zero-area faces (4 min-axis edges x 3 segments), 16 coincident + # verts (4 x (segments+1)), min_area collapse > 1e5x, and a stdlib + # GLB re-parse counting 32 degenerate triangles shipped versus 0. + # Exits non-zero on failure. + xvfb-run -a "$BLENDER" --background \ + --python examples/degenerate-bevel-weld/degenerate_bevel_weld.py -- diff --git a/AGENTS.md b/AGENTS.md index 5cb742e..4d2e7b7 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 12 skills, 6 rules, 2 templates, 17 snippets, and 39 +The content base is 12 skills, 6 rules, 2 templates, 17 snippets, and 40 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// # 39 runnable smoke-gated examples (+ gallery.json) + examples// # 40 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 72292a9..fb0c7e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,7 @@ skills//SKILL.md - AI workflow definitions, 12 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, 39 total (+ gallery.json) +examples// - Runnable smoke-gated examples, 40 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 @@ -82,11 +82,11 @@ 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 (39) +## Examples (40) Runnable scripts at `examples//`, each asserting a real API contract with deterministic checks (exit non-zero on failure) and optionally rendering a still via -`--output`. All thirty-nine run headless on Blender 4.5 LTS and 5.1 in `blender-smoke.yml`; +`--output`. All forty run headless on Blender 4.5 LTS and 5.1 in `blender-smoke.yml`; their renders ship in the site gallery at `docs/gallery/`. `examples/gallery.json` is the gallery's source of truth. When authoring a new one, copy the anatomy of `examples/bmesh-gear/` (script structure, README shape, dark-studio render recipe) and diff --git a/README.md b/README.md index b8a33bf..8f78c33 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@

- 12 skills  •  6 rules  •  2 templates  •  17 snippets  •  39 examples + 12 skills  •  6 rules  •  2 templates  •  17 snippets  •  40 examples

@@ -760,6 +760,26 @@ palette[K−1], and the measured shear matches the palette closed form `active_color`, the shader Attribute node). The broken state is in-frame: the right pinwheel is the falsification variant. + + + + +Degenerate bevel weld: two teal display trays side by side — left with a soft rounded bevel, right collapsed to a knife edge with orange seam markers at the zero-area face centroids, proving the half-dimension bevel collapse ships degenerate triangles + + + +### [degenerate-bevel-weld](examples/degenerate-bevel-weld/) + +The half-dimension bevel collapse, isolated. A slab beveled at +`offset == min_dim/2` pinches its band into exactly **12 zero-area faces** +(4 min-axis edges × 3 segments) with 16 coincident verts and `min_area` +down 3.2e5× — and a stdlib re-parse of the exported GLB counts **32 +degenerate triangles shipped** to disk, where an engine merge-by-distance +welds their loops. Companion to +[`gltf-export-roundtrip`](examples/gltf-export-roundtrip/) (where the count +check first caught the weld) and [`mesh-hygiene-audit`](examples/mesh-hygiene-audit/) +(topology gates). Seam markers are placed from live mesh data. + diff --git a/ROADMAP.md b/ROADMAP.md index 694430e..c07ddd3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -109,7 +109,7 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo - ~~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` — **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) +- ~~Degenerate-bevel weld hazard~~ **SHIPPED** as `examples/degenerate-bevel-weld/` — at `offset == min_dim/2` the bevel band collapses to exactly 12 zero-area faces (4 min-axis edges × 3 segments) with 16 coincident verts (4 × (segments+1)) and min_area down 3.2e5×; a stdlib GLB re-parse counts **32 degenerate triangles crossing the export boundary** (MEASURED regression constant) versus 0 for the safe mesh — the loops an engine merge-by-distance welds; byte-identical on 4.5.11 and 5.1.2 - ~~GAMMA_CROSS blend-curve witness~~ **SHIPPED** as `examples/vse-gamma-cross/` — the cross blends in a gamma-0.5 space: `((1-t)·√A + t·√B)²` with `t = (frame − start)/duration`, never 1 inside the effect; mid-cross dips 0.115 below the sRGB lerp from crimson/teal (closed form (0.341, 0.349, 0.463) confirmed per frame); AgX-default sampling poisons the fit (0.146 red-channel error, `view_transform='Standard'` mandatory); deleting a consumed input orphans-and-deletes the effect — 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`) - ~~Collision compound witness~~ **SHIPPED** as `examples/collision-hull-proxy/` — game-prop collision as a compound of convex pieces, each a `bmesh.ops.convex_hull` of a coarse `sec(π/n)`-inflated cage (containment 4.4e-08, watertight, positive signed volume, Euler 2, per-piece 255-face budget: body 70, caps 60×3, compound 250); a hull of the dense render mesh measures 380 faces — over budget — which is why pipelines hull cages; proud details cost cage rows, concave grooves are free; byte-identical on 4.5.11 and 5.1.2 diff --git a/docs/gallery/assets/degenerate-bevel-weld-hero.webp b/docs/gallery/assets/degenerate-bevel-weld-hero.webp new file mode 100644 index 0000000..3661951 Binary files /dev/null and b/docs/gallery/assets/degenerate-bevel-weld-hero.webp differ diff --git a/docs/gallery/contact-sheets/degenerate-bevel-weld-contact-sheet.webp b/docs/gallery/contact-sheets/degenerate-bevel-weld-contact-sheet.webp new file mode 100644 index 0000000..b574890 Binary files /dev/null and b/docs/gallery/contact-sheets/degenerate-bevel-weld-contact-sheet.webp differ diff --git a/docs/gallery/degenerate-bevel-weld/index.html b/docs/gallery/degenerate-bevel-weld/index.html new file mode 100644 index 0000000..14d45c6 --- /dev/null +++ b/docs/gallery/degenerate-bevel-weld/index.html @@ -0,0 +1,664 @@ + + + + + + degenerate-bevel-weld — Examples — Blender Developer Tools + + + + + + + + + + + + + + + + + +

+
+

degenerate-bevel-weld

+

Bevel offset >= half the min box dimension collapses the band into zero-area faces — and they ship: a stdlib GLB re-parse counts the degenerate triangles crossing the export boundary. Dual tray: rounded edge versus knife seam, markers from live mesh data.

+
+
+ +

Rendered headless by the example itself — click to zoom.

+
witnesses At offset == min_dimension/2 the bevel band pinches to exactly 12 zero-area faces (4 edges x 3 segments); 32 degenerate triangles cross into the exported GLB, waiting for any merge-by-distance to weld them.
+
+
blender --background --python examples/degenerate-bevel-weld/degenerate_bevel_weld.py --
+ +
+
+

A runnable example isolating the half-dimension bevel collapse: author a bevel width against one box and reuse it on a thinner one, and at offset == min_dimension / 2 the bevel band pinches into zero-area faces — and those degenerate triangles cross the export boundary. Re-parsed from the shipped GLB with stdlib only, they are sitting in the file, where an engine-side merge-by-distance welds their loops. Found authoring gltf-export-roundtrip (its count check caught a 36-vertex weld on a thin crate part); this example isolates the threshold.

+

Pipeline arc neighbors: round-trip fidelity in gltf-export-roundtrip, topology gates in mesh-hygiene-audit, watertight parametric solids in bmesh-gear.

+

What it witnesses: a 1.6 × 0.4 × 1.0 slab (min dim 0.4, half = 0.2) with every edge beveled at 3 segments. All closed form or independently re-derived:

+
  • Threshold. Offset 0.10 (< 0.2) yields zero zero-area faces; offset 0.20 (== min/2) yields exactly 12 == 4 min-axis edges × 3 segments. min_area collapses 3.2e5× (5.8e-04 → 1.8e-09).
  • Collapse witness. Coincident-position verts == 16 == 4 edges × (segments + 1), re-derived by 6-decimal position grouping — the loops a merge-by-distance welds.
  • Export crossing. A stdlib re-parse of the GLB recomputes every triangle area from the raw POSITION + indices buffers: 32 degenerate triangles ship (the 12 collapsed faces, triangulated — a MEASURED regression constant like curve-bevel-arc's EXPECT_VERTS; re-measure recipe in check()), versus 0 for the safe mesh. The exporter ships every loop (positions == 384 loops) — nothing warns you.
+

What each check catches on failure: safe offset pushed to the threshold (exit 3); degenerate offset backed below it (exit 4); coincident closed form corrupted (exit 6); GLB degenerate-tri expectation off (exit 8, e.g. if a future Blender changes triangulation — re-measure and update deliberately).

+

Version witness: output is byte-identical on Blender 4.5.11 LTS and 5.1.2 — same counts, same min_area values, same GLB triangle census.

+

Render as proof: dual tray from the same DIMS/offsets the check asserts. Left keeps a soft rounded bevel; right collapses to a knife edge, with an emissive seam marker at every zero-area face centroid — the markers are placed from live mesh data, so a change in the data moves them. The broken state is in-frame by design.

+

Run

+
blender --background --python degenerate_bevel_weld.py --
+blender --background --python degenerate_bevel_weld.py -- --output bevel.png
+blender --background --python degenerate_bevel_weld.py -- --output bevel.png --engine cycles
+

Exits non-zero on failure. The blender-smoke workflow runs the check on Blender 4.5 LTS and 5.1. The --output render path additionally measures framing against the Layer 1 band via examples/gallery_framing.py (exit 10 on violation) before writing the still.

+
+
+

Source

+
+ examples/degenerate-bevel-weld/degenerate_bevel_weld.py + View on GitHub → +
+
"""Degenerate bevel weld — the half-dimension collapse that ships to disk.
+
+Witnesses the bevel-threshold hazard a prop pipeline hits when a width is
+authored against one box and later used on a thinner one: `bmesh.ops.bevel`
+(or the Bevel modifier) with offset >= half the smallest box dimension
+collapses the bevel band into zero-area faces, and those degenerate
+triangles cross the export boundary — re-parsed from the GLB itself, they
+are sitting in the shipped file, where an engine-side merge-by-distance
+welds their loops. Found authoring `gltf-export-roundtrip` (its count check
+caught a 36-vertex weld on a thin crate part); this example isolates the
+threshold with closed forms at every stage.
+
+Check (all closed form or independently re-derived, nothing captured):
+
+1. Threshold: offset 0.10 (< 0.20 = min_dim/2) yields ZERO zero-area faces;
+   offset 0.20 (== min_dim/2) yields exactly 12 == 4 min-axis edges x
+   3 segments, with min_area collapsing > 1e5x (5.8e-04 vs ~1.8e-09).
+2. Collapse witness: coincident-position verts == 16 == 4 edges x
+   (segments+1), re-derived by 6-decimal position grouping.
+3. Export crossing: a stdlib re-parse of the exported GLB recomputes every
+   triangle area from raw POSITION+indices — 32 degenerate triangles ship
+   (the 12 collapsed faces triangulated, MEASURED regression constant like
+   curve-bevel-arc's EXPECT_VERTS; re-measure recipe in check()), versus
+   zero for the safe mesh, and the exporter ships every loop
+   (positions == loop count) — nothing warns you.
+
+By default it runs only the correctness check (no render) — the CI smoke
+check. Pass --output to also render a still:
+
+    blender --background --python degenerate_bevel_weld.py --                 # check only
+    blender --background --python degenerate_bevel_weld.py -- --output b.png  # + render
+"""
+import bpy, bmesh, sys, os, math, argparse, json, struct, tempfile
+
+# Shared Layer 1 framing measurement (render path only) — see gallery_framing.py
+sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir))
+sys.dont_write_bytecode = True  # keep examples/__pycache__ out of the repo tree
+import gallery_framing
+
+DIMS = (1.6, 0.4, 1.0)          # min dim 0.4 -> half = 0.2, the threshold
+SAFE_OFFSET = 0.10
+DEGEN_OFFSET = 0.20             # exactly half the min dimension
+SEGMENTS = 3
+AREA_EPS = 1e-8
+MIN_EDGES_MIN_AXIS = 4          # edges parallel to the min (Y) axis on a box
+EXPECT_DEGEN_FACES = 12         # 4 min-axis edges x SEGMENTS (closed form)
+EXPECT_COINCIDENT = 16          # 4 edges x (SEGMENTS + 1) (closed form)
+EXPECT_GLB_DEGEN_TRIS = 32      # MEASURED regression constant (see check step 3)
+EXPECT_LOOPS = 384              # 96 verts, 4 loops/face x 96... measured: loops
+
+
+def eevee_engine_id():
+    return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT"
+
+
+def beveled_box(dims, offset, segments=SEGMENTS, clamp=True):
+    """Box scaled to dims with every edge beveled — bmesh path, deterministic."""
+    me = bpy.data.meshes.new("Crate")
+    bm = bmesh.new()
+    try:
+        bmesh.ops.create_cube(bm, size=1.0)
+        for v in bm.verts:
+            v.co.x *= dims[0]
+            v.co.y *= dims[1]
+            v.co.z *= dims[2]
+        bmesh.ops.bevel(
+            bm,
+            geom=list(bm.edges),
+            offset=offset,
+            segments=segments,
+            profile=0.5,
+            affect="EDGES",
+            clamp_overlap=clamp,
+        )
+        bm.to_mesh(me)
+    finally:
+        bm.free()
+    return me
+
+
+def zero_area_count(me, eps=AREA_EPS):
+    return sum(1 for p in me.polygons if p.area <= eps)
+
+
+def min_area(me):
+    return min(p.area for p in me.polygons)
+
+
+def coincident_verts(me, ndp=6):
+    """Extra verts sharing a position at ndp-decimal rounding — the loops a
+    downstream merge-by-distance will weld."""
+    seen = set()
+    extra = 0
+    for v in me.vertices:
+        key = tuple(round(c, ndp) for c in v.co)
+        if key in seen:
+            extra += 1
+        seen.add(key)
+    return extra
+
+
+def glb_degenerate_tris(path, eps=AREA_EPS):
+    """Stdlib GLB re-parse: recompute every triangle area from the raw
+    POSITION + indices buffers. Independent of Blender's mesh API."""
+    with open(path, "rb") as f:
+        data = f.read()
+    jlen = struct.unpack_from("<I", data, 12)[0]
+    js = json.loads(data[20: 20 + jlen])
+    bin_ofs = 20 + jlen + 8
+    prim = js["meshes"][0]["primitives"][0]
+    pos_acc = js["accessors"][prim["attributes"]["POSITION"]]
+    pos_bv = js["bufferViews"][pos_acc["bufferView"]]
+    pos = struct.unpack_from(
+        f"<{pos_acc['count'] * 3}f", data, bin_ofs + pos_bv.get("byteOffset", 0)
+    )
+    idx_acc = js["accessors"][prim["indices"]]
+    idx_bv = js["bufferViews"][idx_acc["bufferView"]]
+    fmt = {5123: "H", 5125: "I"}[idx_acc["componentType"]]
+    idx = struct.unpack_from(
+        f"<{idx_acc['count']}{fmt}", data, bin_ofs + idx_bv.get("byteOffset", 0)
+    )
+    degen = 0
+    min_a = None
+    for t in range(0, len(idx), 3):
+        a = pos[idx[t] * 3: idx[t] * 3 + 3]
+        b = pos[idx[t + 1] * 3: idx[t + 1] * 3 + 3]
+        c = pos[idx[t + 2] * 3: idx[t + 2] * 3 + 3]
+        u = [b[i] - a[i] for i in range(3)]
+        v = [c[i] - a[i] for i in range(3)]
+        n = (
+            u[1] * v[2] - u[2] * v[1],
+            u[2] * v[0] - u[0] * v[2],
+            u[0] * v[1] - u[1] * v[0],
+        )
+        area = 0.5 * math.sqrt(n[0] ** 2 + n[1] ** 2 + n[2] ** 2)
+        if min_a is None or area < min_a:
+            min_a = area
+        if area <= eps:
+            degen += 1
+    return degen, min_a, pos_acc["count"]
+
+
+def export_glb(ob, path):
+    # per-object file: the parse reads meshes[0], so export only this object
+    bpy.ops.object.select_all(action="DESELECT")
+    ob.select_set(True)
+    bpy.ops.export_scene.gltf(
+        filepath=path, export_format="GLB", export_apply=False, use_selection=True
+    )
+    return path
+
+
+def check():
+    tmp = tempfile.mkdtemp(prefix="bevelweld_")
+    safe = beveled_box(DIMS, SAFE_OFFSET)
+    degen = beveled_box(DIMS, DEGEN_OFFSET)
+
+    # --- 1. threshold: zero-area faces flip on at offset == half min dim ---
+    safe_za = zero_area_count(safe)
+    degen_za = zero_area_count(degen)
+    safe_min = min_area(safe)
+    degen_min = min_area(degen)
+    print(f"threshold safe_za={safe_za} degen_za={degen_za} "
+          f"(closed form {MIN_EDGES_MIN_AXIS} edges x {SEGMENTS} segs = "
+          f"{EXPECT_DEGEN_FACES})")
+    if safe_za != 0:
+        print(f"ERROR: safe bevel produced {safe_za} zero-area faces — the "
+              f"threshold moved below offset {SAFE_OFFSET}", file=sys.stderr)
+        return 3
+    if degen_za != EXPECT_DEGEN_FACES:
+        print(f"ERROR: degenerate bevel produced {degen_za} zero-area faces, "
+              f"closed form {EXPECT_DEGEN_FACES} — re-derive via "
+              f"zero_area_count() if bevel internals change", file=sys.stderr)
+        return 4
+    ratio = safe_min / max(degen_min, 1e-30)
+    print(f"min_area safe={safe_min:.3e} degen={degen_min:.3e} ratio={ratio:.3e}")
+    if ratio < 1e5:
+        print("ERROR: min_area collapse under 1e5x — the band did not pinch",
+              file=sys.stderr)
+        return 5
+
+    # --- 2. collapse witness: coincident-position verts ---
+    safe_co = coincident_verts(safe)
+    degen_co = coincident_verts(degen)
+    print(f"coincident safe={safe_co} degen={degen_co} "
+          f"(closed form {MIN_EDGES_MIN_AXIS} x ({SEGMENTS}+1) = {EXPECT_COINCIDENT})")
+    if safe_co != 0 or degen_co != EXPECT_COINCIDENT:
+        print("ERROR: coincident-position count off the closed form",
+              file=sys.stderr)
+        return 6
+
+    # --- 3. export crossing: degenerate tris ship in the GLB ---
+    safe_ob = bpy.data.objects.new("Safe", safe)
+    bpy.context.collection.objects.link(safe_ob)
+    degen_ob = bpy.data.objects.new("Degen", degen)
+    bpy.context.collection.objects.link(degen_ob)
+    safe_path = export_glb(safe_ob, os.path.join(tmp, "safe.glb"))
+    degen_path = export_glb(degen_ob, os.path.join(tmp, "degen.glb"))
+    s_tri, s_min, s_pos = glb_degenerate_tris(safe_path)
+    d_tri, d_min, d_pos = glb_degenerate_tris(degen_path)
+    nloops = len(degen.loops)
+    print(f"glb safe_tris_degen={s_tri} degen_tris_degen={d_tri} "
+          f"(expected {EXPECT_GLB_DEGEN_TRIS}) positions={d_pos} loops={nloops}")
+    if s_tri != 0:
+        print("ERROR: safe GLB carries degenerate triangles", file=sys.stderr)
+        return 7
+    if d_tri != EXPECT_GLB_DEGEN_TRIS:
+        # MEASURED regression constant (like curve-bevel-arc EXPECT_VERTS):
+        # the 12 collapsed faces triangulate; if a future Blender changes
+        # triangulation, re-measure with glb_degenerate_tris() and update.
+        print(f"ERROR: GLB carries {d_tri} degenerate triangles, expected "
+              f"{EXPECT_GLB_DEGEN_TRIS} — re-measure if triangulation changed",
+              file=sys.stderr)
+        return 8
+    if d_pos != nloops:
+        print(f"ERROR: exported positions {d_pos} != loops {nloops} — the "
+              f"exporter welded unexpectedly for this shape", file=sys.stderr)
+        return 8
+
+    print(f"degenerate-bevel-weld OK threshold@{DIMS[1]/2} collapse "
+          f"{degen_za}f/{degen_co}v ships {d_tri} degenerate tris in the GLB")
+    return 0
+
+
+def make_material(name, rgb, rough=0.45, metallic=0.35, emit=None, estr=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 = (*rgb, 1.0)
+    b.inputs["Roughness"].default_value = rough
+    b.inputs["Metallic"].default_value = metallic
+    if emit is not None:
+        sock = b.inputs.get("Emission Color") or b.inputs["Emission"]
+        sock.default_value = (*emit, 1.0)
+        b.inputs["Emission Strength"].default_value = estr
+    return mat
+
+
+def _degen_face_centers(me, eps=AREA_EPS):
+    return [p.center.copy() for p in me.polygons if p.area <= eps]
+
+
+def render_still(path, engine):
+    """Dual tray: clean bevel vs collapsed band, with the zero-area faces
+    marked from live mesh data — the seam markers ARE the check's numbers."""
+    bpy.ops.wm.read_factory_settings(use_empty=True)
+    sc = bpy.context.scene
+
+    tray_mat = make_material("Tray", (0.10, 0.34, 0.34), rough=0.38, metallic=0.75)
+    seam_mat = make_material("SeamMark", (0.95, 0.25, 0.1), rough=0.4,
+                             metallic=0.1, emit=(1.0, 0.3, 0.1), estr=1.2)
+
+    safe_me = beveled_box(DIMS, SAFE_OFFSET)
+    safe_me.materials.append(tray_mat)
+    safe_ob = bpy.data.objects.new("SafeTray", safe_me)
+    safe_ob.location = (-1.15, 0.0, 0.55)
+    sc.collection.objects.link(safe_ob)
+
+    degen_me = beveled_box(DIMS, DEGEN_OFFSET)
+    degen_me.materials.append(tray_mat)
+    degen_ob = bpy.data.objects.new("DegenTray", degen_me)
+    degen_ob.location = (1.15, 0.0, 0.55)
+    sc.collection.objects.link(degen_ob)
+
+    # seam markers at every zero-area face centroid — overlay from live data
+    centers = _degen_face_centers(degen_me)
+    print(f"render_defects zero_area_faces={len(centers)}")
+    for i, co in enumerate(centers):
+        sme = bpy.data.meshes.new(f"Seam{i}")
+        sbm = bmesh.new()
+        try:
+            bmesh.ops.create_uvsphere(sbm, u_segments=8, v_segments=6, radius=0.022)
+            sbm.to_mesh(sme)
+        finally:
+            sbm.free()
+        sme.materials.append(seam_mat)
+        sob = bpy.data.objects.new(f"Seam{i}", sme)
+        sob.location = degen_ob.location + co
+        sc.collection.objects.link(sob)
+
+    p_safe = placard(sc, "BEVEL 0.10", (-1.15, -1.1, 0.02), size=0.13)
+    p_degen = placard(sc, "BEVEL 0.20 = min/2", (1.15, -1.1, 0.02), size=0.11)
+
+    floor, wall = build_studio(sc)
+
+    cam_data = bpy.data.cameras.new("Cam")
+    cam_data.lens = 50.0
+    cam = bpy.data.objects.new("Cam", cam_data)
+    cam.location = (0.0, -6.6, 2.9)
+    sc.collection.objects.link(cam)
+    aim = bpy.data.objects.new("Aim", None)
+    aim.location = (0.0, 0.0, 0.6)
+    sc.collection.objects.link(aim)
+    tr = cam.constraints.new("TRACK_TO")
+    tr.target = aim
+    tr.track_axis = "TRACK_NEGATIVE_Z"
+    tr.up_axis = "UP_Y"
+    sc.camera = cam
+
+    sc.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id()
+    if engine == "cycles":
+        sc.cycles.device = "CPU"
+        sc.cycles.samples = 64
+        sc.cycles.use_denoising = True
+    else:
+        try:
+            sc.eevee.taa_render_samples = 64
+        except AttributeError:
+            pass
+    sc.render.resolution_x = 1280
+    sc.render.resolution_y = 720
+    sc.render.image_settings.file_format = "PNG"
+    sc.render.filepath = path
+    # Standard, always — AgX would lift the stage toward grey (VISUAL-STYLE)
+    sc.view_settings.view_transform = "Standard"
+    # Layer 1 framing gate (silhouette matte) — exit 10 on violation.
+    hero = [safe_ob, degen_ob]
+    seams = [o for o in sc.objects if o.name.startswith("Seam")]
+    fcode = gallery_framing.check_framing(
+        sc, cam,
+        hero=hero,
+        elements=hero + [p_safe, p_degen] + seams,
+        stage=[floor, wall],
+    )
+    if fcode:
+        return fcode
+    bpy.ops.render.render(write_still=True)
+    if not (os.path.exists(path) and os.path.getsize(path) > 0):
+        print("ERROR: render produced no file", file=sys.stderr)
+        return 9
+    return 0
+
+
+def placard(sc, text, loc, size=0.18):
+    cu = bpy.data.curves.new(text, "FONT")
+    cu.body = text
+    cu.size = size
+    cu.align_x = "CENTER"
+    ob = bpy.data.objects.new(text, cu)
+    ob.location = loc
+    sc.collection.objects.link(ob)
+    ob.data.materials.append(make_material("Label", (0.9, 0.9, 0.92),
+                                           rough=0.6, metallic=0.0))
+    return ob
+
+
+def build_studio(sc):
+    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 = make_material("Studio", (0.03, 0.032, 0.037), rough=0.7, metallic=0.0)
+    floor_me.materials.append(fmat)
+    floor = bpy.data.objects.new("Floor", floor_me)
+    sc.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)
+    sc.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,
+    )
+    sc.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)
+        sc.collection.objects.link(ob)
+
+    light("Key", (-3.5, -4.5, 5.5), 480.0, 4.5, (1.0, 0.96, 0.9), (48, 0, -35))
+    light("Fill", (5.0, -3.5, 2.5), 120.0, 9.0, (0.75, 0.85, 1.0), (65, 0, 50))
+    light("Rim", (1.5, 4.5, 3.5), 280.0, 3.0, (0.6, 0.78, 1.0), (-55, 0, 170))
+    light("Wedge", (2.5, 5.5, 4.0), 400.0, 6.0, (1.0, 0.72, 0.42), (-68, 0, 190))
+    return floor, wall
+
+
+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)
+
+    print(f"binary version: {bpy.app.version} ({bpy.app.version_string})")
+    bpy.ops.wm.read_factory_settings(use_empty=True)
+    code = check()
+    if code:
+        return code
+
+    if args.output:
+        rcode = render_still(os.path.abspath(args.output), args.engine)
+        if rcode:
+            return rcode
+        print(f"rendered still {args.output}")
+
+    print("degenerate-bevel-weld 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 5a90d56..81e72e3 100644 --- a/docs/gallery/index.html +++ b/docs/gallery/index.html @@ -642,6 +642,17 @@

attribute-domain-shear

View example +
+ + degenerate-bevel-weld — Bevel offset >= half the min box dimension collapses the band into zero-area faces — and they ship: a stdlib GLB re-parse counts the degenerate triangles… + +
+

degenerate-bevel-weld

+

Bevel offset >= half the min box dimension collapses the band into zero-area faces — and they ship: a stdlib GLB re-parse counts the degenerate triangles crossing the export boundary. Dual tray: rounded edge versus knife seam, markers from live mesh data.

+

witnesses At offset == min_dimension/2 the bevel band pinches to exactly 12 zero-area faces (4 edges x 3 segments); 32 degenerate triangles cross into the exported GLB, waiting for any merge-by-distance to weld them.

+ View example +
+