Rendered headless by the example itself — click to zoom.
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_areacollapses 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 incheck()), 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
"""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)