From e30e0b85011c8ec86e5cc720a891c65c82c3e99b Mon Sep 17 00:00:00 2001 From: fOuttaMyPaint Date: Thu, 23 Jul 2026 19:44:14 -0400 Subject: [PATCH] feat: add degenerate-bevel-weld example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Isolates the half-dimension bevel collapse found authoring gltf-export-roundtrip: a slab beveled at offset == min_dim/2 pinches its band into exactly 12 zero-area faces (4 min-axis edges x 3 segments) with 16 coincident verts (4 x (segments+1)) and min_area down 3.2e5x, while the safe offset yields none. The export angle is the point: a stdlib re-parse of the shipped GLB recomputes every triangle area from raw POSITION + indices buffers and counts 32 degenerate triangles crossing the boundary (MEASURED regression constant, re-measure recipe in check) versus 0 for the safe mesh — the loops an engine merge-by-distance welds. Probed five regimes to ground the closed forms (clamp on/off x safe/half/over, modifier vs bmesh path). Byte-identical on 4.5.11 and 5.1.2. Falsified: safe offset at threshold (exit 3), degenerate offset below it (exit 4), coincident closed form corrupted (exit 6), GLB census off (exit 8). Render path gated with check_framing (0.794x/0.356y, margins >= 0.100); dual tray with live-data seam markers. Count prose bumped to 40 across README/CLAUDE/AGENTS. Signed-off-by: fOuttaMyPaint --- .cursor-plugin/plugin.json | 1 + .github/workflows/blender-smoke.yml | 12 + AGENTS.md | 4 +- CLAUDE.md | 6 +- README.md | 22 +- ROADMAP.md | 2 +- .../assets/degenerate-bevel-weld-hero.webp | Bin 0 -> 9306 bytes .../degenerate-bevel-weld-contact-sheet.webp | Bin 0 -> 36136 bytes docs/gallery/degenerate-bevel-weld/index.html | 664 ++++++++++++++++++ docs/gallery/index.html | 11 + examples/degenerate-bevel-weld/README.md | 59 ++ .../degenerate_bevel_weld.py | 420 +++++++++++ examples/degenerate-bevel-weld/preview.webp | Bin 0 -> 8648 bytes examples/gallery.json | 13 + 14 files changed, 1207 insertions(+), 7 deletions(-) create mode 100644 docs/gallery/assets/degenerate-bevel-weld-hero.webp create mode 100644 docs/gallery/contact-sheets/degenerate-bevel-weld-contact-sheet.webp create mode 100644 docs/gallery/degenerate-bevel-weld/index.html create mode 100644 examples/degenerate-bevel-weld/README.md create mode 100644 examples/degenerate-bevel-weld/degenerate_bevel_weld.py create mode 100644 examples/degenerate-bevel-weld/preview.webp 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 0000000000000000000000000000000000000000..366195191e8ccb9c8ebc680c57b20da73fe4f5d9 GIT binary patch literal 9306 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b5748908a136b1050253aef5184052525a057c66 GIT binary patch literal 36136 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 Q4E|Ru?-Vq0g#Z8m00`6%3jhEB literal 0 HcmV?d00001 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 +
+