@@ -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](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
zcmV-gB&FL@Nk&FeBme+cMM6+kP&gn)Bme+#b^)CMDgXu00zOeFkVhk;BcY&iC~$BJ
z32AQXxv>>DhVE}F|C{=@uH1$B;IAtK_3K`X{_lL)k4s17es6tJ4;Mzan7sUYbN?^;
zlkNffdDNrKUqW9%57&?X{>?tCzJMNDKe!(Y|Jpz7`djMtb?dp0^q-PFfWE=@hn!2%
z{nY%g_W#`P?EK>VTmJXdFY%w{f95!E^)K5$P>*GPkN(Gv{}2!6AJ@49C;{S;pqH$D
zgFjvW>vk~(mDJP(#vyTlT}?ntViz$73xo7cb@`ecyh7$6;duBzMFgXEX%mZv+xDZ(
zVuaLl9Y8qGUyrAwI4U}uo_wCWW;pMUiB2dbBLF#tBJ-6NZ>zAimwUu6Qx*Xf2=fw7
zIFvl96YlhqGyKM*Q<+Ckaz1M7qIO?A~kl{L1qdZnXMX}v2;ZN6S+
zx^CL-nB%ciABogrf>JdgIgZ*F7=lMm=V*nyQUNjxMFM(>EDU{c@*RB^so+-t~Y2!{4TnRUyW#h9;P9)hSRVE-bSmL%xZHQoX%%4t{)KYh!cpC>(}Yt
zJ8u@JnReV+n%o_;xQz{wp)p|>AUSx&QnH00?D&I)cc#~t$I4#znFK1+jE
z3&&~IQ_J3Uv~_F(>S_XH5&XtWgQ#m%9!{FVW;i8Vw&SSl^;N<$I<43o<#?($Ss8I$
zZz?LG(sch+yjk63dx5?z+CiwaTb;JWEL!agt3)(B0%@-h#@tn?R
zv(u!v(D=0r;(ArULN)94`u%>tP}0(Z*am1kV7z`mACJzXIh-C3GnvsjHWtK5>aAo|
zWxsSb7#~DnKv)UV!Jle1Iik*AaejC`wR1U~9%#6)-WpB6;~J9f`eoIkImxi=+GXg4
zqN^_pc@Y6l$8|9&i&Qd`cxu~EW)O31H_0P!=o-d(p=LgtXqojQfYnG#_APlM5xI>{
z1i=R+`Z_nc{xPH9JMLqENx+vD&9q-h7+@kTk=R^ZHrv%Tc60{MHX;pEa)WoL4z|OX3g<-nYn0Fs
zYZMqqcrZKgC%_vrb2I{i7Z5JDh_I~N!xbQCs3kp3I>FWzn`h4Rz0EJ#4M*{e3HSL~
z)I5)vREb|#k#)=UY!S1^VG2PEmO92=7(zV)PA(hkHbDYYaxWaC#7M%eV?`{D={RMt
z#sVv$Cc=$EJy+ddwORGmIYanaqyVJfmGS>Fr_CGyvOW0bBi4%Zz1OZNBiESTuXTjC
z$O_=QvQJk6a2Vm?XjfHP`CAovGzE|iOn#_svYU0q!_`T=F`DN$Ch-mAc6RpS8_513
zV)XLu8UN2!49bOv84_AFsSRM42rusy0ZYP|oMa*gKX*lFlFsuAo4{(N#aMQaQdb=Ww0#2^GZa1*&+C8nWppIYwB`CJesmWh!)nTh+FAaCw
z#`i(JohwxHJ2M;7hgUo5kU1zC-4Q}C+%W&P8r@h{Y!>T_i=-RJ40l3?ZK-NS8V>?u
z4IN9*
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.
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.
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:
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.
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(1for 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 isNoneor 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)
+ return3
+ 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)
+ return4
+ 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)
+ return5
+
+ # --- 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 != 0or degen_co != EXPECT_COINCIDENT:
+ print("ERROR: coincident-position count off the closed form",
+ file=sys.stderr)
+ return6
+
+ # --- 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)
+ return7
+ 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)
+ return8
+ if d_pos != nloops:
+ print(f"ERROR: exported positions {d_pos} != loops {nloops} — the "
+ f"exporter welded unexpectedly for this shape", file=sys.stderr)
+ return8
+
+ 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")
+ return0
+
+
+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 isnotNone:
+ 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)
+ ifnot (os.path.exists(path) and os.path.getsize(path) > 0):
+ print("ERROR: render produced no file", file=sys.stderr)
+ return9
+ return0
+
+
+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")
+ return0
+
+
+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)
+
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.